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:
parent
2904a3a6cc
commit
ce09ea40b0
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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", () => {
|
|||
"I’ve 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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: "I’ll 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>
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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: "I’ll 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. I’ll 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. I’m 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 runner’s 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 } },
|
||||
};
|
||||
Loading…
Reference in New Issue