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

@ -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>