feat(runner): add OpenCode and durable runner UX

This commit is contained in:
Dotta committed 2026-08-21 15:18:00 -05:00
1 parent f34591016e
commit 4c3e30cdad
100 files changed
+7156 -412

No files matched your search

@@ -29,6 +29,8 @@ interface TaskChatBubbleProps {
* Supplied by TaskChatThreadView when `item.attachedTurn` is set.
*/
attachedTurn?: ReactNode;
/** New-runner Worked header, rendered above the durable final response. */
beforeTurn?: ReactNode;
/**
* copy · 👍 · 👎 controls for an agent bubble's footer line (PAP-413).
* Rendered here only for a runless reply (leading the bare timestamp); when
@@ -63,7 +65,7 @@ function galleryItemForImage(src: string, name?: string): GalleryMediaItem {
};
}
export function TaskChatBubble({ item, queuedAction, attachedTurn, actions }: TaskChatBubbleProps) {
export function TaskChatBubble({ item, queuedAction, attachedTurn, beforeTurn, actions }: TaskChatBubbleProps) {
// Clicking an embedded image opens the full-screen lightbox (with download);
// arrow keys walk across the other images in the same bubble.
const [lightboxSrc, setLightboxSrc] = useState<string | null>(null);
@@ -96,6 +98,7 @@ export function TaskChatBubble({ item, queuedAction, attachedTurn, actions }: Ta
: Math.max(0, galleryItems.findIndex((galleryItem) => galleryItem.contentPath === lightboxSrc));
return (
<div className={cn("tc-enter-bubble flex w-full flex-col gap-1", 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">
@@ -1,4 +1,4 @@
import { Loader2 } from "lucide-react";
import { ChevronRight, Loader2 } from "lucide-react";
import type { TranscriptEntry } from "../../adapters";
import { cn } from "@/lib/utils";
import { useSecondTick } from "@/hooks/useSecondTick";
@@ -47,6 +47,9 @@ export function TaskChatLiveRunPill({
startedAtMs,
finishedAtMs,
toolSummary,
runnerStyle = false,
expanded,
onToggle,
}: {
status: string;
/** Run start (startedAt, falling back to createdAt) in ms, or null if unknown. */
@@ -54,6 +57,10 @@ export function TaskChatLiveRunPill({
/** Run finish in ms once terminal; drives the settled elapsed readout. */
finishedAtMs?: number | null;
toolSummary: string | null;
/** Codex-style new-runner header: no spinner, whole-second compact duration. */
runnerStyle?: boolean;
expanded?: boolean;
onToggle?: () => void;
}) {
const active = !isTerminalRunStatus(status);
// One shared page-wide ticker drives the live elapsed readout, matching the
@@ -62,10 +69,42 @@ export function TaskChatLiveRunPill({
const elapsedMs =
startedAtMs == null ? null : (active ? Date.now() : finishedAtMs ?? Date.now()) - startedAtMs;
const elapsed = elapsedMs != null ? formatDurationWords(elapsedMs) : null;
const elapsed = elapsedMs != null
? runnerStyle
? `${Math.max(0, Math.floor(elapsedMs / 1000))}s`
: formatDurationWords(elapsedMs)
: null;
const verb = active ? "Working" : "Worked";
const suffix = elapsed ? `for ${elapsed}` : null;
if (runnerStyle) {
const content = (
<>
<span className={cn("text-sm font-medium text-foreground/80", active && "shimmer-text")}>{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")}
aria-hidden
/>
</>
);
return onToggle ? (
<button
type="button"
onClick={onToggle}
aria-expanded={expanded}
className="flex w-full min-w-0 items-center gap-1.5 border-b border-border/70 px-1 py-2 text-left transition-colors hover:text-foreground"
data-testid="task-chat-live-run-pill"
>
{content}
</button>
) : (
<div className="flex min-w-0 items-center gap-1.5 border-b border-border/70 px-1 py-2" data-testid="task-chat-live-run-pill">
{content}
</div>
);
}
return (
<div
className="flex min-w-0 items-center gap-2.5 px-1 py-2"
@@ -29,12 +29,18 @@ import { buildActivityPhases } from "./transcript-adapter";
export function TaskChatLiveTail({
items,
emptyMessage,
excludeFinal = false,
}: {
items: readonly TaskChatItem[];
/** Shown when nothing renderable has streamed yet (queued / pre-first-token). */
emptyMessage?: string;
/** New-runner turn renders final-answer messages in its dedicated response slot. */
excludeFinal?: boolean;
}) {
const rows = buildActivityPhases(items, true)
const visibleItems = excludeFinal
? items.filter((item) => item.kind !== "message" || item.interstitial)
: items;
const rows = buildActivityPhases(visibleItems, true)
.map((item) => renderTailRow(item))
.filter((row): row is ReactElement => row != null);
@@ -0,0 +1,56 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { ThemeProvider } from "@/context/ThemeContext";
import { TaskChatRunnerTurn } from "./TaskChatRunnerTurn";
import type { TaskChatItem } from "./task-chat-model";
describe("TaskChatRunnerTurn", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
const render = (items: TaskChatItem[], status = "running") => act(() => root.render(
<ThemeProvider>
<TaskChatRunnerTurn 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).not.toContain("Working");
expect(container.textContent).not.toContain("Waiting for transcript");
});
it("keeps progress separate from the replace-in-place current command", () => {
render([
{ id: "p1", kind: "message", author: "agent", text: "Running the exact command now.", interstitial: true, channel: "progress", streaming: true },
{ id: "t1", kind: "tool", name: "command", rawName: "Bash", target: "for i in 1 2 3 4; do echo STREAM-$i; done", status: "in_progress", detail: "STREAM-1\n" },
]);
expect(container.querySelector('[data-testid="task-chat-progress-update"]')?.textContent).toContain("Running the exact command now.");
expect(container.querySelector('[data-testid="task-chat-current-activity"]')?.textContent).toContain("Running a command");
expect(container.textContent).toContain("STREAM-$i");
});
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 },
]);
expect(container.querySelector('[data-testid="task-chat-final-response"]')?.textContent).toContain("Completed successfully.");
expect(container.querySelector('[data-testid="task-chat-current-activity"]')).toBeNull();
});
});
@@ -0,0 +1,103 @@
import { 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 { TaskChatLiveRunPill } from "./TaskChatLiveRunPill";
import { TaskChatLiveTail } from "./TaskChatLiveTail";
import { toolTaxonomy } from "./tool-taxonomy";
function lastOf<T extends TaskChatItem>(items: readonly TaskChatItem[], predicate: (item: TaskChatItem) => item is T): T | undefined {
for (let index = items.length - 1; index >= 0; index -= 1) {
const item = items[index];
if (predicate(item)) return item;
}
return undefined;
}
function CurrentActivity({ items }: { items: readonly TaskChatItem[] }) {
const activity = lastOf<TaskChatThinkingItem | TaskChatToolItem>(
items,
(item): item is TaskChatThinkingItem | TaskChatToolItem => item.kind === "thinking" || item.kind === "tool",
);
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 />
<span className="shimmer-text shimmer-text-muted">Thinking</span>
</div>
);
}
const taxonomy = toolTaxonomy(activity.rawName ?? activity.name);
const Icon = taxonomy.icon;
const active = activity.status === "pending" || activity.status === "in_progress";
return (
<div className="flex min-w-0 items-center gap-2 px-1 py-1.5 text-sm text-muted-foreground" data-testid="task-chat-current-activity">
<Icon className="h-3.5 w-3.5 shrink-0" aria-hidden />
<span className={cn("shrink-0", active && "shimmer-text shimmer-text-muted")}>{taxonomy.verbLabel}</span>
{activity.target ? <span className="min-w-0 truncate font-mono text-xs">{activity.target}</span> : null}
</div>
);
}
export function TaskChatRunnerTurn({
items,
status,
startedAtMs,
finishedAtMs,
toolSummary,
}: {
items: readonly TaskChatItem[];
status: string;
startedAtMs: number | null;
finishedAtMs?: number | null;
toolSummary: string | null;
}) {
const [open, setOpen] = useState(false);
const progress = lastOf<TaskChatMessageItem>(items, (item): item is TaskChatMessageItem => item.kind === "message" && Boolean(item.interstitial));
const final = lastOf<TaskChatMessageItem>(items, (item): item is TaskChatMessageItem => item.kind === "message" && item.channel === "final");
if (status === "queued") {
return (
<div className="py-1" data-testid="task-chat-runner-turn" data-phase="startup">
<CurrentActivity items={items} />
</div>
);
}
return (
<div className="flex min-w-0 flex-col" data-testid="task-chat-runner-turn">
<TaskChatLiveRunPill
status={status}
startedAtMs={startedAtMs}
finishedAtMs={finishedAtMs}
toolSummary={toolSummary}
runnerStyle
expanded={open}
onToggle={() => setOpen((value) => !value)}
/>
<div className="tc-turn-fold" data-folded={open ? "false" : "true"} aria-hidden={!open}>
<div>
<div className="flex flex-col gap-2 py-2">
<TaskChatLiveTail items={items as TaskChatItem[]} excludeFinal />
</div>
</div>
</div>
{!open ? (
<div className="flex min-w-0 flex-col py-1">
{progress ? (
<div className="tc-enter-cot-line min-w-0 px-1 py-1.5 text-sm text-foreground/90" data-testid="task-chat-progress-update">
<MarkdownBody softBreaks linkIssueReferences>{progress.text}</MarkdownBody>
</div>
) : null}
{!final ? <CurrentActivity items={items} /> : null}
</div>
) : null}
{final ? (
<div className="tc-enter-bubble px-1 py-2 text-sm text-foreground" data-testid="task-chat-final-response">
<MarkdownBody softBreaks linkIssueReferences>{final.text}</MarkdownBody>
</div>
) : null}
</div>
);
}
@@ -9,7 +9,8 @@ export function TaskChatThinking({ item }: { item: TaskChatThinkingItem }) {
const [open, setOpen] = useState(item.streaming || !item.collapsed);
const body = item.lines.join("\n").trim();
if (!body) return null;
const label = item.streaming ? "Reasoning…" : item.summaryLabel ?? "Reasoning";
const baseLabel = item.channel === "detail" ? "Reasoning detail" : "Reasoning";
const label = item.streaming ? `${baseLabel}…` : item.summaryLabel ?? baseLabel;
return (
<div className="flex min-w-0 flex-col text-xs" data-testid="task-chat-thinking">
@@ -69,21 +69,21 @@ function renderItem(
// put when the tool history expands (PAP-413). The two paths are mutually
// exclusive at runtime, so only one host ever mounts the node.
const actions = renderMessageActions?.(item);
const turn = item.attachedTurn ? (
<TaskChatTurn
item={item.attachedTurn}
timestampPrefix={item.attachedTurn.standaloneHeader ? undefined : item.timestamp}
leading={item.attachedTurn.standaloneHeader ? undefined : actions}
renderChild={(child) => renderItem(child, onApprovalDecision)}
/>
) : undefined;
return (
<TaskChatBubble
item={item}
actions={actions}
actions={item.attachedTurn?.standaloneHeader ? actions : item.attachedTurn ? undefined : actions}
queuedAction={renderQueuedAction?.(item)}
attachedTurn={
item.attachedTurn ? (
<TaskChatTurn
item={item.attachedTurn}
timestampPrefix={item.timestamp}
leading={actions}
renderChild={(child) => renderItem(child, onApprovalDecision)}
/>
) : undefined
}
beforeTurn={item.attachedTurn?.standaloneHeader ? turn : undefined}
attachedTurn={item.attachedTurn?.standaloneHeader ? undefined : turn}
/>
);
}
@@ -124,6 +124,13 @@ describe("TaskChatTurn", () => {
expect(fold()?.getAttribute("data-folded")).toBe("true");
});
it("renders the new-runner settled header as Worked for N above the response", () => {
renderTurn({ ...SETTLED, standaloneHeader: true });
expect(summaryBtn()?.textContent).toContain("Worked for 38s");
expect(summaryBtn()?.textContent).not.toContain("3 tools");
expect(fold()?.getAttribute("data-folded")).toBe("true");
});
it("toggles open on summary click", () => {
renderTurn(SETTLED);
flushSync(() => summaryBtn()!.click());
+12 -5
View File
@@ -85,7 +85,10 @@ export function TaskChatTurn({ item, renderChild, timestampPrefix, leading }: Ta
type="button"
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
className="group flex items-center gap-2 px-1 py-0.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
className={cn(
"group flex w-full items-center gap-2 px-1 text-muted-foreground transition-colors hover:text-foreground",
item.standaloneHeader ? "border-b border-border/70 py-2 text-sm" : "py-0.5 text-xs",
)}
data-testid="task-chat-turn-summary"
>
{timestampPrefix ? (
@@ -94,9 +97,13 @@ export function TaskChatTurn({ item, renderChild, timestampPrefix, leading }: Ta
<span aria-hidden className="text-(length:--text-micro)">·</span>
</>
) : null}
<SummaryIcon className="h-3.5 w-3.5 shrink-0" />
<span>{item.summary.failed ? "Stopped" : "Worked"}</span>
{turnSummaryMetrics(item.summary) ? (
{!item.standaloneHeader ? <SummaryIcon className="h-3.5 w-3.5 shrink-0" /> : null}
<span>
{item.standaloneHeader && item.summary.durationLabel
? `${item.summary.failed ? "Stopped" : "Worked"} for ${item.summary.durationLabel}`
: item.summary.failed ? "Stopped" : "Worked"}
</span>
{!item.standaloneHeader && turnSummaryMetrics(item.summary) ? (
// Time/tools/tokens is demoted, not deleted (PAP-502): it stays in the
// DOM (and the accessible tree) but fades in only on hover/focus so the
// settled line reads as "2:34 PM · ✓ Worked" at rest. Revealed too when
@@ -110,7 +117,7 @@ export function TaskChatTurn({ item, renderChild, timestampPrefix, leading }: Ta
{turnSummaryMetrics(item.summary)}
</span>
) : null}
<ChevronRight className={cn("h-3 w-3 shrink-0 transition-transform", open ? "rotate-90" : null)} />
<ChevronRight className={cn("h-3 w-3 shrink-0 transition-transform", item.standaloneHeader && "ml-auto", open ? "rotate-90" : null)} />
</button>
) : parentRow ? (
// The pill renders the expand button itself, wrapped around only the
@@ -80,14 +80,10 @@ export interface TaskChatMessageItem {
* "for {user}" chip beside the author name (the open cross-task write design (attribution)).
*/
onBehalfOfUserName?: string;
/**
* Agent text streamed inside a run turn (interstitial updates between tool
* calls). Ephemeral in the redesigned view (PAP-361): while streaming it
* takes the live parent row's line (TaskChatStatusItem.selfTalk); once
* finished it renders nowhere — the run log / classic transcript remain the
* archive. Tagged for live and settled transcripts alike.
*/
/** A transient provider progress update, distinct from the durable final response. */
interstitial?: boolean;
/** Normalized runner message channel. Only paperclip_runner currently emits it. */
channel?: "progress" | "final" | "unknown";
/** Epoch ms of the message's first streamed chunk. */
atMs?: number;
/**
@@ -121,6 +117,8 @@ export interface TaskChatThinkingItem {
collapsed?: boolean;
/** Human-readable elapsed label for the collapsed header. */
summaryLabel?: string;
/** Provider-emitted reasoning surface; never synthesized by the UI. */
channel?: "summary" | "detail" | "unknown";
}
/** A tool invocation row (ACP tool_call / tool_call_update). */
@@ -267,6 +265,8 @@ export interface TaskChatTurnItem {
liveStatus?: TaskChatStatusItem;
/** Animate the fold when settling (false = collapse instantly, e.g. history). */
animateFold?: boolean;
/** New-runner turns keep the Worked header standalone above the final response. */
standaloneHeader?: boolean;
summary: {
/** e.g. "38s" — omitted when unknown. */
durationLabel?: string;
@@ -64,10 +64,10 @@ export const TASK_CHAT_STATE_META: Record<TaskChatStateId, TaskChatStateMeta> =
},
"agent-message": {
id: "agent-message",
label: "Agent message",
label: "Final response",
tier: "live",
surface: "thread",
protocol: "text_delta stream:output (ACP agent_message_chunk)",
protocol: 'PRP item.delta kind:"agentMessage" channel:"final"',
},
thinking: {
id: "thinking",
@@ -78,14 +78,14 @@ export const TASK_CHAT_STATE_META: Record<TaskChatStateId, TaskChatStateMeta> =
},
responding: {
id: "responding",
label: "Responding (streaming)",
label: "Progress update (streaming)",
tier: "live",
surface: "thread",
protocol: "text_delta stream:output, streaming",
protocol: 'PRP item.delta kind:"agentMessage" channel:"progress"',
},
"responding-burst": {
id: "responding-burst",
label: "Responding (update burst)",
label: "Progress update burst",
tier: "live",
surface: "thread",
protocol: "text_delta stream:output ×N, tool calls between (PAP-368 dwell)",
@@ -21,6 +21,7 @@ describe("toolTaxonomy", () => {
verbLabel: "Running a command",
});
expect(toolTaxonomy("Shell").family).toBe("terminal");
expect(toolTaxonomy("command").family).toBe("terminal");
expect(toolTaxonomy("Grep")).toEqual({
family: "grep",
+1 -1
View File
@@ -88,7 +88,7 @@ function classify(n: string): Exclude<ToolFamily, "mcp"> {
if (first === "edit" || first === "write" || n === "multiedit" || n === "notebookedit") {
return "edit";
}
if (first === "bash" || first === "shell" || first === "run" || n.includes("terminal")) {
if (first === "bash" || first === "shell" || first === "run" || first === "command" || n.includes("terminal")) {
return "terminal";
}
// Content grep is its own action (search-code glyph); file-name globbing and
@@ -407,6 +407,24 @@ describe("settledRunChildren (PAP-361)", () => {
});
});
describe("paperclip runner semantic channels", () => {
it("keeps progress/final messages and reasoning summary/detail in distinct items", () => {
const parsed = transcriptToTaskChatItems([
{ kind: "assistant", ts: TS, text: "Checking.", channel: "progress", delta: true },
{ kind: "thinking", ts: TS, text: "Summary", channel: "summary", delta: true },
{ kind: "thinking", ts: TS, text: "Detail", channel: "detail", delta: true },
{ kind: "assistant", ts: TS, text: "Done.", channel: "final", delta: true },
], { runId: "run-channels", running: true });
expect(parsed.map((item) => item.kind)).toEqual(["message", "thinking", "thinking", "message"]);
expect(parsed[0]).toMatchObject({ interstitial: true, channel: "progress" });
expect(parsed[1]).toMatchObject({ channel: "summary" });
expect(parsed[2]).toMatchObject({ channel: "detail" });
expect(parsed[3]).toMatchObject({ interstitial: false, channel: "final" });
const history = settledRunChildren(parsed);
expect(JSON.stringify(history)).not.toContain("Done.");
});
});
describe("buildMergedTurnSummary (PAP-362)", () => {
const runA: TranscriptEntry[] = [
{ kind: "tool_call", ts: "2026-07-31T12:00:00.000Z", name: "Read", toolUseId: "a-1" } as TranscriptEntry,
@@ -168,18 +168,22 @@ export function transcriptToTaskChatItems(
const thinkingStartTs = new Map<number, string>();
let lastToolIndex = -1;
let thinkingIndex = -1;
let thinkingChannel: "summary" | "detail" | "unknown" | undefined;
let messageIndex = -1;
let messageChannel: "progress" | "final" | "unknown" | undefined;
const resetInline = () => {
thinkingIndex = -1;
thinkingChannel = undefined;
messageIndex = -1;
messageChannel = undefined;
};
for (const [i, entry] of entries.entries()) {
switch (entry.kind) {
case "thinking": {
if (!entry.text) break;
if (thinkingIndex >= 0) {
if (thinkingIndex >= 0 && thinkingChannel === entry.channel) {
const it = items[thinkingIndex];
if (it.kind === "thinking") {
it.lines.push(...entry.text.split("\n"));
@@ -196,16 +200,20 @@ export function transcriptToTaskChatItems(
// Settled history folds its thinking behind the header (v7);
// the in-flight run streams it expanded.
collapsed: !running,
channel: entry.channel,
});
thinkingIndex = items.length - 1;
thinkingChannel = entry.channel;
thinkingStartTs.set(thinkingIndex, entry.ts);
messageIndex = -1;
messageChannel = undefined;
}
break;
}
case "assistant": {
if (!entry.text) break;
if (messageIndex >= 0) {
const channel = entry.channel;
if (messageIndex >= 0 && messageChannel === channel) {
const it = items[messageIndex];
if (it.kind === "message") it.text += entry.text;
} else {
@@ -217,12 +225,15 @@ export function transcriptToTaskChatItems(
authorName: agentName,
text: entry.text,
streaming: running,
// Everything the agent says inside a run turn is self-talk until it
// lands as the posted comment — live and history tag it alike.
interstitial: true,
// Explicit final-answer items stream durably. Unclassified text
// remains transient for backwards compatibility and fail-closed
// handling of a delta whose start metadata was missed.
interstitial: channel !== "final",
channel,
atMs: Number.isFinite(atMs) ? atMs : undefined,
});
messageIndex = items.length - 1;
messageChannel = channel;
thinkingIndex = -1;
}
break;
@@ -440,6 +451,9 @@ export function buildActivityPhases(
const lastVisible = [...parsed].reverse().find((item) => item.kind !== "thinking");
for (const item of parsed) {
if (item.kind === "message") {
// Durable final-answer text is rendered beside the Worked header, never
// duplicated inside its expandable activity history.
if (!item.interstitial) continue;
// A settled transcript's trailing assistant text is the posted reply.
// Live/settle-gap tails keep it visible until that canonical reply lands.
if (!running && item === lastVisible) continue;