fix(ui): share compact live and saved activity across runners (#13421)

## Thinking Path

> - Paperclip helps people manage AI agents and inspect their work.
> - Task threads show live activity and saved transcripts from several
runners.
> - Legacy runs used a separate activity renderer that expanded tool
cards as commands arrived.
> - A compact live row makes ongoing work easier to follow.
> - This PR shares the native runner activity group across live and
saved legacy turns.
> - Both paths now use the same labels, icon gutter, animation, and
history controls.

## Linked Issues or Issue Description

Related: Refs #13255, which introduced rolling native-runner activity
groups.

**What happened?**

During a legacy CLI run, each new command added an expanded tool card
under an activity heading. The original legacy parity story rendered a
completed turn, so it did not exercise this live path.

**Expected behavior**

Show one current activity line per commentary group. Roll that line
forward when a new activity starts. Keep tool icons aligned on the left,
use friendly labels, and show history only when expanded.

**Steps to reproduce**

1. Start a task with the Codex local adapter using the CLI engine.
2. Ask it to read two files in separate tool calls and run a test
command.
3. Watch the activity feed while the run is active, then expand its
history.

**Paperclip version or commit**

The live behavior was reproduced on
bdee5ebb21b2d09280e49c88bc329435da1b07c8. This update includes both live
and saved rendering fixes.

**Deployment mode**

Built from source with the local test-drive command and a real Codex CLI
agent.

## What Changed

- Use `TaskChatRunnerActivityGroup` for both saved legacy phases and
`TaskChatLiveTail`.
- Align the legacy Working spinner with the shared activity icon gutter.
- Cover streamed reasoning updates, successive commands, image labels,
hidden details, and explicit history expansion in tests.
- Feed raw legacy transcript events through the real adapter and live
renderer in Storybook. Add live, expanded, narrow, light, and completed
stories with the final reply preserved.

## Verification

- Passed 195 targeted tests covering the live tail, status pill, shared
activity group, native turn, and full task thread.
- Passed UI typechecking, token gates, UI build, and Storybook build
before submission.
- Observed a real Codex CLI run while it read separate files and ran
tests. The current activity stayed at one 32-pixel row without an
accumulated tool list; the spinner and activity icon centers aligned.
- Compared native and legacy Storybooks: identical rolling animation,
fixed height, persistent expansion, truncated long labels, and correct
light and completed states.
- Full repository `pnpm -r typecheck` and `pnpm build` passed after
replaying the branch on current master, including the Rust runner. The
full `pnpm test:run` suite is still running.

## Risks

- Legacy activity now starts collapsed. Users can expand each group and
each row to inspect the same transcript details.
- Live runtime request cards must retain their timeline positions.
Existing task-thread and native-runner regression tests cover this
boundary.
- No API, database, or transcript format changes.

## Model Used

- OpenAI GPT-6 through Codex for the live-path fix, tests, and browser
verification. Capabilities used: reasoning, tool use, local code
execution, and image inspection. The exact deployment ID and
context-window size are not exposed in this session.
- The original saved-turn change recorded OpenAI Codex with GPT-5.6; its
exact variant and context size were not recorded.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta
2026-09-15 16:40:32 -05:00
committed by GitHub
co-authored by Paperclip
parent d49f168381
commit 9cfa7fd2d1
9 changed files with 260 additions and 167 deletions
+2 -2
View File
@@ -3471,9 +3471,9 @@ describe("TaskChatThread live transcript", () => {
"Streaming through the shared renderer",
);
const phaseSummary = tail!.querySelector<HTMLButtonElement>(
'[data-testid="task-chat-phase-summary"]',
'[data-testid="task-chat-activity-phase-toggle"]',
);
expect(phaseSummary?.getAttribute("aria-expanded")).toBe("true");
expect(phaseSummary?.getAttribute("aria-expanded")).toBe("false");
expect(tail!.textContent).toContain("src/app.ts");
// None of the debug plumbing reaches the thread.
for (const noise of [
@@ -73,14 +73,16 @@ export function TaskChatLiveRunPill({
return (
<div
className="flex min-w-0 items-center gap-2.5 px-1 py-2"
className="flex min-w-0 items-center gap-2 py-2"
data-testid="task-chat-live-run-pill"
>
<span className="inline-flex items-center gap-2 text-sm font-medium text-foreground/80">
{active ? (
<Loader2 className="h-4 w-4 shrink-0 animate-spin text-muted-foreground" />
<span className="flex size-5 shrink-0 items-center justify-center">
<Loader2 className="size-4 animate-spin text-muted-foreground" />
</span>
) : (
<span className="flex h-4 w-4 shrink-0 items-center justify-center">
<span className="flex size-5 shrink-0 items-center justify-center">
<span className={cn("h-1.5 w-1.5 rounded-full", (failed || active) ? "bg-muted-foreground/40" : "bg-emerald-500/70")} />
</span>
)}
@@ -5,6 +5,7 @@ import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { TranscriptEntry } from "@/adapters";
import { ThemeProvider } from "@/context/ThemeContext";
import { MemoryRouter } from "@/lib/router";
import { TaskChatLiveTail } from "./TaskChatLiveTail";
import { transcriptToTaskChatItems } from "./transcript-adapter";
import type { TaskChatItem } from "./task-chat-model";
@@ -30,9 +31,11 @@ describe("TaskChatLiveTail", () => {
function render(items: TaskChatItem[], emptyMessage?: string) {
flushSync(() =>
root!.render(
<ThemeProvider>
<TaskChatLiveTail items={items} emptyMessage={emptyMessage} />
</ThemeProvider>,
<MemoryRouter>
<ThemeProvider>
<TaskChatLiveTail items={items} emptyMessage={emptyMessage} />
</ThemeProvider>
</MemoryRouter>,
),
);
}
@@ -41,7 +44,18 @@ describe("TaskChatLiveTail", () => {
return transcriptToTaskChatItems(entries, { runId: "run-1", running });
}
it("renders streamed reply markdown and tool cards from a live transcript", () => {
const toggle = () => container.querySelector<HTMLButtonElement>(
'[data-testid="task-chat-activity-phase-toggle"]',
)!;
const viewport = () => container.querySelector(
'[data-testid="task-chat-activity-viewport"]',
)!;
const expandFirstDetail = () => {
flushSync(() => toggle().click());
flushSync(() => container.querySelector<HTMLButtonElement>("li button")!.click());
};
it("renders streamed reply markdown with one compact activity, without old tool cards", () => {
const items = parse([
{ kind: "assistant", ts: TS, text: "Looking into the failing test." },
{ kind: "tool_call", ts: TS, name: "Read", toolUseId: "t1", input: { file_path: "src/app.ts" } },
@@ -52,11 +66,11 @@ describe("TaskChatLiveTail", () => {
expect(container.querySelector('[data-testid="task-chat-phase-interstitial"]')?.textContent).toContain(
"Looking into the failing test.",
);
const phaseSummary = container.querySelector<HTMLButtonElement>('[data-testid="task-chat-phase-summary"]');
expect(phaseSummary?.getAttribute("aria-expanded")).toBe("true");
// Tool row renders with its name + mono target.
expect(container.textContent).toContain("Read");
expect(container.textContent).toContain("src/app.ts");
expect(toggle().getAttribute("aria-expanded")).toBe("false");
expect(viewport().textContent).toContain("Read a file");
expect(viewport().textContent).toContain("src/app.ts");
expect(container.querySelector('[data-testid="task-chat-tool-card"]')).toBeNull();
expect(container.querySelector('[data-testid="task-chat-phase-child-rail"]')).toBeNull();
});
it("keeps tool diff bodies out of the activity feed", () => {
@@ -67,18 +81,15 @@ describe("TaskChatLiveTail", () => {
]);
render(items);
const phaseSummary = container.querySelector<HTMLButtonElement>('[data-testid="task-chat-phase-summary"]');
expect(phaseSummary?.getAttribute("aria-expanded")).toBe("true");
expect(toggle().getAttribute("aria-expanded")).toBe("false");
expect(container.textContent).not.toContain("const x = 1;");
expect(container.textContent).not.toContain("+1 −1");
const tool = container.querySelector<HTMLButtonElement>('[data-testid="task-chat-tool-card"] button');
expect(tool).not.toBeNull();
flushSync(() => tool?.click());
expandFirstDetail();
expect(container.textContent).toContain("+1 −1");
expect(container.textContent).not.toContain("const x = 1;");
expect(container.querySelector('[data-testid="task-chat-tool-change-summary"]')).not.toBeNull();
expect(container.querySelector('[data-testid="task-chat-runner-activity-detail"]')).not.toBeNull();
});
it("drops the debug plumbing kinds RunTranscriptView surfaced", () => {
@@ -110,8 +121,8 @@ describe("TaskChatLiveTail", () => {
expect(container.querySelector('[data-testid="task-chat-phase-interstitial"]')?.textContent).toContain(
"Here is the real reply.",
);
const phaseSummary = container.querySelector<HTMLButtonElement>('[data-testid="task-chat-phase-summary"]');
expect(phaseSummary?.getAttribute("aria-expanded")).toBe("true");
expect(toggle().getAttribute("aria-expanded")).toBe("false");
flushSync(() => toggle().click());
const text = container.textContent ?? "";
expect(text).toContain("Here is the real reply.");
expect(text).toContain("pnpm test");
@@ -130,49 +141,64 @@ describe("TaskChatLiveTail", () => {
}
});
it("renders provider-supplied reasoning in the live activity stream", () => {
const items = parse([
{ kind: "thinking", ts: TS, text: "Provider reasoning summary" },
{ kind: "assistant", ts: TS, text: "Visible answer." },
]);
expect(items[0]).toMatchObject({ kind: "thinking", streaming: false });
render(items);
it("updates only the latest reasoning line in place, with full text available on expansion", () => {
const entries: TranscriptEntry[] = [
{ kind: "thinking", ts: TS, itemId: "reasoning", text: "First line\nChecking" },
];
render(parse(entries));
const row = viewport().querySelector("[data-activity-row]");
entries.push({ kind: "thinking", ts: TS, itemId: "reasoning", text: " the file", delta: true });
render(parse(entries));
expect(container.textContent).toContain("Visible answer.");
const reasoningPhase = container.querySelector<HTMLButtonElement>(
'[data-testid="task-chat-phase-summary"]',
);
expect(reasoningPhase?.textContent).toContain("Reasoning");
expect(reasoningPhase?.getAttribute("aria-expanded")).toBe("true");
expect(viewport().querySelector("[data-activity-row]")).toBe(row);
expect(viewport().textContent).toContain("Checking the file");
expect(container.textContent).not.toContain("First line");
expect(container.querySelector(".runner-activity-roll-in")).toBeNull();
expect(toggle().getAttribute("aria-expanded")).toBe("false");
expandFirstDetail();
expect(container.textContent).toContain("First line");
expect(container.textContent).toContain("Checking the file");
expect(container.querySelector('[data-testid="task-chat-phase-child-rail"]')).toBeNull();
});
expect(container.textContent).toContain("Provider reasoning summary");
const thinking = container.querySelector('[data-testid="task-chat-thinking"]');
expect(thinking?.getAttribute("data-state")).toBe("settled");
expect(
thinking?.querySelector('[data-testid="task-chat-thinking-text"]')
?.textContent,
).toContain("Provider reasoning summary");
const phaseChildren = thinking?.closest(
'[data-testid="task-chat-phase-children"]',
it("rolls successive CLI commands and image calls through one row without accumulating history", () => {
const entries: TranscriptEntry[] = [
{ kind: "assistant", ts: TS, text: "Checking the fix.", channel: "progress" },
{ kind: "tool_call", ts: TS, name: "exec_command", toolUseId: "t1", input: { command: "git status" } },
];
render(parse(entries));
const first = viewport().querySelector("[data-activity-row]");
entries.push({ kind: "tool_result", ts: TS, toolUseId: "t1", content: "clean", isError: false });
render(parse(entries));
expect(viewport().querySelector("[data-activity-row]")).toBe(first);
expect(viewport().textContent).toContain("Ran a command");
expect(container.querySelector(".runner-activity-roll-in")).toBeNull();
entries.push({ kind: "tool_call", ts: TS, name: "Bash", toolUseId: "t2", input: { command: "pnpm test" } });
render(parse(entries));
expect(container.querySelector(".runner-activity-roll-in")?.textContent).toContain("Running a command");
const outgoing = container.querySelector(".runner-activity-roll-out")!;
expect(outgoing.getAttribute("aria-hidden")).toBe("true");
flushSync(() => outgoing.dispatchEvent(new Event("webkitAnimationEnd", { bubbles: true })));
expect(viewport().querySelectorAll("[data-activity-row]")).toHaveLength(1);
expect(container.textContent).not.toContain("git status");
expect(container.querySelector('[data-testid="task-chat-runner-activity-list"]')).toBeNull();
entries.push(
{ kind: "tool_result", ts: TS, toolUseId: "t2", content: "passed", isError: false },
{ kind: "tool_call", ts: TS, name: "image_generation", toolUseId: "t3", input: { file_path: "preview.png" } },
);
expect(phaseChildren?.classList.contains("ml-2.5")).toBe(true);
expect(phaseChildren?.classList.contains("pl-6")).toBe(true);
expect(
phaseChildren?.querySelector('[data-testid="task-chat-phase-child-rail"]'),
).not.toBeNull();
expect(thinking?.textContent).not.toContain("Reasoning");
expect(thinking?.querySelector(".shimmer-text")).toBeNull();
expect(
thinking
?.querySelector('[data-testid="task-chat-thinking-icon"]')
?.classList.contains("text-(--status-agent-running)"),
).toBe(false);
expect(
thinking
?.querySelector('[data-testid="task-chat-thinking-icon"]')
?.classList.contains("text-muted-foreground/50"),
).toBe(true);
expect(thinking?.querySelector(".task-chat-reasoning-markdown")).toBeNull();
render(parse(entries));
expect(container.querySelector(".runner-activity-roll-in")?.textContent).toContain("Generating an image");
flushSync(() => toggle().click());
expect(container.querySelectorAll("li")).toHaveLength(3);
expect(container.textContent).toContain("git status");
expect(container.textContent).toContain("pnpm test");
entries.push({ kind: "tool_result", ts: TS, toolUseId: "t3", content: "created", isError: false });
render(parse(entries));
expect(toggle().getAttribute("aria-expanded")).toBe("true");
expect(container.textContent).toContain("Generated an image");
});
it("bounds long tool targets and wraps the full value only when expanded", () => {
@@ -194,26 +220,21 @@ describe("TaskChatLiveTail", () => {
.find((item) => item.kind === "tool")?.target;
render(items);
const tool = container.querySelector<HTMLButtonElement>(
'[data-testid="task-chat-tool-card"] button',
);
const collapsedTarget = tool?.querySelector(
".task-chat-collapsed-line-fade",
);
expect(tool?.classList.contains("overflow-hidden")).toBe(true);
expect(tool?.getAttribute("aria-expanded")).toBe("false");
const collapsedTarget = viewport().querySelector("[title]");
expect(collapsedTarget?.classList.contains("truncate")).toBe(true);
expect(toggle().getAttribute("aria-expanded")).toBe("false");
expect(renderedTarget).toBeTruthy();
expect(collapsedTarget?.textContent).toBe(renderedTarget);
flushSync(() => tool?.click());
expandFirstDetail();
const expandedTarget = container.querySelector(
'[data-testid="task-chat-tool-target-detail"]',
'[data-testid="task-chat-runner-activity-detail"] p',
);
expect(tool?.getAttribute("aria-expanded")).toBe("true");
expect(toggle().getAttribute("aria-expanded")).toBe("true");
expect(expandedTarget?.textContent).toBe(renderedTarget);
expect(
expandedTarget?.classList.contains("task-chat-expanded-line-wrap"),
expandedTarget?.classList.contains("break-all"),
).toBe(true);
});
@@ -3,26 +3,16 @@ import { MarkdownBody } from "@/components/MarkdownBody";
import type { TaskChatItem, TaskChatRuntimeRequestDecision, TaskChatRuntimeRequestItem } from "./task-chat-model";
import { TaskChatToolCard } from "./TaskChatToolCard";
import { TaskChatUsageReadout } from "./TaskChatUsageReadout";
import { TaskChatActivityPhase } from "./TaskChatActivityPhase";
import { TaskChatRunnerActivityGroup } from "./TaskChatRunnerActivityGroup";
import { TaskChatThinking } from "./TaskChatThinking";
import { TaskChatMarker } from "./TaskChatMarker";
import { TaskChatProtocolCard } from "./TaskChatProtocolCard";
import { TaskChatProtocolActivityRow } from "./TaskChatProtocolActivityRow";
import { buildTurnTimelineRows } from "./transcript-adapter";
/**
* Live-tail body for the experimental chat-style view (PAP-463, Workstream C1
* of PAP-458).
*
* Renders the in-flight run's streaming transcript as the SAME clean rows the
* settled thread uses — tool cards (with diffs) and the streamed reply markdown
* — instead of the verbatim `RunTranscriptView` debug viewer that the live tail
* used since `e4f3d7733`. The items come from `transcriptToTaskChatItems`, which
* already drops the debug plumbing (init / stdout / stderr / system / user /
* result), so none of `RunTranscriptView`'s noise can reach the thread: no INIT
* row, no "N LOG LINES" / "N SYSTEM MESSAGES" banners, no raw stdout/JSON dumps,
* no "Streaming" chip, no uppercase "USED TERMINAL" cards. The status pill above
* this body (`TaskChatLiveRunPill`) owns the run-status affordance.
* Live body for legacy runner transcripts. The adapter drops debug plumbing;
* the same activity group as the native runner shows one rolling current row
* with friendly tool labels and explicitly expandable history. The status pill
* above this body (`TaskChatLiveRunPill`) owns the run-status affordance.
*
* Stable assistant and runtime-request boundaries compact the rows into an
* ordered turn timeline. Commentary remains readable above the activity group
@@ -102,27 +92,9 @@ function renderTailRow(
return <TaskChatThinking key={item.id} item={item} />;
case "activity_phase":
return (
<TaskChatActivityPhase
<TaskChatRunnerActivityGroup
key={item.id}
item={item}
defaultOpen={item.items.some(
(child) =>
child.kind === "thinking" &&
child.lines.some((line) => line.trim().length > 0),
)}
childrenClassName="relative ml-2.5 pl-6"
showChildRail
renderChild={(child) => child.kind === "tool"
? <TaskChatToolCard item={child} />
: child.kind === "thinking"
? <TaskChatThinking item={child} />
: child.kind === "marker"
? <TaskChatMarker item={child} />
: child.kind === "protocol"
? child.surface === "runtime_request"
? <TaskChatProtocolCard item={child} onRuntimeRequestDecision={onRuntimeRequestDecision} />
: <TaskChatProtocolActivityRow item={child} />
: <TaskChatUsageReadout item={child} />}
/>
);
case "protocol":
@@ -209,6 +209,45 @@ describe("TaskChatRunnerActivityGroup", () => {
expect(toggle().textContent).not.toMatch(/\d+ failed/);
});
it("uses the compact runner group for a legacy persisted turn", () => {
act(() =>
root.render(
<TaskChatExpansionState.Provider value={memory}>
<MemoryRouter>
<ThemeProvider>
<TaskChatThreadView
scroll={false}
items={[{
id: "legacy-turn",
kind: "turn",
settled: true,
summary: { toolCount: 1, added: 0, removed: 0 },
items: [{
id: "legacy-phase",
kind: "activity_phase",
active: false,
summary: "Ran a command",
items: [tool("legacy", "completed")],
}],
}]}
/>
</ThemeProvider>
</MemoryRouter>
</TaskChatExpansionState.Provider>,
),
);
act(() =>
container
.querySelector<HTMLButtonElement>('[data-testid="task-chat-turn-summary"]')!
.click(),
);
expect(container.querySelector('[data-testid="task-chat-activity-phase-toggle"]')).not.toBeNull();
expect(container.querySelector('[data-testid="task-chat-phase-summary"]')).toBeNull();
expect(container.textContent).toContain("Ran command");
expect(container.textContent).not.toContain("command-legacy");
});
it("does not offer empty disclosures for sparse activities", () => {
render([
{ id: "thinking-empty", kind: "thinking", lines: [], streaming: true },
@@ -195,35 +195,11 @@ function renderItem(
case "usage":
return <TaskChatUsageReadout item={item} />;
case "activity_phase":
if (activityAppearance === "runner") return <TaskChatRunnerActivityGroup item={item} />;
return (
<TaskChatActivityPhase
item={item}
appearance={activityAppearance}
renderChild={(child) =>
child.kind === "protocol" && child.surface !== "runtime_request" ? (
<TaskChatProtocolActivityRow item={child} />
) : (
renderItem(
child,
onApprovalDecision,
undefined,
undefined,
undefined,
undefined,
onRuntimeRequestDecision,
activityAppearance,
undefined,
false,
undefined,
undefined,
undefined,
attachments,
)
)
}
/>
);
// Legacy adapter transcripts and native runner transcripts now share the
// same compact activity treatment. Keeping this decision at the common
// renderer boundary also gives old persisted runs the current taxonomy,
// alignment, one-line targets, and collapsed-by-default behavior.
return <TaskChatRunnerActivityGroup item={item} />;
case "interaction":
return renderInteraction ? renderInteraction(item) : null;
case "plan_document":
@@ -1,6 +1,7 @@
# Runner activity review
Open **Tasks / Runner activity preview / 01 · Desktop live · animated** in Storybook.
Compare **05 · Legacy live · animated**, which uses the actual legacy live renderer.
Use Pause / Next to step through the fixture, or Replay to watch the transitions.
From this worktree, start the preview with:
@@ -24,7 +25,12 @@ pnpm --filter @paperclipai/ui exec storybook dev --port 6024 --host 127.0.0.1 --
not leave the desktop animation squeezed into a mobile preview.
- Reduced motion uses immediate replacement instead of the rolling transition.
The fixture renders the production `TaskChatRunnerTurn` and activity group, with
simulated event timing. It does not invoke a runner. Production integration tests
Native stories render `TaskChatRunnerTurn`. Live legacy stories feed raw CLI
transcript events through `transcriptToTaskChatItems` into `TaskChatLiveTail` and
`TaskChatLiveRunPill`. Both use the same production activity group. Legacy stories
cover live playback, expanded history, narrow long labels, light mode, and completed
turns. Only the completed story uses the saved-thread renderer from the start.
Event timing is simulated; the fixture does not invoke a runner. Production integration tests
cover commentary boundaries, approvals, final replies, retained expansion,
neutral failures, and reduced motion.
@@ -3,11 +3,15 @@ import { Pause, Play, RotateCcw, StepForward } from "lucide-react";
import { useReducedMotion } from "motion/react";
import { TaskChatThreadView } from "@/components/task-chat/TaskChatThreadView";
import { TaskChatRunnerTurn } from "@/components/task-chat/TaskChatRunnerTurn";
import { TaskChatLiveTail } from "@/components/task-chat/TaskChatLiveTail";
import { TaskChatLiveRunPill, toolCountSummaryFromEntries } from "@/components/task-chat/TaskChatLiveRunPill";
import { TaskChatExpansionState } from "@/components/task-chat/expansion-state";
import { buildTurnTimelineRows } from "@/components/task-chat/transcript-adapter";
import { buildTurnTimelineRows, transcriptToTaskChatItems } from "@/components/task-chat/transcript-adapter";
import type { TranscriptEntry } from "@/adapters";
import type {
TaskChatItem,
TaskChatMessageItem,
TaskChatTurnItem,
} from "@/components/task-chat/task-chat-model";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
@@ -48,7 +52,7 @@ const entries: Entry[] = [
kind: "activity",
id: "read",
tool: "read",
target: "TaskChatActivityPhase.tsx",
target: "TaskChatRunnerActivityGroup.tsx",
detail:
"The activity phase owns expansion. Individual tool rows have separate icon widths and padding.",
},
@@ -104,10 +108,10 @@ const entries: Entry[] = [
{
kind: "activity",
id: "image",
tool: "view_image",
tool: "image_generation",
target: "runner-activity-mobile.png",
detail:
"Reviewed the narrow layout: tool paths truncate in both modes. Click a row to inspect its full target and detail.",
"Generated an image to check the activity feed’s image-tool label and icon.",
},
{
kind: "commentary",
@@ -116,6 +120,35 @@ const entries: Entry[] = [
},
];
// Exercise the CLI transcript adapter, including provider names, stable call IDs,
// multi-line reasoning, and tool results, before entering the legacy live path.
function legacyTranscript(visible: Entry[], finished: boolean): TranscriptEntry[] {
const ts = "2026-09-15T12:00:00.000Z";
return visible.flatMap((entry, index): TranscriptEntry[] => {
if (entry.kind === "commentary") return [{
kind: "assistant", ts, itemId: entry.id, text: entry.text,
channel: entry.id === "final" ? "final" : "progress",
}];
const active = !finished && index === visible.length - 1;
if (!entry.tool) return [{
kind: "thinking", ts, itemId: entry.id,
text: `${entry.target}\n${entry.detail}`,
lifecycle: active ? "started" : "completed",
}];
const name = entry.tool === "read" ? "Read" : entry.tool;
const input = entry.tool === "exec_command"
? { command: entry.target }
: { file_path: entry.target };
const call: TranscriptEntry = {
kind: "tool_call", ts, name, toolUseId: entry.id, input,
};
return active && !entry.failed ? [call] : [call, {
kind: "tool_result", ts, toolUseId: entry.id,
content: entry.detail, isError: Boolean(entry.failed),
}];
});
}
export interface RunnerActivityPreviewProps {
initialStep?: number;
autoPlay?: boolean;
@@ -123,6 +156,7 @@ export interface RunnerActivityPreviewProps {
narrow?: boolean;
longLabels?: boolean;
failed?: boolean;
legacy?: boolean;
}
export function RunnerActivityPreview({
@@ -132,6 +166,7 @@ export function RunnerActivityPreview({
narrow = false,
longLabels = false,
failed = false,
legacy = false,
}: RunnerActivityPreviewProps) {
const [step, setStep] = useState(initialStep);
const [playing, setPlaying] = useState(autoPlay);
@@ -167,7 +202,10 @@ export function RunnerActivityPreview({
};
});
const memory = useMemo(() => new Map<string, boolean>(), [replay]);
const items = visible.map((entry, index): TaskChatItem => {
const transcript = legacyTranscript(visible, finished);
const items = legacy
? transcriptToTaskChatItems(transcript, { runId: `legacy-${replay}`, running: !finished })
: visible.map((entry, index): TaskChatItem => {
if (entry.kind === "commentary")
return {
kind: "message",
@@ -199,13 +237,32 @@ export function RunnerActivityPreview({
if (row.kind === "activity_phase" && !memory.has(row.id))
memory.set(row.id, true);
}
const finalResponse = items.find(
(item): item is TaskChatMessageItem => item.kind === "message" && item.channel === "final",
);
const savedTurn: TaskChatTurnItem = {
id: "preview-saved-turn",
kind: "turn",
settled: true,
standaloneHeader: !legacy,
agentName: "Engineer",
agentIcon: "code",
items: buildTurnTimelineRows(items, false),
summary: {
durationLabel: "28s",
toolCount: items.filter((item) => item.kind === "tool").length,
added: 0,
removed: 0,
},
finalResponse: legacy ? undefined : finalResponse,
};
return (
<div className="min-h-screen bg-background text-foreground">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border px-6 py-3">
<div className="flex flex-col gap-1">
<h1 className="text-sm font-semibold">Runner activity</h1>
<p className="text-xs text-muted-foreground">
Production component ·{" "}
{legacy ? "Legacy CLI transcript" : "Native runner"} ·{" "}
{reducedMotion ? "Reduced motion" : "One activity at a time"}
</p>
</div>
@@ -262,28 +319,19 @@ export function RunnerActivityPreview({
{finished ? (
<TaskChatThreadView
scroll={false}
items={[
{
id: "preview-saved-turn",
kind: "turn",
settled: true,
standaloneHeader: true,
agentName: "Engineer",
agentIcon: "code",
items: buildTurnTimelineRows(items, false),
summary: {
durationLabel: "28s",
toolCount: 8,
added: 0,
removed: 0,
},
finalResponse: items.find(
(item): item is TaskChatMessageItem =>
item.kind === "message" && item.channel === "final",
),
},
]}
items={legacy && finalResponse
? [{ ...finalResponse, attachedTurn: savedTurn }]
: [savedTurn]}
/>
) : legacy ? (
<div className="flex flex-col gap-2" data-testid="legacy-live-preview">
<TaskChatLiveRunPill
status="running"
startedAtMs={null}
toolSummary={toolCountSummaryFromEntries(transcript)}
/>
<TaskChatLiveTail items={items} />
</div>
) : (
<TaskChatRunnerTurn
runId={`preview-${replay}`}
@@ -49,8 +49,37 @@ export const IconAlignment: Story = {
name: "04 · Icon alignment",
args: { initialStep: 12, autoPlay: false, expanded: true },
};
export const LegacyRunnerParity: Story = {
name: "05 · Legacy live · animated",
args: { initialStep: 1, autoPlay: true, expanded: false, legacy: true },
parameters: {
docs: {
description: {
story:
"Raw CLI transcript events pass through transcriptToTaskChatItems and TaskChatLiveTail, the actual live legacy path. Thinking and tools roll through the same single-line activity group as the native runner. Use Next to inspect each transition or expand a group to retain its history.",
},
},
},
};
export const LegacyLiveExpanded: Story = {
name: "Legacy · expanded history · animated",
args: { initialStep: 3, autoPlay: true, expanded: true, legacy: true },
};
export const LegacyLongLabels: Story = {
name: "Legacy · narrow · long labels",
args: { initialStep: 8, autoPlay: false, narrow: true, longLabels: true, legacy: true },
};
export const LegacyLight: Story = {
name: "Legacy · light · animated",
args: { initialStep: 1, autoPlay: true, legacy: true },
globals: { theme: "light" },
};
export const LegacyCompleted: Story = {
name: "Legacy · completed",
args: { initialStep: 12, autoPlay: false, legacy: true },
};
export const LongLabels: Story = {
name: "05 · Long labels & narrow layout",
name: "06 · Long labels & narrow layout",
args: { initialStep: 8, autoPlay: false, narrow: true, longLabels: true },
};
export const Failure: Story = {