feat(ui): LED projet — exploite les signaux d'interaction CLI custom (#198, QA verte)
Projette l'état d'interaction agent→projet sur la LED via des signaux explicites de la CLI custom: - projectCliInteractionActivity: modèle d'activité d'interaction (InteractionStarted/Finished/Stopped, interactionId/seq, version). - useProjectActivity + ProjectTabs: projection live « interaction en cours » → LED, retour immédiat à l'état attendu au stop ou à la fin réelle de l'échange (pas d'état collant). - CustomAgentChatView: émet les signaux d'interaction CLI custom (début d'envoi, fin de tour, stop) consommés par l'activité projet — sans dériver la LED du texte streamé ou d'heuristiques. Tout côté frontend: aucun signal runtime manquant (les signaux proviennent de la CLI custom). Tests QA verts: projectActivity.test.ts, ProjectTabs.test.tsx (+ useProjectActivity.test.tsx, CustomAgentChatView.test.tsx).
This commit is contained in:
@ -81,9 +81,7 @@ describe("ProjectTabs project activity dots (#159)", () => {
|
||||
renderTabs({ items: TABS });
|
||||
|
||||
const tab = screen.getByRole("tab", { name: /alpha/ });
|
||||
expect(
|
||||
within(tab).getByTitle("alpha : aucune conversation active"),
|
||||
).toBeTruthy();
|
||||
expect(within(tab).getByTitle("alpha : Prêt")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders idle and busy project activity states", () => {
|
||||
@ -92,7 +90,7 @@ describe("ProjectTabs project activity dots (#159)", () => {
|
||||
activityByProjectId: { a: "idle", b: "busy" },
|
||||
});
|
||||
|
||||
expect(screen.getByTitle("alpha : conversation ouverte")).toBeTruthy();
|
||||
expect(screen.getByTitle("beta : activité en cours")).toBeTruthy();
|
||||
expect(screen.getByTitle("alpha : Prêt")).toBeTruthy();
|
||||
expect(screen.getByTitle("beta : Échange en cours")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@ -96,10 +96,8 @@ function ProjectActivityDot({
|
||||
}) {
|
||||
const title =
|
||||
status === "busy"
|
||||
? `${label} : activité en cours`
|
||||
: status === "idle"
|
||||
? `${label} : conversation ouverte`
|
||||
: `${label} : aucune conversation active`;
|
||||
? `${label} : Échange en cours`
|
||||
: `${label} : Prêt`;
|
||||
return (
|
||||
<span
|
||||
data-testid={`project-activity-${label}`}
|
||||
@ -110,7 +108,7 @@ function ProjectActivityDot({
|
||||
status === "busy" &&
|
||||
"animate-pulse border-warning bg-warning shadow-[0_0_0_2px_rgba(212,155,58,0.16)]",
|
||||
status === "idle" && "border-success bg-success",
|
||||
status === "none" && "border-border-strong bg-muted",
|
||||
status === "none" && "border-success bg-success",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -1,6 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { projectActivityStatus } from "./projectActivity";
|
||||
import {
|
||||
__resetProjectCliInteractionActivityForTests,
|
||||
hasProjectCliInteraction,
|
||||
markProjectCliInteractionFinished,
|
||||
markProjectCliInteractionStarted,
|
||||
markProjectCliInteractionStopped,
|
||||
} from "./projectCliInteractionActivity";
|
||||
|
||||
describe("projectActivityStatus (#159)", () => {
|
||||
it("is none when no custom CLI conversation is visible in the work-state", () => {
|
||||
@ -74,3 +81,28 @@ describe("projectActivityStatus (#159)", () => {
|
||||
).toBe("busy");
|
||||
});
|
||||
});
|
||||
|
||||
describe("project CLI interaction activity (#198)", () => {
|
||||
it("keeps a project active until every overlapping interaction is finished", () => {
|
||||
__resetProjectCliInteractionActivityForTests();
|
||||
|
||||
markProjectCliInteractionStarted("p1", "turn-1");
|
||||
markProjectCliInteractionStarted("p1", "turn-2");
|
||||
expect(hasProjectCliInteraction("p1")).toBe(true);
|
||||
|
||||
markProjectCliInteractionFinished("p1", "turn-1");
|
||||
expect(hasProjectCliInteraction("p1")).toBe(true);
|
||||
|
||||
markProjectCliInteractionFinished("p1", "turn-2");
|
||||
expect(hasProjectCliInteraction("p1")).toBe(false);
|
||||
});
|
||||
|
||||
it("stopped clears the explicit interaction immediately", () => {
|
||||
__resetProjectCliInteractionActivityForTests();
|
||||
|
||||
markProjectCliInteractionStarted("p1", "turn-1");
|
||||
markProjectCliInteractionStopped("p1", "turn-1");
|
||||
|
||||
expect(hasProjectCliInteraction("p1")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@ -0,0 +1,71 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
const activeByProject = new Map<string, Set<string>>();
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
function emit(): void {
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
function activeSet(projectId: string): Set<string> {
|
||||
let set = activeByProject.get(projectId);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
activeByProject.set(projectId, set);
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
export function markProjectCliInteractionStarted(
|
||||
projectId: string,
|
||||
interactionId: string,
|
||||
): void {
|
||||
const set = activeSet(projectId);
|
||||
const before = set.size;
|
||||
set.add(interactionId);
|
||||
if (set.size !== before) emit();
|
||||
}
|
||||
|
||||
export function markProjectCliInteractionFinished(
|
||||
projectId: string,
|
||||
interactionId: string,
|
||||
): void {
|
||||
const set = activeByProject.get(projectId);
|
||||
if (!set?.delete(interactionId)) return;
|
||||
if (set.size === 0) activeByProject.delete(projectId);
|
||||
emit();
|
||||
}
|
||||
|
||||
export function markProjectCliInteractionStopped(
|
||||
projectId: string,
|
||||
interactionId: string,
|
||||
): void {
|
||||
markProjectCliInteractionFinished(projectId, interactionId);
|
||||
}
|
||||
|
||||
export function hasProjectCliInteraction(projectId: string): boolean {
|
||||
return (activeByProject.get(projectId)?.size ?? 0) > 0;
|
||||
}
|
||||
|
||||
export function subscribeProjectCliInteractionActivity(
|
||||
listener: Listener,
|
||||
): () => void {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
export function useProjectCliInteractionActivityVersion(): number {
|
||||
const [version, setVersion] = useState(0);
|
||||
useEffect(
|
||||
() => subscribeProjectCliInteractionActivity(() => setVersion((v) => v + 1)),
|
||||
[],
|
||||
);
|
||||
return version;
|
||||
}
|
||||
|
||||
export function __resetProjectCliInteractionActivityForTests(): void {
|
||||
activeByProject.clear();
|
||||
emit();
|
||||
}
|
||||
48
frontend/src/features/projects/useProjectActivity.test.tsx
Normal file
48
frontend/src/features/projects/useProjectActivity.test.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
import { act, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { DIProvider } from "@/app/di";
|
||||
import { MockWorkStateGateway } from "@/adapters/mock";
|
||||
import type { Gateways } from "@/ports";
|
||||
import {
|
||||
__resetProjectCliInteractionActivityForTests,
|
||||
markProjectCliInteractionStarted,
|
||||
markProjectCliInteractionStopped,
|
||||
} from "./projectCliInteractionActivity";
|
||||
import { useProjectActivity } from "./useProjectActivity";
|
||||
|
||||
function ActivityProbe({ projectId }: { projectId: string }) {
|
||||
const activity = useProjectActivity([projectId]);
|
||||
return <span data-testid="activity">{activity[projectId]}</span>;
|
||||
}
|
||||
|
||||
describe("useProjectActivity CLI interaction projection (#198)", () => {
|
||||
beforeEach(() => {
|
||||
__resetProjectCliInteractionActivityForTests();
|
||||
});
|
||||
|
||||
it("projects explicit CLI interaction signals over the work-state snapshot", async () => {
|
||||
const projectId = "project-1";
|
||||
const workState = new MockWorkStateGateway();
|
||||
workState._setProjectWorkState(projectId, { agents: [], conversations: [] });
|
||||
act(() => {
|
||||
markProjectCliInteractionStarted(projectId, "turn-1");
|
||||
});
|
||||
|
||||
render(
|
||||
<DIProvider gateways={{ workState } as unknown as Gateways}>
|
||||
<ActivityProbe projectId={projectId} />
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
expect((await screen.findByTestId("activity")).textContent).toBe("busy");
|
||||
|
||||
act(() => {
|
||||
markProjectCliInteractionStopped(projectId, "turn-1");
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("activity").textContent).toBe("none"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -6,6 +6,10 @@ import {
|
||||
projectActivityStatus,
|
||||
type ProjectActivityStatus,
|
||||
} from "./projectActivity";
|
||||
import {
|
||||
hasProjectCliInteraction,
|
||||
useProjectCliInteractionActivityVersion,
|
||||
} from "./projectCliInteractionActivity";
|
||||
|
||||
export type ProjectActivityMap = Record<string, ProjectActivityStatus>;
|
||||
|
||||
@ -31,6 +35,7 @@ export function useProjectActivity(projectIds: string[]): ProjectActivityMap {
|
||||
const [states, setStates] = useState<Record<string, ProjectWorkState | null>>(
|
||||
{},
|
||||
);
|
||||
const cliInteractionVersion = useProjectCliInteractionActivityVersion();
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (stableProjectIds.length === 0) {
|
||||
@ -77,8 +82,10 @@ export function useProjectActivity(projectIds: string[]): ProjectActivityMap {
|
||||
return useMemo(() => {
|
||||
const next: ProjectActivityMap = {};
|
||||
for (const projectId of stableProjectIds) {
|
||||
next[projectId] = projectActivityStatus(states[projectId]);
|
||||
next[projectId] = hasProjectCliInteraction(projectId)
|
||||
? "busy"
|
||||
: projectActivityStatus(states[projectId]);
|
||||
}
|
||||
return next;
|
||||
}, [stableProjectIds, states]);
|
||||
}, [cliInteractionVersion, stableProjectIds, states]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user