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:
@ -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>
|
||||
|
||||
Reference in New Issue
Block a user