feat(ui): show one rolling runner activity per commentary group (#13255)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Task chat shows commentary and runner activity as work proceeds.
> - The expanded activity list uses multiple text lines and an indented
rail.
> - The folded view can repeat reasoning and the current tool in
separate places.
> - This pull request shows one rolling activity between commentary
messages.
> - Users can open a group to follow its full history and inspect each
item.
> - The benefit is a compact feed with aligned icons and stable row
height.

## Linked Issues or Issue Description

**What existing behavior does this improve?**

The runner activity groups in task chat, during live work and in saved
history.

**Current behavior**

Activity is spread across a group summary, a reasoning ticker, and a
current-tool row. Expanded rows have different alignment and can span
several lines.

**Proposed behavior**

Keep one latest activity per commentary group. Roll new activities
upward. Keep each expanded history row on one line. Use neutral failure
text without an X icon. Keep the full details available by opening an
individual row.

**Additional context**

Refs: #13246. That open PR covers related folded activity, steering
timestamps, and status alignment. This change implements the separately
reviewed activity-group design, including animated replacement,
single-line expanded history, and neutral failures. It does not change
steering timestamps or status pills.

## What Changed

- Add a shared runner activity group with persistent expansion and
reduced-motion support.
- Use the group in the production runner turn and remove duplicate
narration and current-tool rows.
- Preserve commentary, plan cards, request receipts, and final-response
placement.
- Keep tool output, reasoning, research sources, file previews, and
failure details accessible.
- Render the production turn in desktop and mobile animated Storybook
fixtures.
- Add tests for animation identity, failure visibility, expansion
persistence, and timeline order.
- Validate resource link schemes and omit disclosure controls for items
with no details.

## Verification

- Focused activity, runner turn, protocol detail, and task-thread tests
pass: 161 tests.
- `pnpm check:token-gates` passes.
- Browser checks confirm a 32-pixel compact row, centered icons, and
one-line expanded rows.
- `pnpm -r typecheck` and `pnpm build` pass. The final UI build also
passes.
- Two real native Codex runs completed in an isolated local instance.
Each run created the expected file, performed separate delayed tool
calls, reported a deliberate missing-file failure, recovered, and
finished the task.
- Browser verification confirmed that the open three-row activity group
remained expanded after the second run moved into saved history. Saved
error output remained accessible without red styling or an X.
- The complete UI suite passes: 581 files and 5,979 tests.
- `pnpm --filter @paperclipai/ui build-storybook` passes for the final
production fixtures.
- Greptile rates the latest commit 5/5, with zero new comments and no
unresolved review threads. The security scan passes.
- All repository test shards pass in CI. The duplicate local `pnpm
test:run` was stopped after CI completed the full test coverage. The CI
build failed twice because the worker ran out of disk space compiling
the Rust runner tests (`No space left on device`, error 28). The build
and its aggregate verify gate remain blocked by CI capacity. GitHub
squash auto-merge is enabled and will wait for required checks to pass.

## Risks

- This changes presentation and disclosure behavior. Users open a group
to see earlier activity.
- Animation uses stable item IDs. A provider that replaces IDs can
trigger another transition.
- No API, database, or runner protocol contracts change.

> Reviewed `ROADMAP.md`. This improves an existing task-chat surface.

## Model Used

OpenAI GPT-6 through Codex, with reasoning, tool use, code execution,
and browser testing. The runtime does not expose a more specific model
version or context-window size.

## 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-11 15:06:31 -05:00 committed by GitHub
parent 2904a3a6cc
commit ce09ea40b0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 1213 additions and 514 deletions

View File

@ -988,7 +988,7 @@ describe("TaskChatThread runtime transcript selection", () => {
);
const revealUsage = () => {
const summary = container.querySelector<HTMLButtonElement>(
'[data-testid="task-chat-phase-summary"]',
'[data-testid="task-chat-activity-phase-toggle"]',
);
expect(summary).not.toBeNull();
if (summary?.getAttribute("aria-expanded") !== "true") {

View File

@ -6,7 +6,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { ThemeProvider } from "@/context/ThemeContext";
import { MemoryRouter } from "@/lib/router";
import type { TaskChatProtocolItem, TaskChatProviderActivityItem } from "./task-chat-model";
import { TaskChatProtocolActivityRow } from "./TaskChatProtocolActivityRow";
import { TaskChatProtocolActivityRow, TaskChatProtocolActivityDetails } from "./TaskChatProtocolActivityRow";
describe("TaskChatProtocolActivityRow", () => {
let container: HTMLDivElement;
@ -119,6 +119,20 @@ describe("TaskChatProtocolActivityRow", () => {
expect(container.querySelector('[data-testid="task-chat-workspace-change-details"] pre')).toBeNull();
});
it.each(["javascript:alert(1)", "java\nscript:alert(1)", "data:text/html,unsafe", "file:///etc/passwd", "vbscript:unsafe"])("rejects unsafe resource URLs in rows and details: %s", (href) => {
const item: TaskChatProtocolItem = { id: "unsafe-resource", kind: "protocol", surface: "resource", resourceKind: "document", title: "Notes", subtitle: "Document", href };
render(item);
expect(container.querySelector("a")).toBeNull();
act(() => root.render(<TaskChatProtocolActivityDetails item={item} />));
expect(container.querySelector("a")).toBeNull();
expect(container.textContent).toContain("Notes");
});
it.each(["https://example.com/report", "http://example.com/report", "/documents/notes", "#notes"])("allows safe resource links in details: %s", (href) => {
act(() => root.render(<TaskChatProtocolActivityDetails item={{ id: "resource", kind: "protocol", surface: "resource", resourceKind: "document", title: "Notes", subtitle: "Document", href }} />));
expect(container.querySelector("a")?.getAttribute("href")).toBe(href);
});
it("renders durable resources as direct compact links", () => {
render({
id: "resource",

View File

@ -35,7 +35,8 @@ function StatusIcon({ status }: { status: "running" | "completed" | "failed" | "
return <Circle className="h-3.5 w-3.5 text-muted-foreground" aria-hidden />;
}
function stepStatusIcon(status: TaskChatProtocolStep["status"]) {
function stepStatusIcon(status: TaskChatProtocolStep["status"], neutral = false) {
if (neutral && (status === "blocked" || status === "failed")) return <Circle className="h-3 w-3 text-muted-foreground" aria-hidden />;
if (status === "in_progress") return <Loader2 className="h-3 w-3 animate-spin text-(--status-agent-running)" aria-hidden />;
if (status === "completed") return <Check className="h-3 w-3 text-(--status-task-icon-done)" aria-hidden />;
if (status === "blocked" || status === "failed") return <X className="h-3 w-3 text-destructive" aria-hidden />;
@ -125,7 +126,7 @@ function ResearchDetails({ item }: { item: TaskChatProviderActivityItem }) {
);
}
function ProviderDetails({ item }: { item: TaskChatProviderActivityItem }) {
function ProviderDetails({ item, neutral = false }: { item: TaskChatProviderActivityItem; neutral?: boolean }) {
if (item.family === "research") return <ResearchDetails item={item} />;
return (
<div className="flex min-w-0 flex-col gap-2">
@ -133,7 +134,7 @@ function ProviderDetails({ item }: { item: TaskChatProviderActivityItem }) {
<ol className="flex flex-col gap-1" aria-label="Plan steps">
{item.steps.map((step) => (
<li className="flex min-w-0 items-start gap-2" key={step.id}>
<span className="mt-0.5 shrink-0">{stepStatusIcon(step.status)}</span>
<span className="mt-0.5 shrink-0">{stepStatusIcon(step.status, neutral)}</span>
<span className={cn("min-w-0", step.status === "completed" && "text-muted-foreground line-through")}>{step.label}</span>
</li>
))}
@ -226,11 +227,11 @@ function WorkspaceFileDetails({ item }: { item: TaskChatWorkspaceFileItem }) {
);
}
function detailContent(item: TaskChatProtocolItem): ReactNode | null {
function detailContent(item: TaskChatProtocolItem, neutral = false): ReactNode | null {
switch (item.surface) {
case "provider_activity": {
const expandable = item.details.length > 0 || item.steps.length > 0 || item.links.length > 0 || item.children.length > 0 || Boolean(item.output) || Boolean(item.outputTruncated);
return expandable ? <ProviderDetails item={item} /> : null;
return expandable ? <ProviderDetails item={item} neutral={neutral} /> : null;
}
case "workspace_change": return <WorkspaceChangeDetails item={item} />;
case "workspace_file": return <WorkspaceFileDetails item={item} />;
@ -242,6 +243,32 @@ function detailContent(item: TaskChatProtocolItem): ReactNode | null {
}
}
function safeActivityHref(value: string | null | undefined): string | undefined {
if (!value) return undefined;
try {
// A fixed HTTPS base accepts relative app paths and fragments as well as
// external HTTP(S) resources, but never executable or local-file schemes.
const url = new URL(value, "https://paperclip.invalid");
return url.protocol === "https:" || url.protocol === "http:" ? value : undefined;
} catch {
return undefined;
}
}
export function hasTaskChatProtocolActivityDetails(item: TaskChatProtocolItem): boolean {
if (item.surface === "resource") return Boolean(safeActivityHref(item.href));
return detailContent(item) !== null || (item.surface === "provider_activity" && Boolean(item.summary?.trim()));
}
export function TaskChatProtocolActivityDetails({ item, neutral = false }: { item: TaskChatProtocolItem; neutral?: boolean }) {
const detail = detailContent(item, neutral);
if (item.surface === "resource") {
const href = safeActivityHref(item.href);
return href ? <a href={href} className="text-foreground underline">{item.title}</a> : <p>{item.title}</p>;
}
return <>{item.surface === "provider_activity" && item.summary ? <p className="whitespace-pre-wrap break-words">{item.summary}</p> : null}{detail}</>;
}
function itemStatus(item: TaskChatProtocolItem): "running" | "completed" | "failed" | "interrupted" | "informational" {
if (item.surface === "provider_activity") return item.status;
if (item.surface === "workspace_change") return item.complete ? "completed" : "running";
@ -292,9 +319,10 @@ export function TaskChatProtocolActivityRow({ item }: { item: TaskChatProtocolIt
</>
);
if (item.surface === "resource" && item.href) {
const resourceHref = item.surface === "resource" ? safeActivityHref(item.href) : undefined;
if (resourceHref) {
return (
<a className="group/activity -mx-1.5 flex min-h-6 w-full min-w-0 max-w-full items-center gap-2 overflow-hidden rounded-sm px-1.5 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground" href={item.href} data-testid="task-chat-protocol-activity-row">
<a className="group/activity -mx-1.5 flex min-h-6 w-full min-w-0 max-w-full items-center gap-2 overflow-hidden rounded-sm px-1.5 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground" href={resourceHref} data-testid="task-chat-protocol-activity-row">
{row}
</a>
);

View File

@ -0,0 +1,253 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TaskChatThreadView } from "./TaskChatThreadView";
import { ThemeProvider } from "@/context/ThemeContext";
import { MemoryRouter } from "@/lib/router";
import { TaskChatRunnerActivityGroup } from "./TaskChatRunnerActivityGroup";
import { TaskChatExpansionState } from "./expansion-state";
import type {
TaskChatActivityPhaseItem,
TaskChatToolItem,
} from "./task-chat-model";
const motion = vi.hoisted(() => ({ reduced: false }));
vi.mock("motion/react", async (original) => ({
...(await original<typeof import("motion/react")>()),
useReducedMotion: () => motion.reduced,
}));
const tool = (
id: string,
status: TaskChatToolItem["status"] = "in_progress",
): TaskChatToolItem => ({
id,
kind: "tool",
name: "exec_command",
target: `command-${id}`,
status,
detail: `output-${id}`,
});
describe("TaskChatRunnerActivityGroup", () => {
let container: HTMLDivElement;
let root: Root;
let memory: Map<string, boolean>;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
memory = new Map();
motion.reduced = false;
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
const render = (
items: TaskChatActivityPhaseItem["items"],
host = "live",
active = true,
) =>
act(() =>
root.render(
<TaskChatExpansionState.Provider value={memory}>
<TaskChatRunnerActivityGroup
key={host}
item={{
id: "commentary:phase",
kind: "activity_phase",
items,
active,
summary: "",
}}
/>
</TaskChatExpansionState.Provider>,
),
);
const toggle = () =>
container.querySelector<HTMLButtonElement>(
'[data-testid="task-chat-activity-phase-toggle"]',
)!;
const viewport = () =>
container.querySelector('[data-testid="task-chat-activity-viewport"]')!;
it("rolls to each new item once while status and token updates keep the current row mounted", () => {
render([tool("one")]);
const first = viewport().querySelector('[data-activity-row="one"]');
render([tool("one", "completed")]);
expect(viewport().querySelector('[data-activity-row="one"]')).toBe(first);
expect(container.querySelector(".runner-activity-roll-in")).toBeNull();
render([tool("one", "completed"), tool("two")]);
expect(viewport().querySelectorAll("[data-activity-row]")).toHaveLength(2);
const outgoing = container.querySelector(".runner-activity-roll-out")!;
expect(outgoing.getAttribute("aria-hidden")).toBe("true");
const current = container.querySelector(".runner-activity-roll-in");
render([
tool("one", "completed"),
{ ...tool("two"), detail: "more output" },
]);
expect(container.querySelector(".runner-activity-roll-in")).toBe(current);
act(() =>
outgoing.dispatchEvent(
new Event("webkitAnimationEnd", { bubbles: true }),
),
);
expect(viewport().querySelectorAll("[data-activity-row]")).toHaveLength(1);
expect(viewport().textContent).toContain("command-two");
});
it("updates provider reasoning text in place without restarting motion", () => {
render([
{
id: "thought",
kind: "thinking",
lines: ["First", "Check"],
streaming: true,
},
]);
const line = viewport().querySelector("[data-activity-row]");
render([
{
id: "thought",
kind: "thinking",
lines: ["First", "Checking the file"],
streaming: true,
},
]);
expect(viewport().querySelector("[data-activity-row]")).toBe(line);
expect(viewport().textContent).toContain("Checking the file");
expect(viewport().textContent).not.toContain("First");
expect(container.querySelector(".runner-activity-roll-in")).toBeNull();
});
it("preserves expanded history and item detail across appends and live-to-history remount", () => {
render([tool("one", "completed")]);
act(() => toggle().click());
const row = container.querySelector<HTMLButtonElement>("li button")!;
act(() => row.click());
expect(container.textContent).toContain("output-one");
render([tool("one", "completed"), tool("two")]);
expect(container.querySelectorAll("li")).toHaveLength(2);
expect(container.querySelector("li button")).toBe(row);
render(
[tool("one", "completed"), tool("two", "completed")],
"history",
false,
);
expect(toggle().getAttribute("aria-expanded")).toBe("true");
expect(container.querySelectorAll("li")).toHaveLength(2);
expect(container.textContent).toContain("output-one");
act(() => toggle().click());
expect(viewport().textContent).toContain("command-two");
});
it("keeps failures discoverable after later activity, with neutral detail and no X", () => {
render([tool("failed", "failed"), tool("next")]);
expect(toggle().textContent).toContain("1 failed");
act(() => toggle().click());
expect(container.querySelector("li")?.textContent).toContain("failed");
act(() => container.querySelector<HTMLButtonElement>("li button")!.click());
expect(container.textContent).toContain("output-failed");
expect(container.querySelector(".lucide-x, .text-destructive")).toBeNull();
});
it("uses the same group and expansion memory in the real persisted thread renderer", () => {
render([tool("one", "failed"), tool("two")]);
act(() => toggle().click());
act(() => container.querySelector<HTMLButtonElement>("li button")!.click());
act(() =>
root.render(
<TaskChatExpansionState.Provider value={memory}>
<MemoryRouter>
<ThemeProvider>
<TaskChatThreadView
scroll={false}
items={[
{
id: "saved",
kind: "message",
author: "agent",
text: "Finished",
attachedTurn: {
id: "turn",
kind: "turn",
settled: true,
standaloneHeader: true,
summary: { toolCount: 2, added: 0, removed: 0 },
items: [
{
id: "commentary:phase",
kind: "activity_phase",
active: false,
summary: "Ran commands",
items: [
tool("one", "failed"),
tool("two", "completed"),
],
},
],
},
},
]}
/>
</ThemeProvider>
</MemoryRouter>
</TaskChatExpansionState.Provider>,
),
);
expect(toggle().getAttribute("aria-expanded")).toBe("true");
expect(container.querySelectorAll("li")).toHaveLength(2);
expect(container.textContent).toContain("output-one");
expect(container.textContent).toContain("Finished");
expect(container.querySelector(".text-destructive,.lucide-x")).toBeNull();
act(() => toggle().click());
expect(viewport().textContent).toContain("command-two");
expect(toggle().textContent).toContain("1 failed");
});
it("does not offer empty disclosures for sparse activities", () => {
render([
{ id: "thinking-empty", kind: "thinking", lines: [], streaming: true },
{ id: "tool-empty", kind: "tool", name: "read", status: "completed" },
{
id: "provider-empty",
kind: "protocol",
surface: "provider_activity",
family: "wait",
status: "running",
title: "Wait",
eventType: "wait.started",
details: [],
steps: [],
links: [],
children: [],
},
]);
act(() => toggle().click());
expect(container.querySelectorAll("li")).toHaveLength(3);
expect(
container.querySelectorAll(
"li button, li [aria-expanded], li [aria-controls]",
),
).toHaveLength(0);
expect(
container.querySelector(
'[data-testid="task-chat-runner-activity-detail"]',
),
).toBeNull();
});
it("replaces immediately with reduced motion", () => {
motion.reduced = true;
render([tool("one")]);
render([tool("one", "completed"), tool("two")]);
expect(viewport().querySelectorAll("[data-activity-row]")).toHaveLength(1);
expect(
container.querySelector(
".runner-activity-roll-in, .runner-activity-roll-out",
),
).toBeNull();
});
});

View File

@ -0,0 +1,405 @@
import { useId, useState } from "react";
import {
Brain,
ChevronDown,
ChevronRight,
CirclePause,
Gauge,
} from "lucide-react";
import { useReducedMotion } from "motion/react";
import { MarkdownBody } from "@/components/MarkdownBody";
import { cn } from "@/lib/utils";
import { useTaskChatExpansion } from "./expansion-state";
import type { TaskChatActivityPhaseItem } from "./task-chat-model";
import {
protocolActivityIsRunning,
protocolActivityLabel,
protocolActivityPresentation,
} from "./task-chat-activity-presentation";
import {
TaskChatProtocolActivityDetails,
hasTaskChatProtocolActivityDetails,
} from "./TaskChatProtocolActivityRow";
import { TaskChatUsageReadout } from "./TaskChatUsageReadout";
import { toolActivityPresentation } from "./tool-taxonomy";
type Activity = TaskChatActivityPhaseItem["items"][number];
function presentation(item: Activity, active: boolean) {
if (item.kind === "tool") {
const tool = toolActivityPresentation({
name: item.rawName ?? item.name,
target: item.target,
});
const running =
active && (item.status === "pending" || item.status === "in_progress");
return {
icon: tool.icon,
label:
item.status === "failed"
? tool.failedLabel
: item.status === "interrupted"
? tool.interruptedLabel
: running
? tool.runningLabel
: tool.completedLabel,
target: item.target,
mono: true,
running,
};
}
if (item.kind === "thinking") {
const running = active && Boolean(item.streaming);
return {
icon: Brain,
label: running ? "Thinking" : "Thought",
target: item.lines
.filter((line) => line.trim())
.at(-1)
?.trim(),
mono: false,
running,
};
}
if (item.kind === "protocol") {
const protocol = protocolActivityPresentation(item);
if (!protocol) return null;
return {
icon: protocol.icon,
label: protocolActivityLabel(item, protocol),
target: protocol.detail,
mono: true,
running: active && protocolActivityIsRunning(item),
};
}
if (item.kind === "marker")
return {
icon: CirclePause,
label: item.label,
target: item.detail,
mono: false,
running: false,
};
const { used, size, inputTokens, outputTokens, costUsd } = item.usage;
const usage = [
size > 0
? `${used.toLocaleString()}/${size.toLocaleString()} ctx`
: undefined,
inputTokens != null || outputTokens != null
? `${(inputTokens ?? 0).toLocaleString()}${(outputTokens ?? 0).toLocaleString()}`
: undefined,
costUsd != null ? `$${costUsd.toFixed(4)}` : undefined,
]
.filter(Boolean)
.join(" · ");
return {
icon: Gauge,
label: item.label ?? "Token usage",
target: usage || item.detail,
mono: false,
running: false,
};
}
function isFailure(item: Activity) {
return (
(item.kind === "tool" && item.status === "failed") ||
(item.kind === "protocol" &&
item.surface === "provider_activity" &&
item.status === "failed") ||
(item.kind === "marker" && item.tone === "error")
);
}
function ActivityContent({
item,
active,
}: {
item: Activity;
active: boolean;
}) {
const row = presentation(item, active);
if (!row) return null;
const Icon = row.icon;
return (
<span
className="flex min-w-0 flex-1 items-center gap-2"
data-activity-row={item.id}
data-activity-family={
item.kind === "protocol"
? item.surface === "provider_activity"
? item.family
: item.surface
: undefined
}
>
<span
className="flex size-5 shrink-0 items-center justify-center"
data-activity-icon
>
<Icon className="size-3.5" aria-hidden="true" />
</span>
<span className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden whitespace-nowrap">
<span
className={cn(
"max-w-full shrink-0 truncate text-xs",
row.running && "text-foreground",
)}
>
{row.label}
</span>
{row.target ? (
<span
title={row.target}
className={cn(
"truncate text-xs text-muted-foreground",
row.mono && "font-mono",
)}
>
{row.target}
</span>
) : null}
</span>
</span>
);
}
/** Only a new logical activity moves; token and status updates stay mounted. */
function RollingActivity({
item,
active,
}: {
item: Activity;
active: boolean;
}) {
const reducedMotion = useReducedMotion();
const [frame, setFrame] = useState({
current: item,
previous: null as Activity | null,
});
if (frame.current.id !== item.id)
setFrame({ current: item, previous: reducedMotion ? null : frame.current });
else if (frame.current !== item) setFrame({ ...frame, current: item });
return (
<span
className="relative flex h-8 min-w-0 flex-1 items-center overflow-hidden"
aria-live="polite"
aria-atomic="true"
data-testid="task-chat-activity-viewport"
>
{frame.previous && !reducedMotion ? (
<span
key={`exit-${item.id}`}
className="runner-activity-roll-out absolute inset-0 flex items-center"
aria-hidden="true"
onAnimationEnd={() =>
setFrame((current) =>
current.current.id === item.id
? { ...current, previous: null }
: current,
)
}
>
<ActivityContent item={frame.previous} active={false} />
</span>
) : null}
<span
key={item.id}
className={cn(
"relative flex w-full min-w-0 items-center",
frame.previous && !reducedMotion && "runner-activity-roll-in",
)}
>
<ActivityContent item={item} active={active} />
</span>
</span>
);
}
function ActivityDetails({ item }: { item: Activity }) {
if (item.kind === "thinking")
return <MarkdownBody softBreaks>{item.lines.join("\n")}</MarkdownBody>;
if (item.kind === "usage") return <TaskChatUsageReadout item={item} />;
if (item.kind === "protocol")
return <TaskChatProtocolActivityDetails item={item} neutral />;
if (item.kind === "marker")
return (
<p className="whitespace-pre-wrap break-words">
{item.detail ?? item.label}
</p>
);
return (
<>
{item.target ? (
<p className="break-all font-mono">{item.target}</p>
) : null}
{item.detail ? (
<pre className="whitespace-pre-wrap break-words font-mono">
{item.detail}
</pre>
) : null}
{item.decision ? <p>Permission {item.decision}</p> : null}
{item.diff ? (
<p className="break-all font-mono">
{item.diff.path} · +{item.diff.added} {item.diff.removed}
</p>
) : null}
</>
);
}
function hasActivityDetails(item: Activity): boolean {
if (item.kind === "protocol") return hasTaskChatProtocolActivityDetails(item);
if (item.kind === "thinking") return item.lines.some((line) => line.trim());
if (item.kind === "tool")
return Boolean(
item.target?.trim() || item.detail?.trim() || item.diff || item.decision,
);
if (item.kind === "marker") return Boolean(item.detail?.trim());
return Boolean(
item.detail ||
item.usage.size > 0 ||
item.usage.inputTokens != null ||
item.usage.outputTokens != null ||
item.usage.costUsd != null,
);
}
function ExpandedActivity({
item,
active,
}: {
item: Activity;
active: boolean;
}) {
const [open, setOpen] = useTaskChatExpansion(
`runner-detail:${item.id}`,
false,
);
const detailId = useId();
const expandable = hasActivityDetails(item);
const content = <ActivityContent item={item} active={active} />;
return (
<li className="min-w-0" data-activity-item-id={item.id}>
{expandable ? (
<button
type="button"
className="flex h-8 w-full min-w-0 items-center gap-2 rounded-sm text-left text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => setOpen(!open)}
aria-expanded={open}
aria-controls={open ? detailId : undefined}
>
{content}
<ChevronRight
className={cn("size-3.5 shrink-0", open && "rotate-90")}
aria-hidden="true"
/>
</button>
) : (
<div className="flex h-8 w-full min-w-0 items-center gap-2 text-muted-foreground">
{content}
</div>
)}
{expandable && open ? (
<div
id={detailId}
className="flex min-w-0 flex-col gap-2 overflow-hidden rounded-md bg-muted/40 p-3 text-xs leading-relaxed text-muted-foreground"
data-testid="task-chat-runner-activity-detail"
>
<ActivityDetails item={item} />
</div>
) : null}
</li>
);
}
/** One rolling activity between commentary messages, with persistent optional history. */
export function TaskChatRunnerActivityGroup({
item,
defaultExpanded = false,
}: {
item: TaskChatActivityPhaseItem;
defaultExpanded?: boolean;
}) {
const [expanded, setExpanded] = useTaskChatExpansion(
item.id,
defaultExpanded,
);
const historyId = useId();
const activities = item.items.filter(
(activity) => presentation(activity, false) !== null,
);
const latest = activities.at(-1);
const failures = activities.filter(isFailure).length;
const countLabel = `${activities.length} ${activities.length === 1 ? "activity" : "activities"}`;
return (
<section
className="flex min-w-0 flex-col gap-2"
data-testid="task-chat-activity-phase"
data-activity-group={item.id}
data-expanded={expanded}
>
{item.interstitial ? (
<div
className="min-w-0 text-sm text-foreground/90"
data-testid="task-chat-phase-interstitial"
>
<MarkdownBody softBreaks linkIssueReferences>
{item.interstitial.text}
</MarkdownBody>
</div>
) : null}
{latest ? (
<div className="min-w-0">
<button
type="button"
className="flex min-h-8 w-full min-w-0 items-center gap-2 rounded-sm text-left text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
data-testid="task-chat-activity-phase-toggle"
onClick={() => setExpanded(!expanded)}
aria-expanded={expanded}
aria-controls={expanded ? historyId : undefined}
aria-label={`${expanded ? "Collapse" : "Expand"} ${countLabel}`}
>
{expanded ? (
<span className="flex min-h-8 flex-1 items-center gap-2 text-xs">
<span className="flex size-5 shrink-0 items-center justify-center">
<ChevronDown className="size-3.5" aria-hidden="true" />
</span>
<span>{countLabel}</span>
</span>
) : (
<RollingActivity item={latest} active={item.active} />
)}
{failures > 0 ? (
<span className="shrink-0 text-xs text-muted-foreground">
{failures} failed
</span>
) : null}
<span className="flex shrink-0 items-center gap-1 text-xs">
{expanded ? "Collapse" : activities.length}
{!expanded ? (
<ChevronRight className="size-3.5" aria-hidden="true" />
) : null}
</span>
</button>
{expanded ? (
<ol
id={historyId}
className="flex min-w-0 flex-col gap-1"
aria-label="Activity history"
data-testid="task-chat-runner-activity-list"
>
{activities.map((activity, index) => (
<ExpandedActivity
key={activity.id}
item={activity}
active={item.active && index === activities.length - 1}
/>
))}
</ol>
) : null}
</div>
) : null}
</section>
);
}

View File

@ -242,39 +242,14 @@ describe("TaskChatRunnerTurn", () => {
detail: "STREAM-1\n",
},
]);
expect(
container.querySelector('[data-testid="task-chat-phase-interstitial"]')
?.textContent,
).toContain("Running the exact command now.");
expect(
container.querySelector('[data-testid="task-chat-phase-summary"]')
?.textContent,
).toContain("Ran a command");
expect(
container.querySelector('[data-testid="task-chat-current-activity"]')
?.textContent,
).toContain("Running a command");
expect(container.textContent).toContain("STREAM-$i");
const identity = container.querySelector(
'[data-testid="task-chat-agent-identity"]',
);
const activity = container.querySelector(
'[data-testid="task-chat-current-activity"]',
);
const timeline = container.querySelector(
'[data-testid="task-chat-turn-timeline"]',
);
expect(identity?.compareDocumentPosition(timeline!)).toBe(
Node.DOCUMENT_POSITION_FOLLOWING,
);
expect(timeline?.compareDocumentPosition(activity!)).toBe(
Node.DOCUMENT_POSITION_FOLLOWING,
);
expect(
container
.querySelector('[data-testid="task-chat-runner-identity-row"]')
?.classList.contains("pt-2"),
).toBe(true);
const commentary = container.querySelector('[data-testid="task-chat-phase-interstitial"]');
const activity = container.querySelector('[data-testid="task-chat-activity-viewport"]');
expect(commentary?.textContent).toContain("Running the exact command now.");
expect(activity?.textContent).toContain("Running a command");
expect(activity?.textContent).toContain("STREAM-$i");
expect(container.querySelector('[data-testid="task-chat-current-activity"]')).toBeNull();
expect(commentary?.compareDocumentPosition(activity!)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
});
it("keeps the timer at the top and moves resumed Thinking below completed activity", () => {
@ -303,23 +278,13 @@ describe("TaskChatRunnerTurn", () => {
},
]);
const header = container.querySelector(
'[data-testid="task-chat-turn-status-header"]',
);
const timeline = container.querySelector(
'[data-testid="task-chat-turn-timeline"]',
);
const activity = container.querySelector(
'[data-testid="task-chat-current-activity"]',
);
const header = container.querySelector('[data-testid="task-chat-turn-status-header"]');
const activity = container.querySelector('[data-testid="task-chat-activity-viewport"]');
expect(header?.textContent).toContain("Working for");
expect(activity?.textContent).toBe("Thinking");
expect(header?.compareDocumentPosition(timeline!)).toBe(
Node.DOCUMENT_POSITION_FOLLOWING,
);
expect(timeline?.compareDocumentPosition(activity!)).toBe(
Node.DOCUMENT_POSITION_FOLLOWING,
);
expect(header?.compareDocumentPosition(activity!)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
expect(container.querySelector('[data-testid="task-chat-current-activity"]')).toBeNull();
});
it("keeps earlier commentary mounted when later commentary streams", () => {
@ -384,19 +349,19 @@ describe("TaskChatRunnerTurn", () => {
"Ive found the rendering seam.",
);
expect(
container.querySelector('[data-testid="task-chat-thinking"]'),
container.querySelector('[data-testid="task-chat-runner-activity-list"]'),
).toBeNull();
act(() =>
container
.querySelector<HTMLButtonElement>(
'[data-testid="task-chat-phase-summary"]',
'[data-testid="task-chat-activity-phase-toggle"]',
)
?.click(),
);
expect(
container.querySelector('[data-testid="task-chat-thinking"]')
container.querySelector('[data-testid="task-chat-runner-activity-list"]')
?.textContent,
).toContain("Reasoning");
).toContain("Thought");
});
it("keeps the latest provider-authored reasoning line visible while activity is folded", () => {
@ -412,14 +377,23 @@ describe("TaskChatRunnerTurn", () => {
]);
const ticker = container.querySelector(
'[data-testid="task-chat-reasoning-ticker"]',
'[data-testid="task-chat-activity-viewport"]',
);
expect(ticker?.textContent).toContain("Checking the steering path.");
expect(
container.querySelector('[data-testid="task-chat-thinking"]'),
container.querySelector('[data-testid="task-chat-runner-activity-list"]'),
).toBeNull();
});
it("keeps a visible fallback when completion tools are filtered before the final reply", () => {
render([{ id: "finish", kind: "tool", name: "paperclip_finish", status: "in_progress" }]);
expect(container.querySelector('[data-testid="task-chat-current-activity"]')?.textContent).toContain("Thinking");
expect(container.querySelector('[data-testid="task-chat-activity-phase"]')).toBeNull();
render([{ id: "finish-provider", kind: "protocol", surface: "provider_activity", family: "tool_execution", eventType: "tool.execution.started", status: "running", title: "Finish", details: [{ label: "Name", value: "paperclip_finish" }], steps: [], links: [], children: [] }]);
expect(container.querySelector('[data-testid="task-chat-current-activity"]')?.textContent).toContain("Thinking");
expect(container.querySelector('[data-testid="task-chat-activity-phase"]')).toBeNull();
});
it("surfaces native activity transport failure while retrying", () => {
act(() =>
root.render(
@ -482,20 +456,20 @@ describe("TaskChatRunnerTurn", () => {
);
expect(rows).toHaveLength(2);
expect(rows[0]?.textContent).toContain("First phase.");
expect(rows[0]?.textContent).toContain("Read a file");
expect(rows[0]?.textContent).toContain("Read file");
expect(rows[1]?.textContent).toContain("Second phase.");
expect(rows[1]?.textContent).toContain("Ran a command");
act(() =>
rows[0]
?.querySelector<HTMLButtonElement>(
'[data-testid="task-chat-phase-summary"]',
'[data-testid="task-chat-activity-phase-toggle"]',
)
?.click(),
);
expect(
rows[0]
?.querySelector('[data-testid="task-chat-tool-icon"]')
?.parentElement?.classList.contains("w-5"),
?.querySelector('[data-activity-icon] svg')
?.parentElement?.classList.contains("size-5"),
).toBe(true);
});
@ -524,65 +498,17 @@ describe("TaskChatRunnerTurn", () => {
act(() =>
container
.querySelector<HTMLButtonElement>(
'[data-testid="task-chat-phase-summary"]',
'[data-testid="task-chat-activity-phase-toggle"]',
)
?.click(),
);
const history = container.querySelector(
'[data-testid="task-chat-runner-activity-list"]',
);
const rail = container.querySelector(
'[data-testid="task-chat-runner-activity-rail"]',
)?.parentElement;
expect(rail?.classList.contains("pl-6")).toBe(true);
expect(rail?.classList.contains("ml-4")).toBe(true);
expect(history?.textContent).toContain("Inspect the current card.");
expect(history?.textContent).toContain(
"Keep the canonical revision atomic.",
);
const thinkingRows = history?.querySelectorAll(
'[data-testid="task-chat-thinking"]',
);
expect(thinkingRows).toHaveLength(2);
expect(thinkingRows?.[0]?.textContent).not.toContain("Reasoning");
expect(thinkingRows?.[0]?.querySelector(".shimmer-text")).toBeNull();
expect(
thinkingRows?.[0]
?.querySelector('[data-testid="task-chat-thinking-icon"]')
?.classList.contains("text-(--status-agent-running)"),
).toBe(false);
expect(thinkingRows?.[1]?.textContent).toContain("Reasoning detail…");
expect(thinkingRows?.[1]?.querySelector(".shimmer-text")).not.toBeNull();
expect(
thinkingRows?.[1]
?.querySelector('[data-testid="task-chat-thinking-icon"]')
?.classList.contains("text-(--status-agent-running)"),
).toBe(true);
expect(thinkingRows?.[0]?.classList.contains("text-xs")).toBe(true);
expect(thinkingRows?.[0]?.classList.contains("font-normal")).toBe(true);
expect(
thinkingRows?.[0]
?.querySelector('[data-testid="task-chat-thinking-icon"]')
?.parentElement?.classList.contains("w-5"),
).toBe(true);
expect(
thinkingRows?.[0]
?.querySelector('[data-testid="task-chat-thinking-icon"]')
?.parentElement?.classList.contains("justify-center"),
).toBe(true);
expect(
thinkingRows?.[0]?.querySelector(
'[data-testid="task-chat-thinking-text"]',
)?.textContent,
).toContain("Inspect the current card.");
expect(
thinkingRows?.[0]?.querySelector(".task-chat-reasoning-markdown"),
).toBeNull();
expect(
thinkingRows?.[1]
?.querySelector("button")
?.classList.contains("font-normal"),
).toBe(true);
const history = container.querySelector('[data-testid="task-chat-runner-activity-list"]');
expect(container.querySelector('[data-testid="task-chat-runner-activity-rail"]')).toBeNull();
expect(history?.querySelectorAll("li")).toHaveLength(2);
expect(history?.textContent).toContain("ThoughtInspect the current card.");
expect(history?.textContent).toContain("ThinkingKeep the canonical revision atomic.");
expect(container.querySelector('[data-testid="task-chat-runner-activity-detail"]')).toBeNull();
});
it("renders only the current reasoning block as active", () => {
@ -613,29 +539,14 @@ describe("TaskChatRunnerTurn", () => {
act(() =>
container
.querySelector<HTMLButtonElement>(
'[data-testid="task-chat-phase-summary"]',
'[data-testid="task-chat-activity-phase-toggle"]',
)
?.click(),
);
const oldReasoning = container.querySelector(
'[data-activity-item-id="old-reasoning"]',
);
expect(oldReasoning?.textContent).toBe("Inspect the current card.");
expect(oldReasoning?.querySelector(".shimmer-text")).toBeNull();
expect(
oldReasoning?.querySelector('[data-testid="task-chat-thinking-text"]'),
).not.toBeNull();
expect(container.querySelector('[data-activity-item-id="old-reasoning"]')?.textContent).toContain("ThoughtInspect the current card.");
expect(container.querySelector('[data-activity-item-id="current-reasoning"]')?.textContent).toContain("ThinkingVerify the updated state.");
const currentReasoning = container.querySelector(
'[data-activity-item-id="current-reasoning"]',
);
expect(currentReasoning?.textContent).toContain("Reasoning…");
expect(
currentReasoning
?.querySelector('[data-testid="task-chat-thinking-icon"]')
?.classList.contains("text-(--status-agent-running)"),
).toBe(true);
});
it("does not let a textless reasoning lifecycle remove sticky commentary", () => {
@ -663,11 +574,11 @@ describe("TaskChatRunnerTurn", () => {
?.textContent,
).toContain("Old commentary");
expect(
container.querySelector('[data-testid="task-chat-phase-summary"]'),
container.querySelector('[data-testid="task-chat-current-activity"]'),
).toBeNull();
expect(
container.querySelector(
'[data-testid="task-chat-current-activity-label"]',
'[data-testid="task-chat-activity-viewport"]',
)?.textContent,
).toBe("Thinking");
});
@ -698,9 +609,9 @@ describe("TaskChatRunnerTurn", () => {
container.querySelector('[data-testid="task-chat-live-plan-preview"]'),
).toBeNull();
const disclosure = container.querySelector<HTMLButtonElement>(
'[data-testid="task-chat-phase-summary"]',
'[data-testid="task-chat-activity-phase-toggle"]',
);
expect(disclosure?.getAttribute("aria-label")).toContain("Expand activity");
expect(disclosure?.getAttribute("aria-label")).toContain("Expand 1 activity");
act(() => disclosure?.click());
const history = container.querySelector(
'[data-testid="task-chat-runner-activity-list"]',
@ -711,8 +622,8 @@ describe("TaskChatRunnerTurn", () => {
).not.toBeNull();
expect(
history
?.querySelector('[data-testid="task-chat-protocol-activity-icon"]')
?.parentElement?.classList.contains("w-5"),
?.querySelector('[data-activity-icon] svg')
?.parentElement?.classList.contains("size-5"),
).toBe(true);
});
@ -737,13 +648,13 @@ describe("TaskChatRunnerTurn", () => {
]);
const activity = container.querySelector(
'[data-testid="task-chat-current-activity"]',
'[data-testid="task-chat-activity-viewport"]',
);
expect(activity?.textContent).toContain("Searching the web");
expect(activity?.textContent).toContain(
"site:openai.com model guide GPT-5.4",
);
expect(activity?.getAttribute("data-activity-family")).toBe("research");
expect(activity?.querySelector("[data-activity-family]")?.getAttribute("data-activity-family")).toBe("research");
});
it("has a purpose-built current-activity presentation for every provider family", () => {
@ -877,9 +788,9 @@ describe("TaskChatRunnerTurn", () => {
},
]);
const activity = container.querySelector(
'[data-testid="task-chat-current-activity"]',
'[data-testid="task-chat-activity-viewport"]',
);
expect(activity?.getAttribute("data-activity-family"), entry.family).toBe(
expect(activity?.querySelector("[data-activity-family]")?.getAttribute("data-activity-family"), entry.family).toBe(
entry.family,
);
expect(activity?.textContent, entry.family).toContain(entry.expected);
@ -904,12 +815,12 @@ describe("TaskChatRunnerTurn", () => {
});
render([provider("failed")]);
expect(
container.querySelector('[data-testid="task-chat-current-activity"]')
container.querySelector('[data-testid="task-chat-activity-viewport"]')
?.textContent,
).toContain("Web search failed");
render([provider("interrupted")]);
expect(
container.querySelector('[data-testid="task-chat-current-activity"]')
container.querySelector('[data-testid="task-chat-activity-viewport"]')
?.textContent,
).toContain("Web search stopped");
});
@ -929,14 +840,6 @@ describe("TaskChatRunnerTurn", () => {
patchArtifactRef: null,
},
]);
expect(
container.querySelector('[data-testid="task-chat-current-activity"]')
?.textContent,
).toContain("Editing files");
expect(
container.querySelector('[data-testid="task-chat-current-activity"]')
?.textContent,
).toContain("2 files");
const card = container.querySelector(
'[data-testid="task-chat-workspace-change"]',
);
@ -962,16 +865,15 @@ describe("TaskChatRunnerTurn", () => {
},
]);
const activity = container.querySelector(
'[data-testid="task-chat-current-activity"]',
'[data-testid="task-chat-activity-viewport"]',
);
expect(activity?.textContent).toContain("Referenced a file");
expect(activity?.textContent).toContain("ui/src/App.tsx:42");
expect(activity?.classList.contains("px-1")).toBe(true);
const icon = activity?.querySelector(
'[data-testid="task-chat-current-activity-icon"]',
'[data-activity-icon] svg',
);
expect(icon).not.toBeNull();
expect(icon?.parentElement?.classList.contains("w-5")).toBe(true);
expect(icon?.parentElement?.classList.contains("size-5")).toBe(true);
expect(icon?.parentElement?.classList.contains("justify-center")).toBe(
true,
);
@ -1203,9 +1105,9 @@ describe("TaskChatRunnerTurn", () => {
?.textContent,
).toContain("Worked for");
expect(
container.querySelector('[data-testid="task-chat-phase-summary"]')
container.querySelector('[data-testid="task-chat-activity-phase-toggle"]')
?.textContent,
).toContain("Reasoning");
).toContain("Thought");
});
it("keeps final text mounted through a transient replay gap", () => {
@ -1313,14 +1215,14 @@ describe("TaskChatRunnerTurn", () => {
act(() =>
container
.querySelector<HTMLButtonElement>(
'[data-testid="task-chat-phase-summary"]',
'[data-testid="task-chat-activity-phase-toggle"]',
)
?.click(),
);
expect(
container
.querySelector('[data-testid="task-chat-marker-icon"]')
?.parentElement?.classList.contains("w-5"),
.querySelector('[data-activity-icon] svg')
?.parentElement?.classList.contains("size-5"),
).toBe(true);
});
@ -1504,9 +1406,9 @@ describe("TaskChatRunnerTurn", () => {
"commentary-4:phase",
],
);
expect(rows[0]?.textContent).toContain("Read 2 files");
expect(rows[0]?.textContent).toContain("Read file");
expect(rows[2]?.textContent).toContain("Questions answered");
expect(rows[3]?.textContent).toContain("Used a tool");
expect(rows[3]?.textContent).toContain("Searched the web");
expect(rows[5]?.textContent).toContain("Ran a command");
const worked = container.querySelector(
'[data-testid="task-chat-turn-status-header"]',
@ -1562,7 +1464,7 @@ describe("TaskChatRunnerTurn", () => {
]);
const disclosure = container.querySelector<HTMLButtonElement>(
'[data-testid="task-chat-phase-summary"]',
'[data-testid="task-chat-activity-phase-toggle"]',
);
expect(disclosure?.getAttribute("aria-expanded")).toBe("false");
expect(
@ -1595,7 +1497,7 @@ describe("TaskChatRunnerTurn", () => {
];
render(items);
const disclosure = container.querySelector<HTMLButtonElement>(
'[data-testid="task-chat-phase-summary"]',
'[data-testid="task-chat-activity-phase-toggle"]',
);
act(() => disclosure?.click());
expect(disclosure?.getAttribute("aria-expanded")).toBe("true");
@ -1606,7 +1508,7 @@ describe("TaskChatRunnerTurn", () => {
);
const settled = container.querySelector<HTMLButtonElement>(
'[data-testid="task-chat-phase-summary"]',
'[data-testid="task-chat-activity-phase-toggle"]',
);
expect(settled?.getAttribute("aria-expanded")).toBe("true");
expect(
@ -1630,13 +1532,13 @@ describe("TaskChatRunnerTurn", () => {
act(() =>
container
.querySelector<HTMLButtonElement>(
'[data-testid="task-chat-phase-summary"]',
'[data-testid="task-chat-activity-phase-toggle"]',
)
?.click(),
);
expect(
container
.querySelector('[data-testid="task-chat-phase-summary"]')
.querySelector('[data-testid="task-chat-activity-phase-toggle"]')
?.getAttribute("aria-expanded"),
).toBe("true");
@ -1644,7 +1546,7 @@ describe("TaskChatRunnerTurn", () => {
expect(
container
.querySelector('[data-testid="task-chat-phase-summary"]')
.querySelector('[data-testid="task-chat-activity-phase-toggle"]')
?.getAttribute("aria-expanded"),
).toBe("false");
});

View File

@ -1,35 +1,19 @@
import { useRef, useState, type ComponentType, type SVGProps } from "react";
import { useRef } from "react";
import type { ExecutionProjection } from "@paperclipai/shared";
import { Brain, OctagonX } from "lucide-react";
import { MarkdownBody } from "@/components/MarkdownBody";
import { useSecondTick } from "@/hooks/useSecondTick";
import { cn } from "@/lib/utils";
import type {
TaskChatItem,
TaskChatMessageItem,
TaskChatMarkerItem,
TaskChatProtocolItem,
TaskChatProviderActivityItem,
TaskChatRuntimeRequestDecision,
TaskChatRuntimeRequestItem,
TaskChatThinkingItem,
TaskChatToolItem,
} from "./task-chat-model";
import { TaskChatAgentIdentity, TaskChatBubble } from "./TaskChatBubble";
import { TaskChatBubbleActions } from "./TaskChatBubbleActions";
import { formatTaskChatTimestamp } from "./task-chat-adapter";
import { TaskChatActivityPhase } from "./TaskChatActivityPhase";
import { TaskChatProtocolActivityRow } from "./TaskChatProtocolActivityRow";
import { TaskChatRunnerActivityGroup } from "./TaskChatRunnerActivityGroup";
import { TaskChatProtocolCard } from "./TaskChatProtocolCard";
import { TaskChatPlanPreviewCard } from "./TaskChatPlanPreviewCard";
import { TaskChatThinking } from "./TaskChatThinking";
import { TaskChatToolCard } from "./TaskChatToolCard";
import { TaskChatUsageReadout } from "./TaskChatUsageReadout";
import {
protocolActivityIsRunning,
protocolActivityLabel,
protocolActivityPresentation,
} from "./task-chat-activity-presentation";
import {
buildTurnTimelineRows,
isTerminalRunStatus,
@ -37,26 +21,6 @@ import {
paperclipRunnerFinalResponse,
paperclipRunnerTimelineItems,
} from "./transcript-adapter";
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 isHeadlineProtocolActivity(
item: TaskChatItem,
): item is TaskChatProtocolItem {
return (
item.kind === "protocol" && protocolActivityPresentation(item) !== null
);
}
function currentActivityStatusItems(
items: readonly TaskChatItem[],
@ -79,131 +43,6 @@ function currentActivityStatusItems(
return items.slice(boundaryIndex + 1);
}
type FoldedNarration =
| { kind: "commentary"; item: TaskChatMessageItem; order: number }
| {
kind: "reasoning";
item: TaskChatThinkingItem;
line: string | null;
lineIndex: number;
order: number;
};
function latestFoldedNarration(
items: readonly TaskChatItem[],
): FoldedNarration | null {
let latest: FoldedNarration | null = null;
for (const [index, item] of items.entries()) {
const order =
item.kind === "message" || item.kind === "thinking"
? (item.transcriptIndex ?? index)
: -1;
if (item.kind === "message" && item.interstitial && item.text.trim()) {
if (!latest || order >= latest.order)
latest = { kind: "commentary", item, order };
continue;
}
if (item.kind !== "thinking") continue;
let lineIndex = -1;
for (
let candidate = item.lines.length - 1;
candidate >= 0;
candidate -= 1
) {
if (item.lines[candidate]?.trim()) {
lineIndex = candidate;
break;
}
}
if (!latest || order >= latest.order) {
latest = {
kind: "reasoning",
item,
line: lineIndex < 0 ? null : item.lines[lineIndex]!.trim(),
lineIndex,
order,
};
}
}
return latest;
}
function FoldedReasoningTicker({
logicalKey,
text,
}: {
logicalKey: string;
text: string;
}) {
const [ticker, setTicker] = useState({
logicalKey,
motionKey: 0,
current: text,
exiting: null as string | null,
});
if (ticker.logicalKey !== logicalKey) {
setTicker({
logicalKey,
motionKey: ticker.motionKey + 1,
current: text,
exiting: ticker.current,
});
} else if (ticker.current !== text) {
// Token fragments update the mounted line. Only a new logical line moves
// the ticker, so streaming text does not restart the animation per token.
setTicker({ ...ticker, current: text });
}
return (
<div
className="flex min-w-0 gap-2 px-1 py-1.5"
data-testid="task-chat-reasoning-ticker"
>
<div className="flex shrink-0 items-center">
<Brain className="h-3.5 w-3.5 text-muted-foreground/50" aria-hidden />
</div>
<div className="relative h-5 min-w-0 flex-1 overflow-hidden">
{ticker.exiting !== null ? (
<span
key={`out-${ticker.motionKey}`}
className="cot-line-exit absolute inset-x-0 truncate text-(length:--text-compact) italic leading-5 text-muted-foreground"
onAnimationEnd={() =>
setTicker((current) => ({ ...current, exiting: null }))
}
>
{ticker.exiting}
</span>
) : null}
<span
key={`in-${ticker.motionKey}`}
className={cn(
"absolute inset-x-0 truncate text-(length:--text-compact) italic leading-5 text-muted-foreground",
ticker.motionKey > 0 && "cot-line-enter",
)}
aria-live="polite"
aria-atomic="true"
>
{ticker.current}
</span>
</div>
</div>
);
}
function FoldedLiveNarration({
narration,
}: {
narration: Extract<FoldedNarration, { kind: "reasoning" }>;
}) {
if (!narration.line) return null;
return (
<FoldedReasoningTicker
logicalKey={`${narration.item.id}:${narration.lineIndex}`}
text={narration.line}
/>
);
}
function formatCompactDuration(ms: number | null): string | null {
if (ms == null || !Number.isFinite(ms)) return null;
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
@ -222,74 +61,6 @@ function terminalStatusFailed(status: string): boolean {
);
}
function RunnerActivityTimeline({ items }: { items: readonly TaskChatItem[] }) {
if (items.length === 0) return null;
return (
<div className="relative ml-4 min-w-0 pl-6">
<span
className="absolute inset-y-1 left-0 w-px bg-border/70"
aria-hidden
data-testid="task-chat-runner-activity-rail"
/>
<ol
className="flex min-w-0 flex-col gap-2 py-1"
aria-label="Run activity"
data-testid="task-chat-runner-activity-list"
>
{items.map((item, index) => (
<li className="min-w-0" key={item.id} data-activity-item-id={item.id}>
{item.kind === "message" ? (
<div
className="min-w-0 px-1 text-sm text-foreground/90"
data-testid="task-chat-activity-commentary"
>
<MarkdownBody softBreaks linkIssueReferences>
{item.text}
</MarkdownBody>
</div>
) : item.kind === "thinking" ? (
<TaskChatThinking
item={item}
active={Boolean(item.streaming) && index === items.length - 1}
defaultOpen={false}
rowClassName="mx-0 px-0"
/>
) : item.kind === "tool" ? (
<TaskChatToolCard item={item} />
) : item.kind === "usage" ? (
<TaskChatUsageReadout item={item} />
) : item.kind === "marker" ? (
<RunnerActivityMarker item={item} />
) : item.kind === "protocol" ? (
<TaskChatProtocolActivityRow item={item} />
) : null}
</li>
))}
</ol>
</div>
);
}
function RunnerActivityMarker({ item }: { item: TaskChatMarkerItem }) {
return (
<div className="flex min-h-6 min-w-0 items-center gap-2 py-1 text-xs text-destructive">
<span className="flex w-5 shrink-0 items-center justify-center">
<OctagonX
className="h-3.5 w-3.5 shrink-0"
aria-hidden
data-testid="task-chat-marker-icon"
/>
</span>
<span className="shrink-0 font-medium">{item.label}</span>
{item.detail ? (
<span className="min-w-0 truncate text-muted-foreground">
{item.detail}
</span>
) : null}
</div>
);
}
function RunnerTurnStatus({
status,
startedAtMs,
@ -336,90 +107,11 @@ function RunnerTurnStatus({
);
}
function RunnerCurrentActivityTail({
items,
status,
}: {
items: readonly TaskChatItem[];
status: string;
}) {
function RunnerCurrentActivityTail({ status }: { status: string }) {
if (isTerminalRunStatus(status)) return null;
const activity = lastOf<
TaskChatThinkingItem | TaskChatToolItem | TaskChatProtocolItem
>(
items,
(
item,
): item is TaskChatThinkingItem | TaskChatToolItem | TaskChatProtocolItem =>
item.kind === "thinking" ||
item.kind === "tool" ||
isHeadlineProtocolActivity(item),
);
let Icon: ComponentType<SVGProps<SVGSVGElement>> | null = null;
let label = "Thinking";
let detail: string | undefined;
let family: string | undefined;
let active = true;
if (activity?.kind === "tool") {
const taxonomy = toolTaxonomy(activity.rawName ?? activity.name);
Icon = taxonomy.icon;
label = taxonomy.verbLabel;
detail = activity.target;
active = activity.status === "pending" || activity.status === "in_progress";
} else if (activity?.kind === "protocol") {
const presentation = protocolActivityPresentation(activity);
if (presentation) {
Icon = presentation.icon;
label = protocolActivityLabel(activity, presentation);
detail = presentation.detail;
active = protocolActivityIsRunning(activity);
family =
activity.surface === "provider_activity"
? activity.family
: activity.surface;
}
}
return (
<div
className="mt-2 flex min-h-8 min-w-0 items-center gap-2 px-1 py-1 text-xs text-muted-foreground"
data-testid="task-chat-current-activity"
data-activity-family={family}
data-turn-position="tail"
>
{Icon ? (
<span className="flex w-5 shrink-0 items-center justify-center">
<Icon
className={cn(
"h-3.5 w-3.5 shrink-0",
active && "text-(--status-agent-running)",
)}
aria-hidden
data-testid="task-chat-current-activity-icon"
/>
</span>
) : null}
<span className="flex min-w-0 flex-1 items-baseline gap-2">
<span
className={cn(
"shrink-0 font-normal",
active && "shimmer-text shimmer-text-muted",
)}
aria-live="polite"
aria-atomic="true"
data-testid="task-chat-current-activity-label"
>
{label}
</span>
{detail ? (
<span className="min-w-0 truncate font-mono text-(length:--text-micro)">
{detail}
</span>
) : null}
</span>
</div>
);
return <div className="mt-2 flex min-h-8 min-w-0 items-center gap-2 px-1 py-1 text-xs text-muted-foreground" data-testid="task-chat-current-activity" data-turn-position="tail">
<span className="shimmer-text shimmer-text-muted" aria-live="polite" data-testid="task-chat-current-activity-label">Thinking</span>
</div>;
}
export function TaskChatRunnerTurn({
@ -455,8 +147,6 @@ export function TaskChatRunnerTurn({
) => void | Promise<void>;
}) {
const terminal = isTerminalRunStatus(status);
const narration = latestFoldedNarration(items);
const currentActivityItems = currentActivityStatusItems(items);
const yielded = items.some(
(item) =>
item.kind === "protocol" &&
@ -503,6 +193,7 @@ export function TaskChatRunnerTurn({
}
const final = finalRef.current.item;
const timelineItems = paperclipRunnerTimelineItems(items);
const currentActivityItems = currentActivityStatusItems(timelineItems);
const timelineRows = buildTurnTimelineRows(
omitProgressRepeatedByResponse(timelineItems, final?.text),
!terminal,
@ -531,17 +222,9 @@ export function TaskChatRunnerTurn({
continuedAfterSteering={continuedAfterSteering}
/>
</div>
{!terminal && narration?.kind === "reasoning" && !final ? (
<div
className="flex min-w-0 flex-col py-1"
data-testid="task-chat-live-narration"
>
<FoldedLiveNarration narration={narration} />
</div>
) : null}
{activityUnavailable ? (
<div
className="px-1 py-1 text-xs text-destructive"
className="px-1 py-1 text-xs text-muted-foreground"
role="status"
data-testid="task-chat-activity-unavailable"
>
@ -562,16 +245,7 @@ export function TaskChatRunnerTurn({
data-thread-anchor={row.id}
>
{row.kind === "activity_phase" ? (
<TaskChatActivityPhase
item={row}
appearance="runner"
autoOpen={false}
childrenClassName="pl-0"
renderChild={() => null}
renderChildren={(children) => (
<RunnerActivityTimeline items={children} />
)}
/>
<TaskChatRunnerActivityGroup item={row} />
) : row.kind === "plan_document" ? (
<TaskChatPlanPreviewCard
source={{ kind: "saved", document: row.document }}
@ -604,7 +278,7 @@ export function TaskChatRunnerTurn({
/>
</div>
) : null}
{!final ? <RunnerCurrentActivityTail items={currentActivityItems} status={status} /> : null}
{!final && currentActivityItems.length === 0 ? <RunnerCurrentActivityTail status={status} /> : null}
</div>
);
}

View File

@ -15,6 +15,7 @@ import { TaskChatMarker } from "./TaskChatMarker";
import { TaskChatStatusPill } from "./TaskChatStatusPill";
import { TaskChatToolCard } from "./TaskChatToolCard";
import { TaskChatUsageReadout } from "./TaskChatUsageReadout";
import { TaskChatRunnerActivityGroup } from "./TaskChatRunnerActivityGroup";
import { TaskChatActivityPhase } from "./TaskChatActivityPhase";
import { TaskChatThinking } from "./TaskChatThinking";
import { TaskMessageScroller } from "./TaskMessageScroller";
@ -192,6 +193,7 @@ function renderItem(
case "usage":
return <TaskChatUsageReadout item={item} />;
case "activity_phase":
if (activityAppearance === "runner") return <TaskChatRunnerActivityGroup item={item} />;
return (
<TaskChatActivityPhase
item={item}

View File

@ -1231,6 +1231,7 @@ export function paperclipRunnerTimelineItems(
return parsed.filter(
(item) =>
activityIds.has(item.id) ||
(item.kind === "thinking" && Boolean(item.streaming)) ||
item.kind === "plan_document" ||
(item.kind === "protocol" && item.surface === "runtime_request"),
);

View File

@ -2924,3 +2924,19 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] {
.task-chat-loading-shell .animate-pulse {
animation: none;
}
/* The runner keeps one fixed-height activity row between commentary updates. */
@keyframes runner-activity-roll-in {
from { transform: translateY(100%); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes runner-activity-roll-out {
from { transform: translateY(0); opacity: 1; }
to { transform: translateY(-100%); opacity: 0; }
}
.runner-activity-roll-in { animation: runner-activity-roll-in var(--motion-line-scroll) var(--motion-ease-standard) both; }
.runner-activity-roll-out { animation: runner-activity-roll-out var(--motion-line-scroll) var(--motion-ease-standard) both; }
@media (prefers-reduced-motion: reduce) {
.runner-activity-roll-in { animation: none; }
.runner-activity-roll-out { display: none; }
}

View File

@ -1,6 +1,7 @@
import { TaskDetailTasksPanel } from "@/components/task-detail/TaskDetailTasksPanel";
import { SavedProviderKeySelect } from "../components/onboarding/SavedProviderKeySelect";
import { RepositoryEditor } from "@/components/RepositoryEditor";
import { TaskChatRunnerActivityGroup } from "@/components/task-chat/TaskChatRunnerActivityGroup";
import { TaskChatMarker } from "@/components/task-chat/TaskChatMarker";
import { TaskChatComposer } from "@/components/task-chat/TaskChatComposer";
import { TaskTreeControlDialog, TaskTreeControlMenuItems } from "@/components/TaskTreeControls";
@ -624,6 +625,13 @@ export function DesignGuide() {
{/* ============================================================ */}
{/* TYPOGRAPHY */}
{/* ============================================================ */}
<Section title="Runner activity">
<TaskChatRunnerActivityGroup item={{ id: "design-runner-activity", kind: "activity_phase", active: true, summary: "", interstitial: { id: "design-runner-commentary", kind: "message", author: "agent", text: "Ill inspect the activity feed and check the layout.", interstitial: true }, items: [
{ id: "design-runner-read", kind: "tool", name: "read", target: "TaskChatRunnerTurn.tsx", status: "completed", detail: "Found the activity groups." },
{ id: "design-runner-check", kind: "tool", name: "exec_command", target: "pnpm check:token-gates", status: "in_progress" },
] }} />
</Section>
<Section title="Typography">
<div className="space-y-3">
<h2 className="text-xl font-bold">Page Title text-xl font-bold</h2>

View File

@ -0,0 +1,30 @@
# Runner activity review
Open **Tasks / Runner activity preview / 01 · Desktop live · animated** in Storybook.
Use Pause / Next to step through the fixture, or Replay to watch the transitions.
From this worktree, start the preview with:
```sh
pnpm --filter @paperclipai/ui exec storybook dev --port 6024 --host 127.0.0.1 --no-open -c storybook/.storybook
```
- Each commentary message stays on the page and starts a new activity group.
- Compact groups retain one latest activity row. A new logical item rolls up;
updates to that same item's status do not replay the transition.
- The count and chevron expand that group into chronological history. An expanded
group stays expanded when new activity arrives. Collapse returns to its latest row.
- Expanded rows also stay on one line: label and target sit side by side,
with long targets truncated. Click a row to inspect its full target and detail.
Icon slots are centered,
identically sized, and aligned without nested rails or indentation.
- Separate stories cover light, mobile, long paths, full icon alignment, and
failures. Failures use neutral text, with no red styling or X icon.
- Desktop stories explicitly reset the viewport so visiting Mobile first does
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
cover commentary boundaries, approvals, final replies, retained expansion,
neutral failures, and reduced motion.

View File

@ -0,0 +1,301 @@
import { useEffect, useMemo, useState } from "react";
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 { TaskChatExpansionState } from "@/components/task-chat/expansion-state";
import { buildTurnTimelineRows } from "@/components/task-chat/transcript-adapter";
import type {
TaskChatItem,
TaskChatMessageItem,
} from "@/components/task-chat/task-chat-model";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
// Deterministic event playback through the production runner turn.
type Activity = {
kind: "activity";
id: string;
tool?: string;
target: string;
detail: string;
failed?: boolean;
};
type Commentary = { kind: "commentary"; id: string; text: string };
type Entry = Activity | Commentary;
const entries: Entry[] = [
{
kind: "commentary",
id: "intro",
text: "Ill check how the activity feed groups tool calls, then tighten up the layout and test it in the browser.",
},
{
kind: "activity",
id: "think-1",
target: "Checking the activity grouping",
detail: "Looking at where commentary ends and tool activity begins.",
},
{
kind: "activity",
id: "search",
tool: "grep",
target: "TaskChatRunnerTurn",
detail:
"Found the runner timeline and its activity rows in ui/src/components/task-chat/.",
},
{
kind: "activity",
id: "read",
tool: "read",
target: "TaskChatActivityPhase.tsx",
detail:
"The activity phase owns expansion. Individual tool rows have separate icon widths and padding.",
},
{
kind: "activity",
id: "mcp",
tool: "mcp__github__get_pull_request",
target: "paperclipai/paperclip · #13229",
detail:
"Read the previous task-feed performance changes to preserve stable row identity.",
},
{
kind: "commentary",
id: "finding",
text: "The icons use different gutters, and the tool list keeps growing between updates. Ill use one aligned row that rolls forward as each new activity starts.",
},
{
kind: "activity",
id: "think-2",
target: "Keeping commentary visible",
detail:
"Each commentary message starts a new activity group. Expanding a group preserves its full history as more items arrive.",
},
{
kind: "activity",
id: "edit",
tool: "apply_patch",
target: "RunnerActivityPreview.tsx",
detail:
"Added a common icon slot and a compact activity viewport. Expanded history uses the same alignment.",
},
{
kind: "activity",
id: "test",
tool: "exec_command",
target: "pnpm check:token-gates",
detail:
"Token gates passed. No hardcoded visual values in the activity rows.",
},
{
kind: "commentary",
id: "verification",
text: "The compact view now stays the same height during tool calls. Im checking long labels and the expanded view next.",
},
{
kind: "activity",
id: "browser",
tool: "exec_command",
target: "Check light, dark, and narrow layouts",
detail:
"All icon centers align with their row centers. Both compact and expanded activity rows stay on one line.",
},
{
kind: "activity",
id: "image",
tool: "view_image",
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.",
},
{
kind: "commentary",
id: "final",
text: "The preview is ready. Tool activity stays compact between each update, and you can expand any group to follow the full sequence.",
},
];
export interface RunnerActivityPreviewProps {
initialStep?: number;
autoPlay?: boolean;
expanded?: boolean;
narrow?: boolean;
longLabels?: boolean;
failed?: boolean;
}
export function RunnerActivityPreview({
initialStep = 3,
autoPlay = true,
expanded = false,
narrow = false,
longLabels = false,
failed = false,
}: RunnerActivityPreviewProps) {
const [step, setStep] = useState(initialStep);
const [playing, setPlaying] = useState(autoPlay);
const [replay, setReplay] = useState(0);
const reducedMotion = useReducedMotion();
const finished = step >= entries.length - 1;
useEffect(() => {
if (!playing || finished) return;
// Fixture event cadence, not animation timing. All movement uses motion tokens.
const timer = window.setTimeout(
() => setStep((value) => Math.min(value + 1, entries.length - 1)),
2400,
);
return () => window.clearTimeout(timer);
}, [playing, finished, step]);
const visible = entries.slice(0, step + 1).map((entry): Entry => {
if (entry.kind !== "activity") return entry;
return {
...entry,
...(longLabels && entry.tool
? {
target:
"ui/src/components/task-chat/transcript-adapter/native-runner-activity/very-long-file-name-without-convenient-breaks.test.tsx",
}
: {}),
...(failed && entry.id === "test"
? {
failed: true,
detail:
"The layout check failed: the trailing icon moved below the label at narrow widths. The failure stays visible even after the next activity arrives.",
}
: {}),
};
});
const memory = useMemo(() => new Map<string, boolean>(), [replay]);
const items = visible.map((entry, index): TaskChatItem => {
if (entry.kind === "commentary")
return {
kind: "message",
id: entry.id,
author: "agent",
text: entry.text,
channel: entry.id === "final" ? "final" : "progress",
interstitial: entry.id !== "final",
};
const active = !finished && index === visible.length - 1;
if (!entry.tool)
return {
kind: "thinking",
id: entry.id,
lines: [entry.target, entry.detail],
streaming: active,
};
return {
kind: "tool",
id: entry.id,
name: entry.tool,
target: entry.target,
detail: entry.detail,
status: entry.failed ? "failed" : active ? "in_progress" : "completed",
};
});
if (expanded)
for (const row of buildTurnTimelineRows(items, !finished)) {
if (row.kind === "activity_phase" && !memory.has(row.id))
memory.set(row.id, true);
}
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 ·{" "}
{reducedMotion ? "Reduced motion" : "One activity at a time"}
</p>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
disabled={finished}
onClick={() => setPlaying(!playing)}
>
{playing && !finished ? (
<Pause aria-hidden="true" />
) : (
<Play aria-hidden="true" />
)}
{playing && !finished ? "Pause" : "Play"}
</Button>
<Button
variant="ghost"
size="sm"
disabled={finished}
onClick={() => {
setPlaying(false);
setStep((value) => Math.min(value + 1, entries.length - 1));
}}
>
<StepForward aria-hidden="true" />
Next
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
setStep(1);
setReplay((value) => value + 1);
setPlaying(true);
}}
>
<RotateCcw aria-hidden="true" />
Replay
</Button>
</div>
</div>
<main
className={cn(
"mx-auto flex w-full flex-col gap-6 px-6 py-8",
narrow ? "max-w-sm" : "max-w-3xl",
)}
>
<div className="self-end rounded-xl bg-muted px-4 py-3 text-sm">
Can you clean up the runners activity feed?
</div>
<TaskChatExpansionState.Provider key={replay} value={memory}>
{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",
),
},
]}
/>
) : (
<TaskChatRunnerTurn
runId={`preview-${replay}`}
agentName="Engineer"
agentIcon="code"
items={items}
status={finished ? "succeeded" : "running"}
startedAtMs={null}
/>
)}
</TaskChatExpansionState.Provider>
</main>
</div>
);
}

View File

@ -0,0 +1,65 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { MINIMAL_VIEWPORTS } from "storybook/viewport";
import { RunnerActivityPreview } from "../prototypes/runner-activity/RunnerActivityPreview";
const meta = {
title: "Tasks/Runner activity preview",
component: RunnerActivityPreview,
globals: { viewport: { value: "desktop", isRotated: false } },
parameters: {
layout: "fullscreen",
viewport: {
options: {
...MINIMAL_VIEWPORTS,
desktop: {
name: "Desktop",
styles: { width: "100%", height: "100%" },
type: "desktop",
},
},
},
docs: {
description: {
component:
"Production runner turn with deterministic event playback. Each commentary boundary starts a separate activity group. Compact groups roll through one tool or thinking item at a time. Expand a group to keep its history growing inline, and click any expanded row to inspect its detail. Pause, Next, and Replay control simulated events; no runner or task API is called.",
},
},
},
} satisfies Meta<typeof RunnerActivityPreview>;
export default meta;
type Story = StoryObj<typeof meta>;
export const DesktopLive: Story = {
name: "01 · Desktop live · animated",
args: { initialStep: 1, autoPlay: true, narrow: false },
};
export const LiveCompact: Story = {
name: "Compact · paused",
args: { autoPlay: false },
};
export const LiveExpanded: Story = {
name: "02 · Desktop expanded · animated",
args: { expanded: true, autoPlay: true, narrow: false },
};
export const BetweenCommentary: Story = {
name: "03 · Between commentary",
args: { initialStep: 12, autoPlay: false },
};
export const IconAlignment: Story = {
name: "04 · Icon alignment",
args: { initialStep: 12, autoPlay: false, expanded: true },
};
export const LongLabels: Story = {
name: "05 · Long labels & narrow layout",
args: { initialStep: 8, autoPlay: false, narrow: true, longLabels: true },
};
export const Failure: Story = {
name: "06 · Failure stays visible",
args: { initialStep: 12, autoPlay: false, failed: true },
};
export const Light: Story = { name: "07 · Light", globals: { theme: "light" } };
export const Mobile: Story = {
name: "08 · Mobile live · animated",
args: { narrow: true },
globals: { viewport: { value: "mobile1", isRotated: false } },
};