fix(chat): réattache persistante CLI custom et rendu tools/MCP/progress enrichi (#188)

Corrige la perte de session à la navigation: le démontage du CustomAgentChatView
appelait close_agent_session (destructeur) au lieu de simplement détacher le
transport. Désormais:

- nouvelle commande Tauri detach_agent_chat (+ ChatBridge::detach) qui libère le
  canal sans tuer la session ni le scrollback retenu; distinct de
  close_agent_session qui reste le close destructeur explicite;
- le frontend appelle detachAgentChat à l'unmount puis restore sa vue depuis un
  cache de session (turns + chunkCount) à la réattache;
- message « Reprise effectuée: N événements rattrapés… » quand la réattache
  rejoue plus de chunks que le cache local n'en avait conservé.

Enrichit le rendu tools/MCP/progress:

- ProgressSettler (chat bridge) émet un progress completed synthétique quand un
  spinner reste ouvert (nouvelle entité progress, chunk text/final/error): fini
  les spinners fantômes;
- les adapters claude/codex/opencode/openai_compat extraient un aperçu du
  payload (arguments/input/command/message/params) attaché au progress;
- bounded_reply_progress_preview (domain) garde les 4 premières lignes non
  vides, normalisation CR/LF;
- previewText (frontend) multiligne whitespace-pre-wrap, source/nativeEvent
  affichés sous le titre; suppression du flag settled et du Spinner inline
  devenus redondants (l'état est porté par le stage + le settler).

Tests: detach_agent_chat gateway (Tauri + mock), ProgressSettler, bornage
reply_progress, item_preview/tool_preview par adapter, réattache avec cache et
notice de rattrapage.
This commit is contained in:
2026-08-11 18:49:12 +02:00
parent bc9a034249
commit ee0ffd4e2d
16 changed files with 654 additions and 61 deletions

View File

@ -171,6 +171,15 @@ describe("TauriAgentGateway invoke payloads", () => {
expect(invoke).not.toHaveBeenCalledWith("close_agent_session", expect.anything());
});
it("detachAgentChat invokes detach_agent_chat without closing the session", async () => {
await new TauriAgentGateway().detachAgentChat("chat-session-1");
expect(invoke).toHaveBeenCalledWith("detach_agent_chat", {
sessionId: "chat-session-1",
});
expect(invoke).not.toHaveBeenCalledWith("close_agent_session", expect.anything());
});
it("sendAgentChat forwards structured clipboard attachments to agent_send", async () => {
await new TauriAgentGateway().sendAgentChat(
"chat-session-1",

View File

@ -262,6 +262,10 @@ export class TauriAgentGateway implements AgentGateway {
});
}
async detachAgentChat(sessionId: string): Promise<void> {
await invoke("detach_agent_chat", { sessionId });
}
async sendAgentChat(
sessionId: string,
prompt: string,

View File

@ -1026,6 +1026,15 @@ export class MockAgentGateway implements AgentGateway {
}
}
async detachAgentChat(sessionId: string): Promise<void> {
if (!this.chatScrollback.has(sessionId)) {
throw {
code: "NOT_FOUND",
message: `agent chat session ${sessionId} not found`,
} as GatewayError;
}
}
async cancelAgentChat(sessionId: string): Promise<void> {
if (!this.chatScrollback.has(sessionId)) {
throw {

View File

@ -1,9 +1,9 @@
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { useState } from "react";
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { DIProvider } from "@/app/di";
import type { AgentProfile } from "@/domain";
import type { AgentProfile, ReplyChunk } from "@/domain";
import { PluginRuntimeProvider } from "@/features/plugins";
import {
PluginCommandRegistry,
@ -12,7 +12,10 @@ import {
PluginRuntimeRegistry,
} from "@/plugins/runtime";
import type { ChatDraftScope, Gateways } from "@/ports";
import { CustomAgentChatView } from "./CustomAgentChatView";
import {
CustomAgentChatView,
__clearCustomAgentChatViewCacheForTests,
} from "./CustomAgentChatView";
const profile: AgentProfile = {
id: "structured",
@ -57,6 +60,11 @@ function addPluginCommand(
}
describe("CustomAgentChatView", () => {
beforeEach(() => {
vi.clearAllMocks();
__clearCustomAgentChatViewCacheForTests();
});
it("repaints attachment labels from user prompt scrollback", async () => {
const agent = {
launchAgentChat: vi.fn(),
@ -101,6 +109,75 @@ describe("CustomAgentChatView", () => {
expect(screen.getByText("Fichier: clipboard.png")).toBeTruthy();
});
it("detaches on unmount then reattaches with missed scrollback chunks", async () => {
let scrollback: ReplyChunk[] = [
{ kind: "userPrompt" as const, text: "Initial prompt" },
{ kind: "textDelta" as const, text: "Initial reply" },
];
const agent = {
launchAgentChat: vi.fn(),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback,
})),
detachAgentChat: vi.fn(async () => {}),
sendAgentChat: vi.fn(async () => {}),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
const props = {
projectId: "project-1",
agentId: "agent-1",
agentName: "Worker",
profile,
cwd: "/repo",
nodeId: "node-1",
sessionId: "chat-session-1",
conversationId: "conversation-1",
onSessionId: vi.fn(),
onConversationId: vi.fn(),
};
const first = render(
<DIProvider
gateways={{
agent,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<CustomAgentChatView {...props} />
</DIProvider>,
);
expect(await screen.findByText("Initial reply")).toBeTruthy();
first.unmount();
await waitFor(() => expect(agent.detachAgentChat).toHaveBeenCalledWith("chat-session-1"));
scrollback = [
...scrollback,
{ kind: "toolActivity" as const, label: "Background tool" },
{ kind: "final" as const, content: "Completed away" },
];
render(
<DIProvider
gateways={{
agent,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<CustomAgentChatView {...props} />
</DIProvider>,
);
expect(screen.getByText("Initial reply")).toBeTruthy();
expect(await screen.findByText("Background tool")).toBeTruthy();
expect(screen.getByText("Completed away")).toBeTruthy();
expect(
screen.getByText("Reprise effectuée: 2 événements rattrapés pendant l'absence."),
).toBeTruthy();
});
it("stops the live agent with cascade feedback and releases the structured session", async () => {
let resolveStop: (value: {
agentId: string;
@ -614,7 +691,7 @@ describe("CustomAgentChatView", () => {
expect(screen.getByText("Worker → QA")).toBeTruthy();
expect(screen.getByText("Worker → QA").parentElement?.textContent).toContain("started");
expect(screen.getAllByRole("status", { name: "Loading" }).length).toBeGreaterThan(0);
expect(screen.queryByRole("status", { name: "Loading" })).toBeNull();
act(() => {
emitChunk?.(progress("providerNative", "completed"));
@ -638,7 +715,7 @@ describe("CustomAgentChatView", () => {
);
});
it("stops animating running progress rows once the turn reaches a final chunk", async () => {
it("renders running progress without spinner and marks it done on final", async () => {
let emitChunk: ((chunk: unknown) => void) | null = null;
const agent = {
launchAgentChat: vi.fn(),
@ -707,7 +784,7 @@ describe("CustomAgentChatView", () => {
});
});
expect(screen.getAllByRole("status", { name: "Loading" }).length).toBeGreaterThan(0);
expect(screen.queryByRole("status", { name: "Loading" })).toBeNull();
expect(screen.getByText("Progress").parentElement?.textContent).toContain("running");
act(() => {

View File

@ -53,6 +53,17 @@ type ChatTurn =
| { role: "error"; text: string }
| { role: "unknown"; text: string };
interface CachedChatView {
turns: ChatTurn[];
chunkCount: number;
}
const chatSessionViewCache = new Map<string, CachedChatView>();
export function __clearCustomAgentChatViewCacheForTests() {
chatSessionViewCache.clear();
}
interface AttachmentDraft {
id: string;
label: string;
@ -288,6 +299,17 @@ function appendProgress(turns: ChatTurn[], progress: ReplyProgress): ChatTurn[]
return [...turns, { role: "progress", progress }];
}
function reattachNotice(missedChunks: number): ChatTurn | null {
if (missedChunks <= 0) return null;
return {
role: "tool",
label:
missedChunks === 1
? "Reprise effectuée: 1 événement rattrapé pendant l'absence."
: `Reprise effectuée: ${missedChunks} événements rattrapés pendant l'absence.`,
};
}
function mergeCompletedProgress(
running: ReplyProgress,
completed: ReplyProgress,
@ -522,7 +544,9 @@ export function CustomAgentChatView({
}: CustomAgentChatViewProps) {
const { agent, profile: profileGateway, system } = useGateways();
const pluginRuntime = usePluginRuntime();
const [turns, setTurns] = useState<ChatTurn[]>([]);
const [turns, setTurns] = useState<ChatTurn[]>(
() => (sessionId ? (chatSessionViewCache.get(sessionId)?.turns ?? []) : []),
);
const [currentSession, setCurrentSession] = useState(sessionId);
const [externalSessionId, setExternalSessionId] = useState(sessionId);
const [draft, setDraft] = useState("");
@ -540,6 +564,11 @@ export function CustomAgentChatView({
const composerRef = useRef<HTMLTextAreaElement | null>(null);
const sessionRef = useRef<string | null>(sessionId);
sessionRef.current = currentSession;
const scrollbackChunkCountRef = useRef(
sessionId ? (chatSessionViewCache.get(sessionId)?.chunkCount ?? 0) : 0,
);
const detachAgentChatRef = useRef(agent.detachAgentChat);
detachAgentChatRef.current = agent.detachAgentChat;
const slashQuerySeqRef = useRef(0);
const draftSaveSeqRef = useRef(0);
const draftHydratedRef = useRef(false);
@ -593,14 +622,30 @@ export function CustomAgentChatView({
mountedRef.current = true;
return () => {
mountedRef.current = false;
const sid = sessionRef.current;
if (!sid) return;
void detachAgentChatRef.current?.(sid).catch((e) => {
if (!isNotFound(e)) {
console.warn("[ticket188] detachAgentChat failed", instrumentationError(e));
}
});
};
}, []);
const receive = useCallback((chunk: ReplyChunk) => {
scrollbackChunkCountRef.current += 1;
setTurns((prev) => foldChunk(prev, chunk));
if (chunk.kind === "final" || chunk.kind === "error") setBusy(false);
}, []);
useEffect(() => {
if (!currentSession) return;
chatSessionViewCache.set(currentSession, {
turns,
chunkCount: scrollbackChunkCountRef.current,
});
}, [currentSession, turns]);
useEffect(() => {
draftSaveSeqRef.current += 1;
draftHydratedRef.current = false;
@ -748,7 +793,14 @@ export function CustomAgentChatView({
if (!mountedRef.current) return reattached.sessionId;
setCurrentSession(reattached.sessionId);
if (options.applyScrollback) {
setTurns(reattached.scrollback.reduce(foldChunk, [] as ChatTurn[]));
const cachedCount = chatSessionViewCache.get(sid)?.chunkCount ?? 0;
scrollbackChunkCountRef.current = Math.max(
scrollbackChunkCountRef.current,
reattached.scrollback.length,
);
const replayed = reattached.scrollback.reduce(foldChunk, [] as ChatTurn[]);
const notice = reattachNotice(reattached.scrollback.length - cachedCount);
setTurns(notice ? [...replayed, notice] : replayed);
}
return reattached.sessionId;
},
@ -810,6 +862,7 @@ export function CustomAgentChatView({
launched,
});
if (!mountedRef.current) return launched.sessionId;
scrollbackChunkCountRef.current = 0;
setCurrentSession(launched.sessionId);
publishSessionId(launched.sessionId);
if (launched.assignedConversationId) {
@ -888,6 +941,11 @@ export function CustomAgentChatView({
setError(null);
try {
if (externalSessionId) {
const cached = chatSessionViewCache.get(externalSessionId);
if (cached) {
scrollbackChunkCountRef.current = cached.chunkCount;
setTurns(cached.turns);
}
try {
await reattachStructuredSession(externalSessionId, { applyScrollback: true });
if (cancelled) return;
@ -1385,7 +1443,9 @@ export function CustomAgentChatView({
{opening && turns.length === 0 ? (
<div className="flex items-center gap-2 text-sm text-muted">
<Spinner size={14} />
<span>Ouverture de la session structurée…</span>
<span>
{externalSessionId ? "Reprise en cours..." : "Ouverture de la session structurée…"}
</span>
</div>
) : turns.length === 0 ? (
<p className="m-auto text-xs text-muted">
@ -1572,7 +1632,6 @@ function ChatBubble({
<ProgressBubble
progress={turn.progress}
requesterName={requesterName}
settled={turn.settled}
/>
);
}
@ -1683,9 +1742,20 @@ function compactToolName(progress: ReplyProgress): string {
return progress.toolName ?? progress.label;
}
function truncateText(text: string, max = 140): string {
const flat = text.replace(/\s+/g, " ").trim();
return flat.length > max ? `${flat.slice(0, max - 3)}...` : flat;
function previewText(text: string, maxLineLength = 160): string {
return text
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n")
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.slice(0, 4)
.map((line) =>
line.length > maxLineLength
? `${line.slice(0, Math.max(0, maxLineLength - 3))}...`
: line,
)
.join("\n");
}
function parseKeyValue(text: string, key: string): string | null {
@ -1710,9 +1780,9 @@ function delegationSummary(progress: ReplyProgress): {
requester,
target,
excerpt: task
? truncateText(task, 92)
? previewText(task, 92)
: fallback
? truncateText(fallback, 92)
? previewText(fallback, 92)
: null,
};
}
@ -1743,16 +1813,13 @@ function TurnMarker({ progress }: { progress: ReplyProgress }) {
function ToolProgressCard({
progress,
requesterName,
settled,
}: {
progress: ReplyProgress;
requesterName: string;
settled: boolean;
}) {
const running = !settled && (progress.stage === "started" || progress.stage === "delta");
const stage = progressStageLabel(progress.stage);
const detail =
progress.text && !isDelegationProgress(progress) ? truncateText(progress.text) : null;
progress.text && !isDelegationProgress(progress) ? previewText(progress.text) : null;
const delegation = isDelegationProgress(progress) ? delegationSummary(progress) : null;
const title = delegation
? delegation.target
@ -1770,7 +1837,6 @@ function ToolProgressCard({
)}
>
<div className="flex min-w-0 items-center justify-center gap-1.5">
{running && <Spinner size={12} />}
<span className="shrink-0 font-semibold">{title}</span>
{!delegation && (
<span className="min-w-0 truncate text-muted">{compactToolName(progress)}</span>
@ -1787,8 +1853,19 @@ function ToolProgressCard({
{compactToolName(progress)}
</p>
)}
{(progress.source || progress.nativeEvent || progress.provider) && (
<div className="mt-0.5 flex min-w-0 flex-wrap justify-center gap-x-2 gap-y-0.5 text-[11px] text-muted">
<span>{progressSourceLabel(progress)}</span>
{progress.nativeEvent && (
<span className="truncate">event: {progress.nativeEvent}</span>
)}
</div>
)}
{detail && (
<p className="mt-1 text-center text-[11px] text-content" style={fourLineClamp}>
<p
className="mt-1 overflow-hidden whitespace-pre-wrap break-words text-center text-[11px] text-content"
style={fourLineClamp}
>
{detail}
</p>
)}
@ -1800,11 +1877,9 @@ function ToolProgressCard({
function ProgressBubble({
progress,
requesterName,
settled = false,
}: {
progress: ReplyProgress;
requesterName: string;
settled?: boolean;
}) {
if (isTimelineProgress(progress)) return <TurnMarker progress={progress} />;
if (isCenteredToolProgress(progress)) {
@ -1812,15 +1887,13 @@ function ProgressBubble({
<ToolProgressCard
progress={progress}
requesterName={requesterName}
settled={settled}
/>
);
}
const title = progressTitle(progress);
const stage = progressStageLabel(progress.stage);
const source = progressSourceLabel(progress);
const detail = progress.text ? truncateText(progress.text, 260) : null;
const running = !settled && (progress.stage === "started" || progress.stage === "delta");
const detail = progress.text ? previewText(progress.text, 180) : null;
return (
<div data-testid="chat-progress-status" className="flex min-w-0 justify-end">
<div
@ -1830,7 +1903,6 @@ function ProgressBubble({
)}
>
<div className="flex min-w-0 items-center gap-1.5">
{running && <Spinner size={12} />}
<span className="shrink-0 font-semibold">{title}</span>
<span className="min-w-0 truncate text-muted">{progress.label}</span>
<span className="shrink-0 text-muted">· {stage}</span>

View File

@ -311,6 +311,8 @@ export interface AgentGateway {
sessionId: string,
onChunk: (chunk: ReplyChunk) => void,
): Promise<ReattachAgentChatResult>;
/** Detaches the current custom chat view transport while keeping the session alive. */
detachAgentChat?(sessionId: string): Promise<void>;
/** Sends one user prompt to a live structured session. */
sendAgentChat?(
sessionId: string,