From f2e2d389728369a89d94f2bd78da97c1a7f40214 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:48:25 -0500 Subject: [PATCH] feat(ui): summarize completed runner activity groups (#13274) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Task chat shows tool activity between agent updates. > - Finished groups currently keep the last raw command on screen. > - Failure totals add visual weight to normal retries. > - This pull request summarizes finished groups with short action phrases. > - Users can expand each group to inspect the original activity and output. > - The benefit is a quieter history that still explains the work. ## Linked Issues or Issue Description **What existing behavior does this improve?** Runner activity in live task chat and saved history. **Current behavior** A finished group shows its last command or tool target. A separate failure count remains visible after retries. **Proposed behavior** Show short phrases such as “Ran commands” or “Read files, ran commands” when a commentary group finishes. Remove failure counts in both active and finished groups. Keep original output in expandable history. **Reason and benefit** The activity summary describes the work without filling the conversation with shell arguments or treating each retry as an alarm. Unsuccessful reads and edits use “Checked files” and “Worked on files” to avoid claiming success. **Additional context** Refs: #13255. This builds on the merged rolling activity groups. Related open PR #13246 covers steering and status presentation; this change concerns completed summaries and failure counts. The design was reviewed in desktop and mobile Storybook previews. ## What Changed - Add a deterministic summary shared by builtin and provider-native activity. - Combine repeated categories and retries, preserve category order, and bound long summaries. - Summarize each inactive commentary group while the next group can still be running. - Keep expansion, individual output disclosures, and active rolling rows. - Use the production component in eight review stories, including animated desktop and mobile transitions. - Cover retries, unsuccessful edits, unknown tools, reasoning-only groups, saved history, and resuming work. ## Verification - Focused summary, group, and runner-turn tests pass: 62 tests. - Token gates pass. - The approved Storybook previews passed browser checks for desktop, mobile, keyboard expansion, live-to-completed transitions, and original retry output. - `pnpm -r typecheck`, `pnpm build`, and the production Storybook build pass. - The complete UI suite passes: 582 files and 6,005 tests. The full repository test run is in progress after repairing missing symlinks in the local PostgreSQL dependency. A native workspace suite that hit the setup failure now passes all six tests. - Greptile is 5/5 on the current commit, with no review threads. Security checks pass. All individual CI jobs pass except server shard 4/5, where one plugin-worker output timing assertion failed. The complete plugin-worker suite passes locally (83 tests). The workflow finished. One retry of the failed shard and aggregate check was dispatched and is queued. Squash auto-merge is enabled and remains gated on required checks. - Rechecked the production stories in the browser: mobile history stays on one line, and expansion survives completion while the next group remains active. - Review in Storybook under Tasks → Completed activity preview. Use Next to finish one group while the next group remains active. Open a completed summary to inspect its history. ## Risks - Summaries classify observed activity; “Ran commands” does not mean exit code zero. - Unknown tools use a generic description. Full names and outputs remain in history. - No API, database, or runner protocol contracts change. > Reviewed ROADMAP.md. This is a focused improvement to the existing task-chat presentation. ## 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 --- .../TaskChatRunnerActivityGroup.test.tsx | 29 +- .../task-chat/TaskChatRunnerActivityGroup.tsx | 44 +-- .../task-chat/TaskChatRunnerTurn.test.tsx | 2 +- .../completed-activity-summary.test.ts | 144 +++++++ .../task-chat/completed-activity-summary.ts | 132 +++++++ ui/src/pages/DesignGuide.tsx | 4 + .../CompletedActivityPreview.tsx | 358 ++++++++++++++++++ .../prototypes/completed-activity/README.md | 19 + .../prototypes/runner-activity/README.md | 6 +- .../runner-activity/RunnerActivityPreview.tsx | 2 +- .../stories/completed-activity.stories.tsx | 58 +++ .../stories/runner-activity.stories.tsx | 2 +- 12 files changed, 768 insertions(+), 32 deletions(-) create mode 100644 ui/src/components/task-chat/completed-activity-summary.test.ts create mode 100644 ui/src/components/task-chat/completed-activity-summary.ts create mode 100644 ui/storybook/prototypes/completed-activity/CompletedActivityPreview.tsx create mode 100644 ui/storybook/prototypes/completed-activity/README.md create mode 100644 ui/storybook/stories/completed-activity.stories.tsx diff --git a/ui/src/components/task-chat/TaskChatRunnerActivityGroup.test.tsx b/ui/src/components/task-chat/TaskChatRunnerActivityGroup.test.tsx index 75ab903264..132451e0cd 100644 --- a/ui/src/components/task-chat/TaskChatRunnerActivityGroup.test.tsx +++ b/ui/src/components/task-chat/TaskChatRunnerActivityGroup.test.tsx @@ -140,12 +140,13 @@ describe("TaskChatRunnerActivityGroup", () => { expect(container.querySelectorAll("li")).toHaveLength(2); expect(container.textContent).toContain("output-one"); act(() => toggle().click()); - expect(viewport().textContent).toContain("command-two"); + expect(toggle().textContent).toContain("Ran commands"); + expect(container.textContent).not.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"); + expect(toggle().textContent).not.toMatch(/\d+ failed/); act(() => toggle().click()); expect(container.querySelector("li")?.textContent).toContain("failed"); act(() => container.querySelector("li button")!.click()); @@ -203,8 +204,9 @@ describe("TaskChatRunnerActivityGroup", () => { 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"); + expect(toggle().textContent).toContain("Ran commands"); + expect(container.textContent).not.toContain("command-two"); + expect(toggle().textContent).not.toMatch(/\d+ failed/); }); it("does not offer empty disclosures for sparse activities", () => { @@ -239,6 +241,25 @@ describe("TaskChatRunnerActivityGroup", () => { ).toBeNull(); }); + it("settles to a summary and can resume without losing the current activity", () => { + const items = [tool("one", "failed"), tool("two", "completed")]; + render(items); + expect(viewport().textContent).toContain("command-two"); + render(items, "live", false); + expect( + container.querySelector('[data-testid="task-chat-activity-viewport"]'), + ).toBeNull(); + expect(toggle().textContent).toBe("Ran commands"); + expect(toggle().getAttribute("aria-label")).toContain("ran commands"); + expect(container.textContent).not.toContain("command-two"); + act(() => toggle().click()); + expect(container.querySelectorAll("li")).toHaveLength(2); + expect(toggle().textContent).not.toMatch(/\d+ failed/); + act(() => toggle().click()); + render([...items, tool("three")]); + expect(viewport().textContent).toContain("command-three"); + }); + it("replaces immediately with reduced motion", () => { motion.reduced = true; render([tool("one")]); diff --git a/ui/src/components/task-chat/TaskChatRunnerActivityGroup.tsx b/ui/src/components/task-chat/TaskChatRunnerActivityGroup.tsx index d0210e0214..d60e81c765 100644 --- a/ui/src/components/task-chat/TaskChatRunnerActivityGroup.tsx +++ b/ui/src/components/task-chat/TaskChatRunnerActivityGroup.tsx @@ -1,4 +1,5 @@ import { useId, useState } from "react"; +import { completedActivitySummary } from "./completed-activity-summary"; import { Brain, ChevronDown, @@ -101,16 +102,6 @@ function presentation(item: Activity, active: boolean) { }; } -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, @@ -330,7 +321,8 @@ export function TaskChatRunnerActivityGroup({ (activity) => presentation(activity, false) !== null, ); const latest = activities.at(-1); - const failures = activities.filter(isFailure).length; + const summary = completedActivitySummary(activities); + const SummaryIcon = summary.icon; const countLabel = `${activities.length} ${activities.length === 1 ? "activity" : "activities"}`; return (
setExpanded(!expanded)} aria-expanded={expanded} aria-controls={expanded ? historyId : undefined} - aria-label={`${expanded ? "Collapse" : "Expand"} ${countLabel}`} + aria-label={`${expanded ? "Collapse" : "Expand"} ${item.active ? countLabel : `${summary.fullLabel.toLowerCase()} (${countLabel})`}`} > - {expanded ? ( + {!item.active ? ( + + + + + {summary.label} + + + ) : expanded ? (
diff --git a/ui/storybook/prototypes/completed-activity/CompletedActivityPreview.tsx b/ui/storybook/prototypes/completed-activity/CompletedActivityPreview.tsx new file mode 100644 index 0000000000..eb4b464dcc --- /dev/null +++ b/ui/storybook/prototypes/completed-activity/CompletedActivityPreview.tsx @@ -0,0 +1,358 @@ +import { useEffect, useState } from "react"; +import { Pause, Play, RotateCcw, StepForward } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import type { + TaskChatActivityPhaseItem, + TaskChatToolItem, +} from "@/components/task-chat/task-chat-model"; +import { TaskChatRunnerActivityGroup as CompletedActivityGroup } from "@/components/task-chat/TaskChatRunnerActivityGroup"; + +type Activity = TaskChatActivityPhaseItem["items"][number]; +const tool = ( + id: string, + name: string, + target: string, + status: TaskChatToolItem["status"] = "completed", + detail = "Completed.", +): TaskChatToolItem => ({ kind: "tool", id, name, target, status, detail }); +const read = tool( + "read", + "read", + "ui/src/components/task-chat/TaskChatRunnerActivityGroup.tsx", + "completed", + "Read the activity group renderer.", +); +const command = tool( + "command", + "exec_command", + "pnpm --filter @paperclipai/ui typecheck", + "completed", + "Typecheck passed.", +); +const retry = tool( + "retry", + "exec_command", + "/bin/bash -lc 'curl --fail http://127.0.0.1:6025/'", + "failed", + "curl: (7) Could not connect to server. The preview was still starting.", +); +const recovered = tool( + "recovered", + "exec_command", + "curl --fail http://127.0.0.1:6025/", + "completed", + "HTTP 200. The preview is now reachable.", +); +const thought: Activity = { + kind: "thinking", + id: "thought", + lines: ["Checking how completed groups read between commentary messages."], +}; +const search = tool( + "search", + "grep", + "activity_phase", + "completed", + "Found the activity grouping code.", +); +const edit = tool( + "edit", + "apply_patch", + "ui/storybook/prototypes/completed-activity/CompletedActivityGroup.tsx", + "completed", + "Updated the completed summary.", +); +const web: Activity = { + kind: "protocol", + id: "web", + surface: "provider_activity", + family: "research", + eventType: "research.completed", + status: "completed", + title: "Web search", + summary: "Accessible activity disclosures", + details: [{ label: "Query", value: "accessible disclosure patterns" }], + steps: [], + links: [], + children: [], +}; + +const scenarios: Array<{ + id: string; + title: string; + note: string; + items: Activity[]; +}> = [ + { + id: "commands", + title: "Commands only", + note: "Repeated commands become one phrase, with no shell text in the collapsed row.", + items: [command, retry, recovered], + }, + { + id: "mixed", + title: "Files and commands", + note: "Reading plus execution stays specific. Thoughts and usage do not crowd out the useful actions.", + items: [thought, read, command], + }, + { + id: "single", + title: "One activity", + note: "The same quiet category wording works for one file or many files.", + items: [read], + }, + { + id: "recovery", + title: "Retry followed by recovery", + note: "No failure count or warning badge. Expand to inspect the first attempt and its output.", + items: [retry, recovered], + }, + { + id: "unsuccessful", + title: "Commands end without success", + note: "“Ran commands” describes what happened; it does not say the commands passed.", + items: [ + retry, + { + ...command, + status: "failed", + detail: + "Command exited with code 1. The configuration needs attention.", + }, + ], + }, + { + id: "edits", + title: "An edit did not complete", + note: "Use “Worked on files” when no edit completed, rather than claiming files were changed.", + items: [ + { + ...edit, + status: "failed", + detail: + "The patch did not apply because the surrounding lines changed.", + }, + ], + }, + { + id: "interrupted", + title: "Stopped partway through", + note: "Describe the actions taken. Task-level commentary explains why work stopped.", + items: [ + read, + { + ...command, + status: "interrupted", + detail: "Stopped at the user’s request.", + }, + ], + }, + { + id: "research", + title: "Web research", + note: "Provider-native events get the same human summary as ordinary tools.", + items: [thought, web], + }, + { + id: "many", + title: "Several kinds of work", + note: "Keep the row short with “and more”; the full description is available on hover and all activity remains expandable.", + items: [ + read, + command, + search, + edit, + web, + tool( + "mcp", + "mcp__github__get_pull_request", + "paperclipai/paperclip #13255", + ), + ], + }, + { + id: "thought", + title: "Thoughts only", + note: "No invented tool activity when the agent only reasoned about the task.", + items: [thought], + }, + { + id: "unknown", + title: "Unrecognized tool", + note: "Fall back to “Used tools” without exposing internal identifiers.", + items: [tool("unknown", "custom_worker_v2", "opaque-operation-8792")], + }, +]; +function phase( + id: string, + items: Activity[], + active = false, +): TaskChatActivityPhaseItem { + return { id, kind: "activity_phase", items, active, summary: "" }; +} + +export function CompletedActivityPreview({ + mode = "conversation", + narrow = false, + expanded = false, + autoPlay = true, +}: { + mode?: "conversation" | "gallery" | "live"; + narrow?: boolean; + expanded?: boolean; + autoPlay?: boolean; +}) { + const [step, setStep] = useState(0); + const [playing, setPlaying] = useState(autoPlay); + const [replay, setReplay] = useState(0); + useEffect(() => { + if (mode !== "live" || !playing || step >= 4) return; + // Fixture cadence; row motion uses the existing production motion tokens. + const timer = window.setTimeout(() => setStep((s) => s + 1), 2200); + return () => window.clearTimeout(timer); + }, [mode, playing, step]); + const liveItems: Activity[] = + step === 0 + ? [{ ...read, status: "in_progress" }] + : step === 1 + ? [read, { ...retry, status: "in_progress" }] + : step === 2 + ? [read, retry, { ...recovered, status: "in_progress" }] + : [read, retry, recovered]; + return ( +
+
+
+

Completed activity

+

+ Completed activity · expand any summary to inspect its history +

+
+ {mode === "live" && ( +
+ + + +
+ )} +
+
+ {mode === "gallery" ? ( + scenarios.map((scenario) => ( +
+

{scenario.title}

+

{scenario.note}

+ +
+ )) + ) : mode === "live" ? ( + <> +

+ I’ll read the activity component, then check that the preview is + reachable. +

+ + {step >= 3 && ( +

+ The preview is reachable. The first request arrived before the + server was ready; the next one connected. +

+ )} + {step >= 3 && ( + + )} + {step === 4 && ( +

+ Typecheck passed. The preview is ready for review. +

+ )} + + ) : ( + <> +

+ I’ll check the activity renderer and the surrounding task layout. +

+ +

+ The grouping is already in place. I’m updating how each group + reads after its work is done. +

+ +

+ The preview took a moment to start. I’ll check the address again. +

+ +

+ The preview is ready. Completed groups now describe the work in a + few words, and you can expand any group for the full details. +

+ + )} +
+
+ ); +} diff --git a/ui/storybook/prototypes/completed-activity/README.md b/ui/storybook/prototypes/completed-activity/README.md new file mode 100644 index 0000000000..65a41cef50 --- /dev/null +++ b/ui/storybook/prototypes/completed-activity/README.md @@ -0,0 +1,19 @@ +# Completed activity proposal + +Production runner activity group with deterministic fixtures for the approved completed summaries. + +Open **Tasks → Completed activity preview**. Start with Completed conversation, Summary situations, and Desktop live to completed. Mobile and expanded playback variants exercise the same proposal. + +## Behavior + +- While a group is active, keep the current rolling activity and its target. +- When the next commentary message arrives, or the run ends, replace the collapsed activity with a short taxonomy-based summary. +- Combine repeated categories and retries: “Ran commands”, “Read files, ran commands”. Do not summarize shell arguments or invent outcomes from command text. +- Omit failure counts in both collapsed and expanded groups. Tool details retain the actual output. +- Leave completed collapsed rows free of counts. The chevron opens history; the expanded header shows the ordinary activity count. +- A group with tools omits thoughts and usage from its summary. Thoughts-only groups say “Thought through the task”. +- For unsuccessful reads/edits, use “Checked files” / “Worked on files” instead of claiming a successful read/change. “Ran commands” does not imply exit code zero. +- More than three categories collapse to two categories plus “and more”. The full description is the tooltip; history remains available by click or keyboard. +- Expansion persists while a group transitions from active to settled. History stays single-line, with full output behind an individual disclosure. + +All stories render the shared production activity group. No runner or task API calls are made. diff --git a/ui/storybook/prototypes/runner-activity/README.md b/ui/storybook/prototypes/runner-activity/README.md index 1d04f08744..28b8b5bc58 100644 --- a/ui/storybook/prototypes/runner-activity/README.md +++ b/ui/storybook/prototypes/runner-activity/README.md @@ -10,16 +10,16 @@ pnpm --filter @paperclipai/ui exec storybook dev --port 6024 --host 127.0.0.1 -- ``` - 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; +- Active 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. + group stays expanded when new activity arrives. Collapse returns to its latest row while active, or a short action summary once finished. - 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. + retries. Failures use neutral text inside history, with no red styling, X icon, or failure count. - 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. diff --git a/ui/storybook/prototypes/runner-activity/RunnerActivityPreview.tsx b/ui/storybook/prototypes/runner-activity/RunnerActivityPreview.tsx index 5f8d33d159..4f397d91c7 100644 --- a/ui/storybook/prototypes/runner-activity/RunnerActivityPreview.tsx +++ b/ui/storybook/prototypes/runner-activity/RunnerActivityPreview.tsx @@ -161,7 +161,7 @@ export function RunnerActivityPreview({ ? { 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.", + "The layout check failed: the trailing icon moved below the label at narrow widths. The output stays available in expanded history after the next activity arrives.", } : {}), }; diff --git a/ui/storybook/stories/completed-activity.stories.tsx b/ui/storybook/stories/completed-activity.stories.tsx new file mode 100644 index 0000000000..3aab0de5e1 --- /dev/null +++ b/ui/storybook/stories/completed-activity.stories.tsx @@ -0,0 +1,58 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { MINIMAL_VIEWPORTS } from "storybook/viewport"; +import { CompletedActivityPreview } from "../prototypes/completed-activity/CompletedActivityPreview"; + +const meta = { + title: "Tasks/Completed activity preview", + component: CompletedActivityPreview, + 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 activity group. Completed commentary groups collapse to short action summaries. No failure counts in either state. Expand to see the original one-line activity rows and their full details. The live stories show a group settling while the next group starts.", + }, + }, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; +export const Conversation: Story = { name: "01 · Completed conversation" }; +export const Situations: Story = { + name: "02 · Summary situations", + args: { mode: "gallery" }, +}; +export const DesktopLive: Story = { + name: "03 · Desktop live to completed", + args: { mode: "live" }, +}; +export const Expanded: Story = { + name: "04 · Expanded history", + args: { expanded: true }, +}; +export const ExpandedLive: Story = { + name: "05 · Expanded live to completed", + args: { mode: "live", expanded: true }, +}; +export const MobileLive: Story = { + name: "06 · Mobile live to completed", + args: { mode: "live", narrow: true }, + globals: { viewport: { value: "mobile1", isRotated: false } }, +}; +export const MobileSituations: Story = { + name: "07 · Mobile summary situations", + args: { mode: "gallery", narrow: true }, + globals: { viewport: { value: "mobile1", isRotated: false } }, +}; +export const Light: Story = { name: "08 · Light", globals: { theme: "light" } }; diff --git a/ui/storybook/stories/runner-activity.stories.tsx b/ui/storybook/stories/runner-activity.stories.tsx index 4d6929f2a7..0be92c339c 100644 --- a/ui/storybook/stories/runner-activity.stories.tsx +++ b/ui/storybook/stories/runner-activity.stories.tsx @@ -54,7 +54,7 @@ export const LongLabels: Story = { args: { initialStep: 8, autoPlay: false, narrow: true, longLabels: true }, }; export const Failure: Story = { - name: "06 · Failure stays visible", + name: "06 · Retry details", args: { initialStep: 12, autoPlay: false, failed: true }, }; export const Light: Story = { name: "07 · Light", globals: { theme: "light" } };