From ce09ea40b0bd04eb1d8be04ec3f1f648147144b7 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Fri, 11 Sep 2026 15:06:31 -0500 Subject: [PATCH] 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 --- ui/src/components/TaskChatThread.test.tsx | 2 +- .../TaskChatProtocolActivityRow.test.tsx | 16 +- .../task-chat/TaskChatProtocolActivityRow.tsx | 42 +- .../TaskChatRunnerActivityGroup.test.tsx | 253 +++++++++++ .../task-chat/TaskChatRunnerActivityGroup.tsx | 405 ++++++++++++++++++ .../task-chat/TaskChatRunnerTurn.test.tsx | 240 +++-------- .../task-chat/TaskChatRunnerTurn.tsx | 346 +-------------- .../task-chat/TaskChatThreadView.tsx | 2 + .../task-chat/transcript-adapter.ts | 1 + ui/src/index.css | 16 + ui/src/pages/DesignGuide.tsx | 8 + .../prototypes/runner-activity/README.md | 30 ++ .../runner-activity/RunnerActivityPreview.tsx | 301 +++++++++++++ .../stories/runner-activity.stories.tsx | 65 +++ 14 files changed, 1213 insertions(+), 514 deletions(-) create mode 100644 ui/src/components/task-chat/TaskChatRunnerActivityGroup.test.tsx create mode 100644 ui/src/components/task-chat/TaskChatRunnerActivityGroup.tsx create mode 100644 ui/storybook/prototypes/runner-activity/README.md create mode 100644 ui/storybook/prototypes/runner-activity/RunnerActivityPreview.tsx create mode 100644 ui/storybook/stories/runner-activity.stories.tsx diff --git a/ui/src/components/TaskChatThread.test.tsx b/ui/src/components/TaskChatThread.test.tsx index cee57a30aa..063472e28a 100644 --- a/ui/src/components/TaskChatThread.test.tsx +++ b/ui/src/components/TaskChatThread.test.tsx @@ -988,7 +988,7 @@ describe("TaskChatThread runtime transcript selection", () => { ); const revealUsage = () => { const summary = container.querySelector( - '[data-testid="task-chat-phase-summary"]', + '[data-testid="task-chat-activity-phase-toggle"]', ); expect(summary).not.toBeNull(); if (summary?.getAttribute("aria-expanded") !== "true") { diff --git a/ui/src/components/task-chat/TaskChatProtocolActivityRow.test.tsx b/ui/src/components/task-chat/TaskChatProtocolActivityRow.test.tsx index fa9ad62506..bb2fb87944 100644 --- a/ui/src/components/task-chat/TaskChatProtocolActivityRow.test.tsx +++ b/ui/src/components/task-chat/TaskChatProtocolActivityRow.test.tsx @@ -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()); + 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()); + expect(container.querySelector("a")?.getAttribute("href")).toBe(href); + }); + it("renders durable resources as direct compact links", () => { render({ id: "resource", diff --git a/ui/src/components/task-chat/TaskChatProtocolActivityRow.tsx b/ui/src/components/task-chat/TaskChatProtocolActivityRow.tsx index 8f0b41b048..a1f3590a15 100644 --- a/ui/src/components/task-chat/TaskChatProtocolActivityRow.tsx +++ b/ui/src/components/task-chat/TaskChatProtocolActivityRow.tsx @@ -35,7 +35,8 @@ function StatusIcon({ status }: { status: "running" | "completed" | "failed" | " return ; } -function stepStatusIcon(status: TaskChatProtocolStep["status"]) { +function stepStatusIcon(status: TaskChatProtocolStep["status"], neutral = false) { + if (neutral && (status === "blocked" || status === "failed")) return ; if (status === "in_progress") return ; if (status === "completed") return ; if (status === "blocked" || status === "failed") return ; @@ -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 ; return (
@@ -133,7 +134,7 @@ function ProviderDetails({ item }: { item: TaskChatProviderActivityItem }) {
    {item.steps.map((step) => (
  1. - {stepStatusIcon(step.status)} + {stepStatusIcon(step.status, neutral)} {step.label}
  2. ))} @@ -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 ? : null; + return expandable ? : null; } case "workspace_change": return ; case "workspace_file": return ; @@ -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 ? {item.title} :

    {item.title}

    ; + } + return <>{item.surface === "provider_activity" && item.summary ?

    {item.summary}

    : 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 ( - + {row} ); diff --git a/ui/src/components/task-chat/TaskChatRunnerActivityGroup.test.tsx b/ui/src/components/task-chat/TaskChatRunnerActivityGroup.test.tsx new file mode 100644 index 0000000000..75ab903264 --- /dev/null +++ b/ui/src/components/task-chat/TaskChatRunnerActivityGroup.test.tsx @@ -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()), + 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; + 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( + + + , + ), + ); + const toggle = () => + container.querySelector( + '[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("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("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("li button")!.click()); + act(() => + root.render( + + + + + + + , + ), + ); + 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(); + }); +}); diff --git a/ui/src/components/task-chat/TaskChatRunnerActivityGroup.tsx b/ui/src/components/task-chat/TaskChatRunnerActivityGroup.tsx new file mode 100644 index 0000000000..d0210e0214 --- /dev/null +++ b/ui/src/components/task-chat/TaskChatRunnerActivityGroup.tsx @@ -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 ( + + + + + + {row.label} + + {row.target ? ( + + {row.target} + + ) : null} + + + ); +} + +/** 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 ( + + {frame.previous && !reducedMotion ? ( + + ) : null} + + + + + ); +} + +function ActivityDetails({ item }: { item: Activity }) { + if (item.kind === "thinking") + return {item.lines.join("\n")}; + if (item.kind === "usage") return ; + if (item.kind === "protocol") + return ; + if (item.kind === "marker") + return ( +

    + {item.detail ?? item.label} +

    + ); + return ( + <> + {item.target ? ( +

    {item.target}

    + ) : null} + {item.detail ? ( +
    +          {item.detail}
    +        
    + ) : null} + {item.decision ?

    Permission {item.decision}

    : null} + {item.diff ? ( +

    + {item.diff.path} · +{item.diff.added} −{item.diff.removed} +

    + ) : 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 = ; + return ( +
  3. + {expandable ? ( + + ) : ( +
    + {content} +
    + )} + {expandable && open ? ( +
    + +
    + ) : null} +
  4. + ); +} + +/** 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 ( +
    + {item.interstitial ? ( +
    + + {item.interstitial.text} + +
    + ) : null} + {latest ? ( +
    + + {expanded ? ( +
      + {activities.map((activity, index) => ( + + ))} +
    + ) : null} +
    + ) : null} +
    + ); +} diff --git a/ui/src/components/task-chat/TaskChatRunnerTurn.test.tsx b/ui/src/components/task-chat/TaskChatRunnerTurn.test.tsx index 21b2b1aaa8..6e9510ccb7 100644 --- a/ui/src/components/task-chat/TaskChatRunnerTurn.test.tsx +++ b/ui/src/components/task-chat/TaskChatRunnerTurn.test.tsx @@ -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( - '[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( - '[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( - '[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( - '[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( - '[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( - '[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( - '[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( - '[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( - '[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( - '[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"); }); diff --git a/ui/src/components/task-chat/TaskChatRunnerTurn.tsx b/ui/src/components/task-chat/TaskChatRunnerTurn.tsx index 7f944e6c1b..c65890e7a9 100644 --- a/ui/src/components/task-chat/TaskChatRunnerTurn.tsx +++ b/ui/src/components/task-chat/TaskChatRunnerTurn.tsx @@ -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( - 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 ( -
    -
    - -
    -
    - {ticker.exiting !== null ? ( - - setTicker((current) => ({ ...current, exiting: null })) - } - > - {ticker.exiting} - - ) : null} - 0 && "cot-line-enter", - )} - aria-live="polite" - aria-atomic="true" - > - {ticker.current} - -
    -
    - ); -} - -function FoldedLiveNarration({ - narration, -}: { - narration: Extract; -}) { - if (!narration.line) return null; - return ( - - ); -} - 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 ( -
    - -
      - {items.map((item, index) => ( -
    1. - {item.kind === "message" ? ( -
      - - {item.text} - -
      - ) : item.kind === "thinking" ? ( - - ) : item.kind === "tool" ? ( - - ) : item.kind === "usage" ? ( - - ) : item.kind === "marker" ? ( - - ) : item.kind === "protocol" ? ( - - ) : null} -
    2. - ))} -
    -
    - ); -} - -function RunnerActivityMarker({ item }: { item: TaskChatMarkerItem }) { - return ( -
    - - - - {item.label} - {item.detail ? ( - - {item.detail} - - ) : null} -
    - ); -} - 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> | 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 ( -
    - {Icon ? ( - - - - ) : null} - - - {label} - - {detail ? ( - - {detail} - - ) : null} - -
    - ); + return
    + Thinking +
    ; } export function TaskChatRunnerTurn({ @@ -455,8 +147,6 @@ export function TaskChatRunnerTurn({ ) => void | Promise; }) { 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} />
- {!terminal && narration?.kind === "reasoning" && !final ? ( -
- -
- ) : null} {activityUnavailable ? (
@@ -562,16 +245,7 @@ export function TaskChatRunnerTurn({ data-thread-anchor={row.id} > {row.kind === "activity_phase" ? ( - null} - renderChildren={(children) => ( - - )} - /> + ) : row.kind === "plan_document" ? (
) : null} - {!final ? : null} + {!final && currentActivityItems.length === 0 ? : null} ); } diff --git a/ui/src/components/task-chat/TaskChatThreadView.tsx b/ui/src/components/task-chat/TaskChatThreadView.tsx index 3f5a1aa503..a1242e367c 100644 --- a/ui/src/components/task-chat/TaskChatThreadView.tsx +++ b/ui/src/components/task-chat/TaskChatThreadView.tsx @@ -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 ; case "activity_phase": + if (activityAppearance === "runner") return ; return ( activityIds.has(item.id) || + (item.kind === "thinking" && Boolean(item.streaming)) || item.kind === "plan_document" || (item.kind === "protocol" && item.surface === "runtime_request"), ); diff --git a/ui/src/index.css b/ui/src/index.css index 8cb1bdca25..80db413948 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -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; } +} diff --git a/ui/src/pages/DesignGuide.tsx b/ui/src/pages/DesignGuide.tsx index 201230bc03..dff00f5938 100644 --- a/ui/src/pages/DesignGuide.tsx +++ b/ui/src/pages/DesignGuide.tsx @@ -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 */} {/* ============================================================ */} +
+ +
+

Page Title — text-xl font-bold

diff --git a/ui/storybook/prototypes/runner-activity/README.md b/ui/storybook/prototypes/runner-activity/README.md new file mode 100644 index 0000000000..1d04f08744 --- /dev/null +++ b/ui/storybook/prototypes/runner-activity/README.md @@ -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. diff --git a/ui/storybook/prototypes/runner-activity/RunnerActivityPreview.tsx b/ui/storybook/prototypes/runner-activity/RunnerActivityPreview.tsx new file mode 100644 index 0000000000..5f8d33d159 --- /dev/null +++ b/ui/storybook/prototypes/runner-activity/RunnerActivityPreview.tsx @@ -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(), [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 ( +
+
+
+

Runner activity

+

+ Production component ·{" "} + {reducedMotion ? "Reduced motion" : "One activity at a time"} +

+
+
+ + + +
+
+
+
+ Can you clean up the runner’s activity feed? +
+ + {finished ? ( + + item.kind === "message" && item.channel === "final", + ), + }, + ]} + /> + ) : ( + + )} + +
+
+ ); +} diff --git a/ui/storybook/stories/runner-activity.stories.tsx b/ui/storybook/stories/runner-activity.stories.tsx new file mode 100644 index 0000000000..4d6929f2a7 --- /dev/null +++ b/ui/storybook/stories/runner-activity.stories.tsx @@ -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; +export default meta; +type Story = StoryObj; + +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 } }, +};