From b167226179b008522ab851d13f2e05ff68a94042 Mon Sep 17 00:00:00 2001 From: Blomios Date: Wed, 12 Aug 2026 15:44:30 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui):=20LED=20projet=20=E2=80=94=20exploite?= =?UTF-8?q?=20les=20signaux=20d'interaction=20CLI=20custom=20(#198,=20QA?= =?UTF-8?q?=20verte)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../agents/CustomAgentChatView.test.tsx | 8 +++ .../features/agents/CustomAgentChatView.tsx | 54 +++++++++++++- .../features/projects/ProjectTabs.test.tsx | 8 +-- .../src/features/projects/ProjectTabs.tsx | 8 +-- .../features/projects/projectActivity.test.ts | 32 +++++++++ .../projects/projectCliInteractionActivity.ts | 71 +++++++++++++++++++ .../projects/useProjectActivity.test.tsx | 48 +++++++++++++ .../features/projects/useProjectActivity.ts | 11 ++- 8 files changed, 226 insertions(+), 14 deletions(-) create mode 100644 frontend/src/features/projects/projectCliInteractionActivity.ts create mode 100644 frontend/src/features/projects/useProjectActivity.test.tsx diff --git a/frontend/src/features/agents/CustomAgentChatView.test.tsx b/frontend/src/features/agents/CustomAgentChatView.test.tsx index 7c30d29a..95f29984 100644 --- a/frontend/src/features/agents/CustomAgentChatView.test.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.test.tsx @@ -5,6 +5,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { DIProvider } from "@/app/di"; import type { AgentProfile, ReplyChunk } from "@/domain"; import { PluginRuntimeProvider } from "@/features/plugins"; +import { + __resetProjectCliInteractionActivityForTests, + hasProjectCliInteraction, +} from "@/features/projects/projectCliInteractionActivity"; import { PluginCommandRegistry, PluginLayoutRegistry, @@ -71,6 +75,7 @@ describe("CustomAgentChatView", () => { beforeEach(() => { vi.clearAllMocks(); __clearCustomAgentChatViewCacheForTests(); + __resetProjectCliInteractionActivityForTests(); }); it("repaints attachment labels from user prompt scrollback", async () => { @@ -381,6 +386,7 @@ describe("CustomAgentChatView", () => { expect.any(Function), ), ); + expect(hasProjectCliInteraction("project-1")).toBe(true); expect(screen.queryByRole("button", { name: "Cancel" })).toBeNull(); fireEvent.click(await screen.findByRole("button", { name: "Stop" })); @@ -396,6 +402,7 @@ describe("CustomAgentChatView", () => { kind: "structured", }); await waitFor(() => expect(onSessionId).toHaveBeenCalledWith(null)); + expect(hasProjectCliInteraction("project-1")).toBe(false); expect(agent.cancelAgentChat).not.toHaveBeenCalled(); expect(agent.closeAgentChat).not.toHaveBeenCalled(); expect( @@ -514,6 +521,7 @@ describe("CustomAgentChatView", () => { fireEvent.click(screen.getByRole("button", { name: "Envoyer" })); await screen.findByText("done"); + expect(hasProjectCliInteraction("project-1")).toBe(false); expect(screen.getAllByText("hello agent")).toHaveLength(1); expect(screen.getByText("Terminé").parentElement?.parentElement?.className).toContain( "justify-end", diff --git a/frontend/src/features/agents/CustomAgentChatView.tsx b/frontend/src/features/agents/CustomAgentChatView.tsx index 3c5a3c2e..7935043a 100644 --- a/frontend/src/features/agents/CustomAgentChatView.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.tsx @@ -27,6 +27,11 @@ import type { } from "@/domain"; import { useGateways } from "@/app/di"; import { usePluginRuntime } from "@/features/plugins/PluginRuntimeProvider"; +import { + markProjectCliInteractionFinished, + markProjectCliInteractionStarted, + markProjectCliInteractionStopped, +} from "@/features/projects/projectCliInteractionActivity"; import { Button, Spinner, cn } from "@/shared"; import type { ChatAttachmentInput, ChatDraftScope } from "@/ports"; @@ -638,6 +643,8 @@ export function CustomAgentChatView({ detachAgentChatRef.current = agent.detachAgentChat; const slashQuerySeqRef = useRef(0); const draftSaveSeqRef = useRef(0); + const interactionSeqRef = useRef(0); + const activeInteractionIdRef = useRef(null); const draftHydratedRef = useRef(false); const draftEditedBeforeHydrationRef = useRef(false); const lastPersistedDraftRef = useRef(""); @@ -731,10 +738,33 @@ export function CustomAgentChatView({ const receive = useCallback( (chunk: ReplyChunk) => { scrollbackChunkCountRef.current += 1; + const activeInteractionId = activeInteractionIdRef.current; + if ( + activeInteractionId && + chunk.kind === "progress" && + chunk.progress.kind === "turn" + ) { + if ( + chunk.progress.stage === "started" || + chunk.progress.stage === "delta" + ) { + markProjectCliInteractionStarted(projectId, activeInteractionId); + } + if (chunk.progress.stage === "completed") { + markProjectCliInteractionFinished(projectId, activeInteractionId); + activeInteractionIdRef.current = null; + } + } updateChatViewState((prev) => foldChunk(prev, chunk)); - if (chunk.kind === "final" || chunk.kind === "error") setBusy(false); + if (chunk.kind === "final" || chunk.kind === "error") { + if (activeInteractionIdRef.current) { + markProjectCliInteractionFinished(projectId, activeInteractionIdRef.current); + activeInteractionIdRef.current = null; + } + setBusy(false); + } }, - [updateChatViewState], + [projectId, updateChatViewState], ); useEffect(() => { @@ -1328,6 +1358,14 @@ export function CustomAgentChatView({ const attachmentInputs = outgoingAttachments.map((item) => item.input); const attachmentLabels = outgoingAttachments.map((item) => item.label); const displayText = text || "Pièce jointe"; + const interactionId = `${agentId}:${++interactionSeqRef.current}`; + activeInteractionIdRef.current = interactionId; + markProjectCliInteractionStarted(projectId, interactionId); + const finishInteraction = () => { + if (activeInteractionIdRef.current !== interactionId) return; + markProjectCliInteractionFinished(projectId, interactionId); + activeInteractionIdRef.current = null; + }; setDraft(""); setAttachments([]); void clearPersistedDraft(); @@ -1356,6 +1394,7 @@ export function CustomAgentChatView({ retryAttachNotFound: true, })); await sendTurn(sid); + finishInteraction(); } catch (e) { if (isNotFound(e)) { try { @@ -1366,8 +1405,10 @@ export function CustomAgentChatView({ retryAttachNotFound: true, }); await sendTurn(recoveredSession); + finishInteraction(); return; } catch (recoveryError) { + finishInteraction(); setBusy(false); setError(describe(recoveryError)); updateChatViewState((prev) => ({ @@ -1377,6 +1418,7 @@ export function CustomAgentChatView({ return; } } + finishInteraction(); setBusy(false); setError(describe(e)); updateChatViewState((prev) => ({ @@ -1393,6 +1435,10 @@ export function CustomAgentChatView({ setError(null); try { const stopped = await agent.stopLiveAgent(projectId, agentId); + if (activeInteractionIdRef.current) { + markProjectCliInteractionStopped(projectId, activeInteractionIdRef.current); + activeInteractionIdRef.current = null; + } setBusy(false); setOpening(false); setCurrentSession(null); @@ -1415,6 +1461,10 @@ export function CustomAgentChatView({ }); } catch (e) { if (isNotFound(e)) { + if (activeInteractionIdRef.current) { + markProjectCliInteractionStopped(projectId, activeInteractionIdRef.current); + activeInteractionIdRef.current = null; + } setBusy(false); setOpening(false); setCurrentSession(null); diff --git a/frontend/src/features/projects/ProjectTabs.test.tsx b/frontend/src/features/projects/ProjectTabs.test.tsx index e5585ece..7b6fa4bf 100644 --- a/frontend/src/features/projects/ProjectTabs.test.tsx +++ b/frontend/src/features/projects/ProjectTabs.test.tsx @@ -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(); }); }); diff --git a/frontend/src/features/projects/ProjectTabs.tsx b/frontend/src/features/projects/ProjectTabs.tsx index 468e54da..febdc6da 100644 --- a/frontend/src/features/projects/ProjectTabs.tsx +++ b/frontend/src/features/projects/ProjectTabs.tsx @@ -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 ( ); diff --git a/frontend/src/features/projects/projectActivity.test.ts b/frontend/src/features/projects/projectActivity.test.ts index f957ac1d..42031eb3 100644 --- a/frontend/src/features/projects/projectActivity.test.ts +++ b/frontend/src/features/projects/projectActivity.test.ts @@ -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); + }); +}); diff --git a/frontend/src/features/projects/projectCliInteractionActivity.ts b/frontend/src/features/projects/projectCliInteractionActivity.ts new file mode 100644 index 00000000..8e384c67 --- /dev/null +++ b/frontend/src/features/projects/projectCliInteractionActivity.ts @@ -0,0 +1,71 @@ +import { useEffect, useState } from "react"; + +type Listener = () => void; + +const activeByProject = new Map>(); +const listeners = new Set(); + +function emit(): void { + for (const listener of listeners) listener(); +} + +function activeSet(projectId: string): Set { + 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(); +} diff --git a/frontend/src/features/projects/useProjectActivity.test.tsx b/frontend/src/features/projects/useProjectActivity.test.tsx new file mode 100644 index 00000000..aa8cf90b --- /dev/null +++ b/frontend/src/features/projects/useProjectActivity.test.tsx @@ -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 {activity[projectId]}; +} + +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( + + + , + ); + + expect((await screen.findByTestId("activity")).textContent).toBe("busy"); + + act(() => { + markProjectCliInteractionStopped(projectId, "turn-1"); + }); + + await waitFor(() => + expect(screen.getByTestId("activity").textContent).toBe("none"), + ); + }); +}); diff --git a/frontend/src/features/projects/useProjectActivity.ts b/frontend/src/features/projects/useProjectActivity.ts index 64d797a7..19151bbb 100644 --- a/frontend/src/features/projects/useProjectActivity.ts +++ b/frontend/src/features/projects/useProjectActivity.ts @@ -6,6 +6,10 @@ import { projectActivityStatus, type ProjectActivityStatus, } from "./projectActivity"; +import { + hasProjectCliInteraction, + useProjectCliInteractionActivityVersion, +} from "./projectCliInteractionActivity"; export type ProjectActivityMap = Record; @@ -31,6 +35,7 @@ export function useProjectActivity(projectIds: string[]): ProjectActivityMap { const [states, setStates] = useState>( {}, ); + 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]); }