diff --git a/ui/src/adapters/transcript.test.ts b/ui/src/adapters/transcript.test.ts index 56627f328ad..cab723b2b7e 100644 --- a/ui/src/adapters/transcript.test.ts +++ b/ui/src/adapters/transcript.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; -import { buildTranscript, type RunLogChunk } from "./transcript"; +import { appendTranscriptEntry, buildTranscript, type RunLogChunk } from "./transcript"; import { grokLocalUIAdapter } from "./grok-local"; -import type { UIAdapterModule } from "./types"; +import type { TranscriptEntry, UIAdapterModule } from "./types"; describe("buildTranscript", () => { const ts = "2026-03-20T13:00:00.000Z"; @@ -199,4 +199,15 @@ describe("buildTranscript", () => { { kind: "system", ts, text: "stop_reason=EndTurn session=sess-1" }, ]); }); + + it("never coalesces adjacent assistant deltas across progress and final channels", () => { + const entries: TranscriptEntry[] = []; + appendTranscriptEntry(entries, { kind: "assistant", ts, text: "Checking.", delta: true, channel: "progress" }); + appendTranscriptEntry(entries, { kind: "assistant", ts, text: "Done.", delta: true, channel: "final" }); + + expect(entries).toEqual([ + { kind: "assistant", ts, text: "Checking.", delta: true, channel: "progress" }, + { kind: "assistant", ts, text: "Done.", delta: true, channel: "final" }, + ]); + }); }); diff --git a/ui/src/adapters/transcript.ts b/ui/src/adapters/transcript.ts index 70f9c5369f1..008360edbf8 100644 --- a/ui/src/adapters/transcript.ts +++ b/ui/src/adapters/transcript.ts @@ -19,7 +19,12 @@ function resolveStdoutParser(source: StdoutLineParser | TranscriptParserSource) export function appendTranscriptEntry(entries: TranscriptEntry[], entry: TranscriptEntry) { if ((entry.kind === "thinking" || entry.kind === "assistant") && entry.delta) { const last = entries[entries.length - 1]; - if (last && last.kind === entry.kind && last.delta) { + if ( + last && + last.kind === entry.kind && + last.delta && + last.channel === entry.channel + ) { last.text += entry.text; last.ts = entry.ts; return; diff --git a/ui/src/components/TaskChatThread.test.tsx b/ui/src/components/TaskChatThread.test.tsx index 92b1d2c8210..d31ac6af764 100644 --- a/ui/src/components/TaskChatThread.test.tsx +++ b/ui/src/components/TaskChatThread.test.tsx @@ -580,6 +580,56 @@ describe("TaskChatThread mobile composer dock (PAP-495)", () => { }); describe("TaskChatThread live transcript", () => { + it("keeps a persisted runner reply hidden while the live turn still owns that response", () => { + transcriptState.transcriptByRun.set("run-runner", [ + { + kind: "assistant", + ts: "2026-08-21T15:44:20.000Z", + text: "Completed the requested streaming test.", + channel: "final", + delta: true, + }, + ]); + const comment = { + id: "comment-runner", + companyId: "company-1", + issueId: "issue-1", + authorType: "agent" as const, + authorAgentId: "agent-1", + authorUserId: null, + body: "Completed the requested streaming test.", + presentation: null, + metadata: null, + runId: "run-runner", + createdAt: new Date("2026-08-21T15:44:22.000Z"), + updatedAt: new Date("2026-08-21T15:44:22.000Z"), + }; + + render( + {}} + issueStatus="in_progress" + activeRun={{ + id: "run-runner", + status: "running", + invocationSource: "issue", + triggerDetail: null, + startedAt: "2026-08-21T15:44:00.000Z", + finishedAt: null, + createdAt: "2026-08-21T15:44:00.000Z", + agentId: "agent-1", + agentName: "Runner", + adapterType: "paperclip_runner", + }} + />, + ); + + expect(container.textContent?.match(/Completed the requested streaming test\./g)).toHaveLength(1); + expect(container.querySelectorAll('[data-testid="task-chat-agent-bubble"]')).toHaveLength(1); + expect(container.querySelector('[data-testid="task-chat-live-transcript"]')).not.toBeNull(); + }); + it("renders in-flight output through TaskChatLiveTail, dropping the debug plumbing (PAP-463 C1)", () => { // Interleave the exact noise the old RunTranscriptView tail surfaced (init // row, stdout/stderr/system dumps) with real content. Only the streamed diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx index fc0d6aed3f2..e0844fc9da6 100644 --- a/ui/src/components/TaskChatThread.tsx +++ b/ui/src/components/TaskChatThread.tsx @@ -250,6 +250,33 @@ export function TaskChatThread(props: TaskChatThreadProps) { return (liveRuns ?? []).find((r) => isLiveIssueRun(r, issueStatus)) ?? null; }, [activeRun, issueStatus, liveRuns]); + // The live runner turn remains the sole owner of its response until the + // canonical comment and settled activity are both ready. This state is + // established in a layout effect so the live lane never disappears for a + // paint between `liveRun` ending and the persistence handoff beginning. + const [settlingRun, setSettlingRun] = useState<{ id: string; startedAtMs: number | null } | null>(null); + const prevLiveRunRef = useRef(null); + useLayoutEffect(() => { + if (liveRun) { + prevLiveRunRef.current = liveRun; + setSettlingRun((current) => (current && current.id !== liveRun.id ? null : current)); + return; + } + const prev = prevLiveRunRef.current; + prevLiveRunRef.current = null; + if (!prev) return; + setSettlingRun({ + id: prev.id, + startedAtMs: (prev.startedAt ? toMs(prev.startedAt) : null) ?? toMs(prev.createdAt), + }); + }, [liveRun]); + + const heldPaperclipRunnerRunId = liveRun?.adapterType === "paperclip_runner" + ? liveRun.id + : settlingRun && runs.find((run) => run.id === settlingRun.id)?.adapterType === "paperclip_runner" + ? settlingRun.id + : null; + // Runs observed non-terminal while mounted: their turns ANIMATE the fold when // they settle. Runs already terminal at mount collapse instantly. const liveSeenRef = useRef>(new Set()); @@ -283,6 +310,10 @@ export function TaskChatThread(props: TaskChatThreadProps) { // two lists stay index-aligned. const visibleComments = comments.filter((comment) => !comment.deletedAt); visibleComments.forEach((comment, index) => { + // The live/settling runner lane already renders this run's final answer. + // Keep a just-persisted duplicate out of the backbone until the complete + // canonical bubble + Worked header can replace that lane atomically. + if (comment.runId === heldPaperclipRunnerRunId) return; const item = commentItems[index]; if (!item) return; entries.push({ ms: toMs(comment.createdAt), order: 1, id: item.id, item }); @@ -339,7 +370,7 @@ export function TaskChatThread(props: TaskChatThreadProps) { return entries.sort( (a, b) => a.ms - b.ms || a.order - b.order || a.id.localeCompare(b.id), ); - }, [comments, commentItems, interactions, timelineEvents, linkedRuns, liveRuns, planDocument]); + }, [comments, commentItems, interactions, timelineEvents, linkedRuns, liveRuns, planDocument, heldPaperclipRunnerRunId]); // Boolean gate (stable across the host's per-render brief objects) so the // heavy assembly memo doesn't recompute on every parent render. @@ -475,32 +506,6 @@ export function TaskChatThread(props: TaskChatThreadProps) { }; }, [orderedEntries, runs, liveRun, transcriptByRun, linkedRunMetaById, lastCommentIdByRun, hasBrief]); - // PAP-462 B4: the moment a run settles, `liveRun` flips to null — but its - // settled turn (or reply comment) can take seconds to arrive on the next - // comment refetch. Without a handoff the whole transcript unmounts that frame, - // blinking out already-streamed output before its settled form renders. Snapshot - // the just-settled run so its now-static transcript stays mounted through the gap. - const [settlingRun, setSettlingRun] = useState<{ id: string; startedAtMs: number | null } | null>(null); - const prevLiveRunRef = useRef(null); - // Layout effect (not passive): snapshot the settled run before the browser - // paints the `liveRun === null` frame, so the tail never blinks empty for a - // frame between the run stopping and this snapshot committing. - useLayoutEffect(() => { - if (liveRun) { - prevLiveRunRef.current = liveRun; - // A fresh live run supersedes any lingering settled remnant. - setSettlingRun((current) => (current && current.id !== liveRun.id ? null : current)); - return; - } - const prev = prevLiveRunRef.current; - prevLiveRunRef.current = null; - if (!prev) return; - setSettlingRun({ - id: prev.id, - startedAtMs: (prev.startedAt ? toMs(prev.startedAt) : null) ?? toMs(prev.createdAt), - }); - }, [liveRun]); - // Hand off once the settled turn or its reply comment is in the thread; a // stopped run that yields neither is released by the backstop timeout so the // tail never lingers indefinitely. @@ -509,7 +514,9 @@ export function TaskChatThread(props: TaskChatThreadProps) { const settlingIsPaperclipRunner = settlingRun != null && runs.find((run) => run.id === settlingRun.id)?.adapterType === "paperclip_runner"; const settledRunRendered = settlingRun != null && - (settlingHasComment || (!settlingIsPaperclipRunner && settledRunIds.has(settlingRun.id))); + (settlingIsPaperclipRunner + ? settlingHasComment && settledRunIds.has(settlingRun.id) + : settlingHasComment || settledRunIds.has(settlingRun.id)); useEffect(() => { if (!settlingRun) return; if (settledRunRendered) { @@ -756,6 +763,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
{paperclipRunnerTail || optimisticRunnerStartup ? ( (null); @@ -97,7 +106,7 @@ export function TaskChatBubble({ item, queuedAction, attachedTurn, beforeTurn, a ? -1 : Math.max(0, galleryItems.findIndex((galleryItem) => galleryItem.contentPath === lightboxSrc)); return ( -
+
{beforeTurn ?
{beforeTurn}
: null} {!isHuman && item.authorName ? ( diff --git a/ui/src/components/task-chat/TaskChatRunnerTurn.test.tsx b/ui/src/components/task-chat/TaskChatRunnerTurn.test.tsx index 70b9b6eb43c..9f7e88d0ce5 100644 --- a/ui/src/components/task-chat/TaskChatRunnerTurn.test.tsx +++ b/ui/src/components/task-chat/TaskChatRunnerTurn.test.tsx @@ -22,9 +22,9 @@ describe("TaskChatRunnerTurn", () => { container.remove(); }); - const render = (items: TaskChatItem[], status = "running") => act(() => root.render( + const render = (items: TaskChatItem[], status = "running", runId = "run-1") => act(() => root.render( - + , )); @@ -48,12 +48,35 @@ describe("TaskChatRunnerTurn", () => { it("streams the final response in its durable slot and hides current activity", () => { render([ { id: "p1", kind: "message", author: "agent", text: "Checking.", interstitial: true, channel: "progress" }, - { id: "f1", kind: "message", author: "agent", text: "Completed successfully.", channel: "final", streaming: true }, + { id: "f1", kind: "message", author: "agent", authorName: "Runner", text: "Completed successfully.", channel: "final", streaming: true }, ]); expect(container.querySelector('[data-testid="task-chat-final-response"]')?.textContent).toContain("Completed successfully."); + expect(container.querySelector('[data-testid="task-chat-agent-avatar"]')).not.toBeNull(); expect(container.querySelector('[data-testid="task-chat-current-activity"]')).toBeNull(); }); + it("keeps final text mounted through a transient replay gap", () => { + render([ + { id: "f1", kind: "message", author: "agent", authorName: "Runner", text: "Completed successfully.", channel: "final", streaming: true }, + ]); + render([{ id: "t1", kind: "tool", name: "Paperclip_finish", status: "completed" }]); + + expect(container.querySelector('[data-testid="task-chat-final-response"]')?.textContent) + .toContain("Completed successfully."); + expect(container.querySelectorAll('[data-testid="task-chat-final-response"]')).toHaveLength(1); + }); + + it("clears replay-latched final text when the next run takes over the lane", () => { + render([ + { id: "f1", kind: "message", author: "agent", authorName: "Runner", text: "First answer.", channel: "final" }, + ], "running", "run-1"); + render([], "running", "run-2"); + + expect(container.textContent).not.toContain("First answer."); + expect(container.querySelector('[data-testid="task-chat-current-activity"]')?.textContent) + .toContain("Thinking"); + }); + it("omits runner lifecycle and token noise while preserving useful history", () => { render([ { id: "session", kind: "marker", variant: "session_start", label: "Session started", detail: "session-id" }, diff --git a/ui/src/components/task-chat/TaskChatRunnerTurn.tsx b/ui/src/components/task-chat/TaskChatRunnerTurn.tsx index bf984688f63..f152ff841a6 100644 --- a/ui/src/components/task-chat/TaskChatRunnerTurn.tsx +++ b/ui/src/components/task-chat/TaskChatRunnerTurn.tsx @@ -1,8 +1,9 @@ -import { useState } from "react"; +import { useRef, useState } from "react"; import { Brain } from "lucide-react"; import { MarkdownBody } from "@/components/MarkdownBody"; import { cn } from "@/lib/utils"; import type { TaskChatItem, TaskChatMessageItem, TaskChatThinkingItem, TaskChatToolItem } from "./task-chat-model"; +import { TaskChatBubble } from "./TaskChatBubble"; import { TaskChatLiveRunPill } from "./TaskChatLiveRunPill"; import { TaskChatLiveTail } from "./TaskChatLiveTail"; import { paperclipRunnerHistoryItems } from "./transcript-adapter"; @@ -42,12 +43,15 @@ function CurrentActivity({ items }: { items: readonly TaskChatItem[] }) { } export function TaskChatRunnerTurn({ + runId, items, status, startedAtMs, finishedAtMs, toolSummary, }: { + /** Stable identity used to clear replay-latched final text for the next turn. */ + runId?: string | null; items: readonly TaskChatItem[]; status: string; startedAtMs: number | null; @@ -57,7 +61,18 @@ export function TaskChatRunnerTurn({ const [open, setOpen] = useState(false); const historyItems = paperclipRunnerHistoryItems(items); const progress = lastOf(items, (item): item is TaskChatMessageItem => item.kind === "message" && Boolean(item.interstitial)); - const final = lastOf(items, (item): item is TaskChatMessageItem => item.kind === "message" && item.channel === "final"); + const observedFinal = lastOf(items, (item): item is TaskChatMessageItem => item.kind === "message" && item.channel === "final"); + // A reconnect/replay can briefly rebuild the transcript without the final + // item (or with an earlier, shorter prefix). Once durable answer text is on + // screen it must be monotonic for the lifetime of this mounted turn. + const finalRef = useRef<{ runId?: string | null; item?: TaskChatMessageItem }>({ runId }); + if (finalRef.current.runId !== runId) { + finalRef.current = { runId }; + } + if (observedFinal && (!finalRef.current.item || observedFinal.text.length >= finalRef.current.item.text.length)) { + finalRef.current.item = observedFinal; + } + const final = finalRef.current.item; if (status === "queued") { return ( @@ -96,8 +111,8 @@ export function TaskChatRunnerTurn({
) : null} {final ? ( -
- {final.text} +
+
) : null}
diff --git a/ui/src/components/task-chat/TaskChatThreadView.tsx b/ui/src/components/task-chat/TaskChatThreadView.tsx index c99e253a868..961b2511fea 100644 --- a/ui/src/components/task-chat/TaskChatThreadView.tsx +++ b/ui/src/components/task-chat/TaskChatThreadView.tsx @@ -80,6 +80,7 @@ function renderItem( return (