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:
2026-08-12 15:44:30 +02:00
parent b38f5b6f14
commit b167226179
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 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",

View File

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