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" } };