merge(ui): LED projet — exploite les signaux d'interaction CLI custom (#198, QA verte)

This commit is contained in:
2026-08-12 15:44:41 +02:00
8 changed files with 226 additions and 14 deletions

View File

@ -5,6 +5,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { DIProvider } from "@/app/di"; import { DIProvider } from "@/app/di";
import type { AgentProfile, ReplyChunk } from "@/domain"; import type { AgentProfile, ReplyChunk } from "@/domain";
import { PluginRuntimeProvider } from "@/features/plugins"; import { PluginRuntimeProvider } from "@/features/plugins";
import {
__resetProjectCliInteractionActivityForTests,
hasProjectCliInteraction,
} from "@/features/projects/projectCliInteractionActivity";
import { import {
PluginCommandRegistry, PluginCommandRegistry,
PluginLayoutRegistry, PluginLayoutRegistry,
@ -71,6 +75,7 @@ describe("CustomAgentChatView", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
__clearCustomAgentChatViewCacheForTests(); __clearCustomAgentChatViewCacheForTests();
__resetProjectCliInteractionActivityForTests();
}); });
it("repaints attachment labels from user prompt scrollback", async () => { it("repaints attachment labels from user prompt scrollback", async () => {
@ -381,6 +386,7 @@ describe("CustomAgentChatView", () => {
expect.any(Function), expect.any(Function),
), ),
); );
expect(hasProjectCliInteraction("project-1")).toBe(true);
expect(screen.queryByRole("button", { name: "Cancel" })).toBeNull(); expect(screen.queryByRole("button", { name: "Cancel" })).toBeNull();
fireEvent.click(await screen.findByRole("button", { name: "Stop" })); fireEvent.click(await screen.findByRole("button", { name: "Stop" }));
@ -396,6 +402,7 @@ describe("CustomAgentChatView", () => {
kind: "structured", kind: "structured",
}); });
await waitFor(() => expect(onSessionId).toHaveBeenCalledWith(null)); await waitFor(() => expect(onSessionId).toHaveBeenCalledWith(null));
expect(hasProjectCliInteraction("project-1")).toBe(false);
expect(agent.cancelAgentChat).not.toHaveBeenCalled(); expect(agent.cancelAgentChat).not.toHaveBeenCalled();
expect(agent.closeAgentChat).not.toHaveBeenCalled(); expect(agent.closeAgentChat).not.toHaveBeenCalled();
expect( expect(
@ -514,6 +521,7 @@ describe("CustomAgentChatView", () => {
fireEvent.click(screen.getByRole("button", { name: "Envoyer" })); fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
await screen.findByText("done"); await screen.findByText("done");
expect(hasProjectCliInteraction("project-1")).toBe(false);
expect(screen.getAllByText("hello agent")).toHaveLength(1); expect(screen.getAllByText("hello agent")).toHaveLength(1);
expect(screen.getByText("Terminé").parentElement?.parentElement?.className).toContain( expect(screen.getByText("Terminé").parentElement?.parentElement?.className).toContain(
"justify-end", "justify-end",

View File

@ -27,6 +27,11 @@ import type {
} from "@/domain"; } from "@/domain";
import { useGateways } from "@/app/di"; import { useGateways } from "@/app/di";
import { usePluginRuntime } from "@/features/plugins/PluginRuntimeProvider"; import { usePluginRuntime } from "@/features/plugins/PluginRuntimeProvider";
import {
markProjectCliInteractionFinished,
markProjectCliInteractionStarted,
markProjectCliInteractionStopped,
} from "@/features/projects/projectCliInteractionActivity";
import { Button, Spinner, cn } from "@/shared"; import { Button, Spinner, cn } from "@/shared";
import type { ChatAttachmentInput, ChatDraftScope } from "@/ports"; import type { ChatAttachmentInput, ChatDraftScope } from "@/ports";
@ -638,6 +643,8 @@ export function CustomAgentChatView({
detachAgentChatRef.current = agent.detachAgentChat; detachAgentChatRef.current = agent.detachAgentChat;
const slashQuerySeqRef = useRef(0); const slashQuerySeqRef = useRef(0);
const draftSaveSeqRef = useRef(0); const draftSaveSeqRef = useRef(0);
const interactionSeqRef = useRef(0);
const activeInteractionIdRef = useRef<string | null>(null);
const draftHydratedRef = useRef(false); const draftHydratedRef = useRef(false);
const draftEditedBeforeHydrationRef = useRef(false); const draftEditedBeforeHydrationRef = useRef(false);
const lastPersistedDraftRef = useRef(""); const lastPersistedDraftRef = useRef("");
@ -731,10 +738,33 @@ export function CustomAgentChatView({
const receive = useCallback( const receive = useCallback(
(chunk: ReplyChunk) => { (chunk: ReplyChunk) => {
scrollbackChunkCountRef.current += 1; 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)); 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(() => { useEffect(() => {
@ -1328,6 +1358,14 @@ export function CustomAgentChatView({
const attachmentInputs = outgoingAttachments.map((item) => item.input); const attachmentInputs = outgoingAttachments.map((item) => item.input);
const attachmentLabels = outgoingAttachments.map((item) => item.label); const attachmentLabels = outgoingAttachments.map((item) => item.label);
const displayText = text || "Pièce jointe"; 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(""); setDraft("");
setAttachments([]); setAttachments([]);
void clearPersistedDraft(); void clearPersistedDraft();
@ -1356,6 +1394,7 @@ export function CustomAgentChatView({
retryAttachNotFound: true, retryAttachNotFound: true,
})); }));
await sendTurn(sid); await sendTurn(sid);
finishInteraction();
} catch (e) { } catch (e) {
if (isNotFound(e)) { if (isNotFound(e)) {
try { try {
@ -1366,8 +1405,10 @@ export function CustomAgentChatView({
retryAttachNotFound: true, retryAttachNotFound: true,
}); });
await sendTurn(recoveredSession); await sendTurn(recoveredSession);
finishInteraction();
return; return;
} catch (recoveryError) { } catch (recoveryError) {
finishInteraction();
setBusy(false); setBusy(false);
setError(describe(recoveryError)); setError(describe(recoveryError));
updateChatViewState((prev) => ({ updateChatViewState((prev) => ({
@ -1377,6 +1418,7 @@ export function CustomAgentChatView({
return; return;
} }
} }
finishInteraction();
setBusy(false); setBusy(false);
setError(describe(e)); setError(describe(e));
updateChatViewState((prev) => ({ updateChatViewState((prev) => ({
@ -1393,6 +1435,10 @@ export function CustomAgentChatView({
setError(null); setError(null);
try { try {
const stopped = await agent.stopLiveAgent(projectId, agentId); const stopped = await agent.stopLiveAgent(projectId, agentId);
if (activeInteractionIdRef.current) {
markProjectCliInteractionStopped(projectId, activeInteractionIdRef.current);
activeInteractionIdRef.current = null;
}
setBusy(false); setBusy(false);
setOpening(false); setOpening(false);
setCurrentSession(null); setCurrentSession(null);
@ -1415,6 +1461,10 @@ export function CustomAgentChatView({
}); });
} catch (e) { } catch (e) {
if (isNotFound(e)) { if (isNotFound(e)) {
if (activeInteractionIdRef.current) {
markProjectCliInteractionStopped(projectId, activeInteractionIdRef.current);
activeInteractionIdRef.current = null;
}
setBusy(false); setBusy(false);
setOpening(false); setOpening(false);
setCurrentSession(null); setCurrentSession(null);

View File

@ -81,9 +81,7 @@ describe("ProjectTabs project activity dots (#159)", () => {
renderTabs({ items: TABS }); renderTabs({ items: TABS });
const tab = screen.getByRole("tab", { name: /alpha/ }); const tab = screen.getByRole("tab", { name: /alpha/ });
expect( expect(within(tab).getByTitle("alpha : Prêt")).toBeTruthy();
within(tab).getByTitle("alpha : aucune conversation active"),
).toBeTruthy();
}); });
it("renders idle and busy project activity states", () => { it("renders idle and busy project activity states", () => {
@ -92,7 +90,7 @@ describe("ProjectTabs project activity dots (#159)", () => {
activityByProjectId: { a: "idle", b: "busy" }, activityByProjectId: { a: "idle", b: "busy" },
}); });
expect(screen.getByTitle("alpha : conversation ouverte")).toBeTruthy(); expect(screen.getByTitle("alpha : Prêt")).toBeTruthy();
expect(screen.getByTitle("beta : activité en cours")).toBeTruthy(); expect(screen.getByTitle("beta : Échange en cours")).toBeTruthy();
}); });
}); });

View File

@ -96,10 +96,8 @@ function ProjectActivityDot({
}) { }) {
const title = const title =
status === "busy" status === "busy"
? `${label} : activité en cours` ? `${label} : Échange en cours`
: status === "idle" : `${label} : Prêt`;
? `${label} : conversation ouverte`
: `${label} : aucune conversation active`;
return ( return (
<span <span
data-testid={`project-activity-${label}`} data-testid={`project-activity-${label}`}
@ -110,7 +108,7 @@ function ProjectActivityDot({
status === "busy" && status === "busy" &&
"animate-pulse border-warning bg-warning shadow-[0_0_0_2px_rgba(212,155,58,0.16)]", "animate-pulse border-warning bg-warning shadow-[0_0_0_2px_rgba(212,155,58,0.16)]",
status === "idle" && "border-success bg-success", status === "idle" && "border-success bg-success",
status === "none" && "border-border-strong bg-muted", status === "none" && "border-success bg-success",
)} )}
/> />
); );

View File

@ -1,6 +1,13 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { projectActivityStatus } from "./projectActivity"; import { projectActivityStatus } from "./projectActivity";
import {
__resetProjectCliInteractionActivityForTests,
hasProjectCliInteraction,
markProjectCliInteractionFinished,
markProjectCliInteractionStarted,
markProjectCliInteractionStopped,
} from "./projectCliInteractionActivity";
describe("projectActivityStatus (#159)", () => { describe("projectActivityStatus (#159)", () => {
it("is none when no custom CLI conversation is visible in the work-state", () => { it("is none when no custom CLI conversation is visible in the work-state", () => {
@ -74,3 +81,28 @@ describe("projectActivityStatus (#159)", () => {
).toBe("busy"); ).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);
});
});

View File

@ -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();
}

View 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"),
);
});
});

View File

@ -6,6 +6,10 @@ import {
projectActivityStatus, projectActivityStatus,
type ProjectActivityStatus, type ProjectActivityStatus,
} from "./projectActivity"; } from "./projectActivity";
import {
hasProjectCliInteraction,
useProjectCliInteractionActivityVersion,
} from "./projectCliInteractionActivity";
export type ProjectActivityMap = Record<string, ProjectActivityStatus>; 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 [states, setStates] = useState<Record<string, ProjectWorkState | null>>(
{}, {},
); );
const cliInteractionVersion = useProjectCliInteractionActivityVersion();
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
if (stableProjectIds.length === 0) { if (stableProjectIds.length === 0) {
@ -77,8 +82,10 @@ export function useProjectActivity(projectIds: string[]): ProjectActivityMap {
return useMemo(() => { return useMemo(() => {
const next: ProjectActivityMap = {}; const next: ProjectActivityMap = {};
for (const projectId of stableProjectIds) { for (const projectId of stableProjectIds) {
next[projectId] = projectActivityStatus(states[projectId]); next[projectId] = hasProjectCliInteraction(projectId)
? "busy"
: projectActivityStatus(states[projectId]);
} }
return next; return next;
}, [stableProjectIds, states]); }, [cliInteractionVersion, stableProjectIds, states]);
} }