From d2c851523bfa686169da1302aa784e7005ad6e19 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 21 Aug 2026 16:10:48 -0500 Subject: [PATCH] fix(runner): keep live turn layout stable --- ui/src/components/TaskChatThread.test.tsx | 4 +- ui/src/components/TaskChatThread.tsx | 65 +++++++++++++++++-- .../components/task-chat/TaskChatBubble.tsx | 54 ++++++++++----- .../task-chat/TaskChatLiveRunPill.test.tsx | 16 +++++ .../task-chat/TaskChatLiveRunPill.tsx | 2 +- .../task-chat/TaskChatRunnerTurn.test.tsx | 6 +- .../task-chat/TaskChatRunnerTurn.tsx | 20 ++++-- .../task-chat/tool-taxonomy.test.ts | 8 +++ ui/src/components/task-chat/tool-taxonomy.ts | 3 + 9 files changed, 144 insertions(+), 34 deletions(-) diff --git a/ui/src/components/TaskChatThread.test.tsx b/ui/src/components/TaskChatThread.test.tsx index d31ac6af764..e4909b6af9a 100644 --- a/ui/src/components/TaskChatThread.test.tsx +++ b/ui/src/components/TaskChatThread.test.tsx @@ -580,7 +580,7 @@ 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", () => { + it("keeps an unlinked persisted runner reply hidden while the live turn still owns that response", () => { transcriptState.transcriptByRun.set("run-runner", [ { kind: "assistant", @@ -600,7 +600,7 @@ describe("TaskChatThread live transcript", () => { body: "Completed the requested streaming test.", presentation: null, metadata: null, - runId: "run-runner", + runId: null, createdAt: new Date("2026-08-21T15:44:22.000Z"), updatedAt: new Date("2026-08-21T15:44:22.000Z"), }; diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx index e0844fc9da6..a351adf82f7 100644 --- a/ui/src/components/TaskChatThread.tsx +++ b/ui/src/components/TaskChatThread.tsx @@ -60,6 +60,25 @@ function toMs(value: Date | string | null | undefined): number { return Number.isNaN(ms) ? 0 : ms; } +function normalizedMessageText(value: string | null | undefined): string { + return (value ?? "").trim().replace(/\s+/g, " "); +} + +function isRunnerResponseComment(params: { + comment: TaskChatThreadProps["comments"][number]; + runId: string; + runStartedAtMs: number | null; + finalText: string | null; +}): boolean { + const { comment, runId, runStartedAtMs, finalText } = params; + if (comment.deletedAt || comment.authorType === "user") return false; + if (comment.runId === runId) return true; + if (comment.runId || !finalText) return false; + const commentAtMs = toMs(comment.createdAt); + if (runStartedAtMs != null && commentAtMs < runStartedAtMs - 5_000) return false; + return normalizedMessageText(comment.body) === normalizedMessageText(finalText); +} + // PAP-462 B4: backstop for how long the just-settled run's transcript stays // mounted when neither a settled turn nor a reply comment ever arrives to hand // off to (e.g. a stopped run with no tool activity). Normal completions hand off @@ -276,6 +295,20 @@ export function TaskChatThread(props: TaskChatThreadProps) { : settlingRun && runs.find((run) => run.id === settlingRun.id)?.adapterType === "paperclip_runner" ? settlingRun.id : null; + const heldPaperclipRunnerStartedAtMs = heldPaperclipRunnerRunId === liveRun?.id + ? (liveRun.startedAt ? toMs(liveRun.startedAt) : null) ?? toMs(liveRun.createdAt) + : settlingRun?.startedAtMs ?? null; + const heldPaperclipRunnerFinalText = useMemo(() => { + if (!heldPaperclipRunnerRunId) return null; + const entries = transcriptByRun.get(heldPaperclipRunnerRunId) ?? []; + for (let index = entries.length - 1; index >= 0; index -= 1) { + const entry = entries[index]; + if (entry.kind === "assistant" && entry.channel === "final" && entry.text.trim()) { + return entry.text; + } + } + return null; + }, [heldPaperclipRunnerRunId, transcriptByRun]); // Runs observed non-terminal while mounted: their turns ANIMATE the fold when // they settle. Runs already terminal at mount collapse instantly. @@ -313,7 +346,15 @@ export function TaskChatThread(props: TaskChatThreadProps) { // 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; + if ( + heldPaperclipRunnerRunId && + isRunnerResponseComment({ + comment, + runId: heldPaperclipRunnerRunId, + runStartedAtMs: heldPaperclipRunnerStartedAtMs, + finalText: heldPaperclipRunnerFinalText, + }) + ) return; const item = commentItems[index]; if (!item) return; entries.push({ ms: toMs(comment.createdAt), order: 1, id: item.id, item }); @@ -370,7 +411,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, heldPaperclipRunnerRunId]); + }, [comments, commentItems, interactions, timelineEvents, linkedRuns, liveRuns, planDocument, heldPaperclipRunnerRunId, heldPaperclipRunnerStartedAtMs, heldPaperclipRunnerFinalText]); // Boolean gate (stable across the host's per-render brief objects) so the // heavy assembly memo doesn't recompute on every parent render. @@ -509,10 +550,18 @@ export function TaskChatThread(props: TaskChatThreadProps) { // 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. - const settlingHasComment = settlingRun != null && - comments.some((comment) => comment.runId === settlingRun.id && !comment.deletedAt); const settlingIsPaperclipRunner = settlingRun != null && runs.find((run) => run.id === settlingRun.id)?.adapterType === "paperclip_runner"; + const settlingHasComment = settlingRun != null && comments.some((comment) => + settlingIsPaperclipRunner + ? isRunnerResponseComment({ + comment, + runId: settlingRun.id, + runStartedAtMs: settlingRun.startedAtMs, + finalText: heldPaperclipRunnerFinalText, + }) + : comment.runId === settlingRun.id && !comment.deletedAt, + ); const settledRunRendered = settlingRun != null && (settlingIsPaperclipRunner ? settlingHasComment && settledRunIds.has(settlingRun.id) @@ -587,6 +636,12 @@ export function TaskChatThread(props: TaskChatThreadProps) { // tailStreaming: true while live, false through the settle gap — so the tail // renders identically either side of the run finishing. const tailAgentName = liveRun?.agentName ?? linkedRunMetaById.get(tailRunId ?? "")?.agentName; + const tailAgentId = liveRun?.agentId + ?? linkedRunMetaById.get(tailRunId ?? "")?.agentId + ?? issueAssigneeAgentId; + const tailAgent = tailAgentId ? agentMap?.get(tailAgentId) : undefined; + const visibleTailAgentName = tailAgentName ?? tailAgent?.name ?? null; + const visibleTailAgentIcon = tailAgent?.icon ?? null; const tailItems = useMemo( () => tailRunId @@ -764,6 +819,8 @@ export function TaskChatThread(props: TaskChatThreadProps) { {paperclipRunnerTail || optimisticRunnerStartup ? ( + + {agentIcon ? ( + + + + ) : ( + {initialsForName(agentName)} + )} + + {agentName} + {onBehalfOfUserName ? ( + + ) : null} + + ); +} + /** * Author-typed message row — the primary legibility signal. Human messages sit * right in a solid accent bubble; agent messages sit directly on the page @@ -109,24 +140,11 @@ export function TaskChatBubble({
{beforeTurn ?
{beforeTurn}
: null} {!isHuman && item.authorName ? ( - - - {item.agentIcon ? ( - - - - ) : ( - {initialsForName(item.authorName)} - )} - - {item.authorName} - {item.onBehalfOfUserName ? ( - - ) : null} - + ) : null} {bodyText.length > 0 ? (
{ expect(pill?.textContent).toContain("for 42 seconds"); expect(pill?.textContent).toContain("ran 1 command"); }); + + it("keeps the paperclip runner Working header static", () => { + act(() => { + root.render( + , + ); + }); + + expect(container.textContent).toContain("Workingfor 2s"); + expect(container.querySelector(".shimmer-text")).toBeNull(); + }); }); diff --git a/ui/src/components/task-chat/TaskChatLiveRunPill.tsx b/ui/src/components/task-chat/TaskChatLiveRunPill.tsx index 3a602170b4c..69415b855e1 100644 --- a/ui/src/components/task-chat/TaskChatLiveRunPill.tsx +++ b/ui/src/components/task-chat/TaskChatLiveRunPill.tsx @@ -80,7 +80,7 @@ export function TaskChatLiveRunPill({ if (runnerStyle) { const content = ( <> - {verb} + {verb} {suffix ? {suffix} : null} { const render = (items: TaskChatItem[], status = "running", runId = "run-1") => act(() => root.render( - + , )); it("shows immediate Thinking before the first runner event", () => { render([], "queued"); expect(container.textContent).toContain("Thinking"); + expect(container.textContent).toContain("Runner"); + expect(container.querySelector('[data-testid="task-chat-current-activity"] svg')).toBeNull(); expect(container.textContent).not.toContain("Working"); expect(container.textContent).not.toContain("Waiting for transcript"); }); @@ -51,7 +53,7 @@ describe("TaskChatRunnerTurn", () => { { 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.querySelectorAll('[data-testid="task-chat-agent-avatar"]')).toHaveLength(1); expect(container.querySelector('[data-testid="task-chat-current-activity"]')).toBeNull(); }); diff --git a/ui/src/components/task-chat/TaskChatRunnerTurn.tsx b/ui/src/components/task-chat/TaskChatRunnerTurn.tsx index f152ff841a6..cf0cf4fa9cf 100644 --- a/ui/src/components/task-chat/TaskChatRunnerTurn.tsx +++ b/ui/src/components/task-chat/TaskChatRunnerTurn.tsx @@ -1,9 +1,8 @@ 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 { TaskChatAgentIdentity } from "./TaskChatBubble"; import { TaskChatLiveRunPill } from "./TaskChatLiveRunPill"; import { TaskChatLiveTail } from "./TaskChatLiveTail"; import { paperclipRunnerHistoryItems } from "./transcript-adapter"; @@ -24,8 +23,7 @@ function CurrentActivity({ items }: { items: readonly TaskChatItem[] }) { ); if (!activity || activity.kind === "thinking") { return ( -
- +
Thinking
); @@ -44,6 +42,8 @@ function CurrentActivity({ items }: { items: readonly TaskChatItem[] }) { export function TaskChatRunnerTurn({ runId, + agentName, + agentIcon, items, status, startedAtMs, @@ -52,6 +52,8 @@ export function TaskChatRunnerTurn({ }: { /** Stable identity used to clear replay-latched final text for the next turn. */ runId?: string | null; + agentName?: string | null; + agentIcon?: string | null; items: readonly TaskChatItem[]; status: string; startedAtMs: number | null; @@ -76,8 +78,9 @@ export function TaskChatRunnerTurn({ if (status === "queued") { return ( -
+
+ {agentName ? : null}
); } @@ -110,9 +113,12 @@ export function TaskChatRunnerTurn({ {!final ? : null}
) : null} + {agentName ? : null} {final ? ( -
- +
+
+ {final.text} +
) : null}
diff --git a/ui/src/components/task-chat/tool-taxonomy.test.ts b/ui/src/components/task-chat/tool-taxonomy.test.ts index 2db871af2d4..84265712ae5 100644 --- a/ui/src/components/task-chat/tool-taxonomy.test.ts +++ b/ui/src/components/task-chat/tool-taxonomy.test.ts @@ -84,6 +84,14 @@ describe("toolTaxonomy", () => { expect(toolTaxonomy(undefined).icon).toBe(Wrench); expect(toolTaxonomy(null).icon).toBe(Wrench); }); + + it("labels the semantic completion tool as Finishing", () => { + expect(toolTaxonomy("paperclip_finish")).toEqual({ + family: "other", + icon: Wrench, + verbLabel: "Finishing", + }); + }); }); describe("toolTaxonomy multi-word ACP titles", () => { diff --git a/ui/src/components/task-chat/tool-taxonomy.ts b/ui/src/components/task-chat/tool-taxonomy.ts index 1bd088d0ee0..9cf334bf7d8 100644 --- a/ui/src/components/task-chat/tool-taxonomy.ts +++ b/ui/src/components/task-chat/tool-taxonomy.ts @@ -104,6 +104,9 @@ function classify(n: string): Exclude { export function toolTaxonomy(name: string | undefined | null): ToolTaxonomyEntry { const raw = (name ?? "").trim(); if (!raw) return { family: "other", ...FAMILY_META.other }; + if (raw.toLowerCase() === "paperclip_finish") { + return { family: "other", icon: Wrench, verbLabel: "Finishing" }; + } const mcpTool = mcpToolSegment(raw); if (mcpTool) return { family: "mcp", icon: McpIcon, verbLabel: `Using ${mcpTool}` }; const family = classify(raw.toLowerCase());