fix(runner): keep live turn layout stable

This commit is contained in:
Dotta committed 2026-08-21 16:10:48 -05:00
1 parent c31a345a3d
commit d2c851523b
9 files changed
+144 -34

No files matched your search

+2 -2
View File
@@ -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"),
};
+61 -4
View File
@@ -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 ? (
<TaskChatRunnerTurn
runId={tailRunId}
agentName={visibleTailAgentName}
agentIcon={visibleTailAgentIcon}
items={tailItems}
status={optimisticRunnerStartup ? "queued" : tailStatus}
startedAtMs={tailStartedAtMs}
+36 -18
View File
@@ -50,6 +50,37 @@ function initialsForName(name: string) {
return name.slice(0, 2).toUpperCase();
}
export function TaskChatAgentIdentity({
agentName,
agentIcon,
onBehalfOfUserName,
}: {
agentName: string;
agentIcon?: string | null;
onBehalfOfUserName?: string;
}) {
return (
<span className="flex items-center gap-2 px-1" data-testid="task-chat-agent-identity">
<Avatar size="sm" className="shrink-0" data-testid="task-chat-agent-avatar">
{agentIcon ? (
<AvatarFallback>
<AgentIcon icon={agentIcon} className="h-3.5 w-3.5" />
</AvatarFallback>
) : (
<AvatarFallback>{initialsForName(agentName)}</AvatarFallback>
)}
</Avatar>
<span className="text-sm font-semibold text-foreground">{agentName}</span>
{onBehalfOfUserName ? (
<CommentAttributionChip
agentName={agentName}
userName={onBehalfOfUserName}
/>
) : null}
</span>
);
}
/**
* 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({
<div className={cn("flex w-full flex-col gap-1", animateEntry && "tc-enter-bubble", isHuman ? "items-end" : "items-start")}>
{beforeTurn ? <div className="w-full pb-1">{beforeTurn}</div> : null}
{!isHuman && item.authorName ? (
<span className="flex items-center gap-2 px-1">
<Avatar size="sm" className="shrink-0" data-testid="task-chat-agent-avatar">
{item.agentIcon ? (
<AvatarFallback>
<AgentIcon icon={item.agentIcon} className="h-3.5 w-3.5" />
</AvatarFallback>
) : (
<AvatarFallback>{initialsForName(item.authorName)}</AvatarFallback>
)}
</Avatar>
<span className="text-sm font-semibold text-foreground">{item.authorName}</span>
{item.onBehalfOfUserName ? (
<CommentAttributionChip
agentName={item.authorName}
userName={item.onBehalfOfUserName}
/>
) : null}
</span>
<TaskChatAgentIdentity
agentName={item.authorName}
agentIcon={item.agentIcon}
onBehalfOfUserName={item.onBehalfOfUserName}
/>
) : null}
{bodyText.length > 0 ? (
<div
@@ -89,4 +89,20 @@ describe("TaskChatLiveRunPill", () => {
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(
<TaskChatLiveRunPill
status="running"
startedAtMs={Date.now() - 2_000}
toolSummary={null}
runnerStyle
/>,
);
});
expect(container.textContent).toContain("Workingfor 2s");
expect(container.querySelector(".shimmer-text")).toBeNull();
});
});
@@ -80,7 +80,7 @@ export function TaskChatLiveRunPill({
if (runnerStyle) {
const content = (
<>
<span className={cn("text-sm font-medium text-foreground/80", active && "shimmer-text")}>{verb}</span>
<span className="text-sm font-medium text-foreground/80">{verb}</span>
{suffix ? <span className="text-sm text-muted-foreground">{suffix}</span> : null}
<ChevronRight
className={cn("ml-auto h-3.5 w-3.5 text-muted-foreground transition-transform", expanded && "rotate-90")}
@@ -24,13 +24,15 @@ describe("TaskChatRunnerTurn", () => {
const render = (items: TaskChatItem[], status = "running", runId = "run-1") => act(() => root.render(
<ThemeProvider>
<TaskChatRunnerTurn runId={runId} items={items} status={status} startedAtMs={Date.now() - 2_000} toolSummary={null} />
<TaskChatRunnerTurn runId={runId} agentName="Runner" items={items} status={status} startedAtMs={Date.now() - 2_000} toolSummary={null} />
</ThemeProvider>,
));
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();
});
@@ -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 (
<div className="flex items-center gap-2 px-1 py-1.5 text-sm text-muted-foreground" data-testid="task-chat-current-activity">
<Brain className="h-3.5 w-3.5 shrink-0" aria-hidden />
<div className="px-1 py-1.5 text-sm text-muted-foreground" data-testid="task-chat-current-activity">
<span className="shimmer-text shimmer-text-muted">Thinking</span>
</div>
);
@@ -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 (
<div className="py-1" data-testid="task-chat-runner-turn" data-phase="startup">
<div className="flex flex-col gap-1 py-1" data-testid="task-chat-runner-turn" data-phase="startup">
<CurrentActivity items={items} />
{agentName ? <TaskChatAgentIdentity agentName={agentName} agentIcon={agentIcon} /> : null}
</div>
);
}
@@ -110,9 +113,12 @@ export function TaskChatRunnerTurn({
{!final ? <CurrentActivity items={items} /> : null}
</div>
) : null}
{agentName ? <TaskChatAgentIdentity agentName={agentName} agentIcon={agentIcon} /> : null}
{final ? (
<div data-testid="task-chat-final-response">
<TaskChatBubble item={final} />
<div className="tc-enter-bubble w-full" data-testid="task-chat-final-response">
<div className="break-words px-1 py-2 text-sm text-foreground" data-testid="task-chat-agent-bubble">
<MarkdownBody softBreaks linkIssueReferences>{final.text}</MarkdownBody>
</div>
</div>
) : null}
</div>
@@ -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", () => {
@@ -104,6 +104,9 @@ function classify(n: string): Exclude<ToolFamily, "mcp"> {
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());