mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-02 02:07:25 +08:00
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:
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user