feat(ui): summarize completed runner activity groups (#13274)

## 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 <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-09-11 17:48:25 -05:00 committed by GitHub
parent 1c4bcff2b1
commit f2e2d38972
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 768 additions and 32 deletions

View File

@ -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<HTMLButtonElement>("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")]);

View File

@ -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 (
<section
@ -358,9 +350,21 @@ export function TaskChatRunnerActivityGroup({
onClick={() => 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 ? (
<span
className="flex h-8 min-w-0 flex-1 items-center gap-2 text-xs"
data-completed-summary
>
<span className="flex size-5 shrink-0 items-center justify-center">
<SummaryIcon className="size-3.5" aria-hidden="true" />
</span>
<span className="truncate" title={summary.fullLabel}>
{summary.label}
</span>
</span>
) : expanded ? (
<span className="flex min-h-8 flex-1 items-center gap-2 text-xs">
<span className="flex size-5 shrink-0 items-center justify-center">
<ChevronDown className="size-3.5" aria-hidden="true" />
@ -370,16 +374,12 @@ export function TaskChatRunnerActivityGroup({
) : (
<RollingActivity item={latest} active={item.active} />
)}
{failures > 0 ? (
<span className="shrink-0 text-xs text-muted-foreground">
{failures} failed
</span>
) : null}
<span className="flex shrink-0 items-center gap-1 text-xs">
{expanded ? "Collapse" : activities.length}
{!expanded ? (
<ChevronRight className="size-3.5" aria-hidden="true" />
) : null}
{expanded ? countLabel : item.active ? activities.length : null}
<ChevronRight
className={cn("size-3.5", expanded && "rotate-90")}
aria-hidden="true"
/>
</span>
</button>
{expanded ? (

View File

@ -1409,7 +1409,7 @@ describe("TaskChatRunnerTurn", () => {
expect(rows[0]?.textContent).toContain("Read file");
expect(rows[2]?.textContent).toContain("Questions answered");
expect(rows[3]?.textContent).toContain("Searched the web");
expect(rows[5]?.textContent).toContain("Ran a command");
expect(rows[5]?.textContent).toContain("Ran commands");
const worked = container.querySelector(
'[data-testid="task-chat-turn-status-header"]',
);

View File

@ -0,0 +1,144 @@
import { describe, expect, it } from "vitest";
import { completedActivitySummary } from "./completed-activity-summary";
import type {
TaskChatActivityPhaseItem,
TaskChatProviderActivityItem,
TaskChatToolItem,
} from "./task-chat-model";
type Activity = TaskChatActivityPhaseItem["items"][number];
const tool = (
name: string,
status: TaskChatToolItem["status"] = "completed",
): TaskChatToolItem => ({
id: name,
kind: "tool",
name,
status,
target: "private-command-or-path",
detail: "private-output",
});
const provider = (
family: TaskChatProviderActivityItem["family"],
status: TaskChatProviderActivityItem["status"] = "completed",
): TaskChatProviderActivityItem => ({
id: family,
kind: "protocol",
surface: "provider_activity",
family,
status,
title: "provider internal title",
eventType: "activity",
details: [],
steps: [],
links: [],
children: [],
});
const thought: Activity = {
id: "thought",
kind: "thinking",
lines: ["Reasoning text"],
};
describe("completedActivitySummary", () => {
it.each([
"completed",
"failed",
"interrupted",
"pending",
"in_progress",
] as const)(
"describes command attempts without claiming success (%s)",
(status) => {
expect(
completedActivitySummary([
tool("exec_command", status),
tool("bash", status),
]).label,
).toBe("Ran commands");
},
);
it("merges retries and hides reasoning, raw targets and output from the summary", () => {
const summary = completedActivitySummary([
thought,
tool("read"),
tool("exec_command", "failed"),
tool("exec_command"),
]);
expect(summary.label).toBe("Read files, ran commands");
expect(summary.fullLabel).not.toMatch(/private|Reasoning|failed/);
});
it.each([
["read", "failed", "Checked files"],
["read", "interrupted", "Checked files"],
["apply_patch", "failed", "Worked on files"],
["apply_patch", "interrupted", "Worked on files"],
["read", "completed", "Read files"],
["apply_patch", "completed", "Edited files"],
] as const)(
"does not claim an unsuccessful %s completed",
(name, status, expected) => {
expect(completedActivitySummary([tool(name, status)]).label).toBe(
expected,
);
},
);
it("uses completed work once when a read or edit retry succeeds", () => {
expect(
completedActivitySummary([
tool("read", "failed"),
tool("apply_patch", "failed"),
tool("read"),
tool("apply_patch"),
]).label,
).toBe("Read files, edited files");
});
it("classifies native provider tools through the same taxonomy and keeps encounter order", () => {
const native = {
...provider("tool_execution", "failed"),
details: [{ label: "Name", value: "exec_command" }],
};
expect(
completedActivitySummary([
tool("read"),
provider("research"),
native,
tool("bash"),
]).label,
).toBe("Read files, searched the web, ran commands");
});
it("supports canonical operation identity when a native tool has no useful name", () => {
const native = {
...provider("tool_execution"),
details: [
{ label: "Name", value: "tool" },
{ label: "Operation", value: "execute" },
],
};
expect(completedActivitySummary([native]).label).toBe("Ran commands");
});
it("bounds many categories while keeping the complete accessible description", () => {
const summary = completedActivitySummary([
tool("read"),
tool("exec_command"),
tool("grep"),
tool("apply_patch"),
provider("research"),
]);
expect(summary.label).toBe("Read files, ran commands, and more");
expect(summary.fullLabel).toBe(
"Read files, ran commands, searched files, edited files, searched the web",
);
});
it("does not invent tool activity for thoughts-only groups or expose unknown identifiers", () => {
expect(completedActivitySummary([thought]).label).toBe(
"Thought through the task",
);
expect(completedActivitySummary([tool("opaque_8792")]).label).toBe(
"Used tools",
);
expect(
completedActivitySummary([tool("mcp__github__get_pull_request")]).label,
).toBe("Used connected tools");
});
});

View File

@ -0,0 +1,132 @@
import { Brain, CirclePause, Gauge, Layers3 } from "lucide-react";
import type { TaskChatActivityPhaseItem } from "./task-chat-model";
import {
toolActivityPresentation,
type ToolFamily,
type ToolIcon,
} from "./tool-taxonomy";
import { protocolActivityPresentation } from "./task-chat-activity-presentation";
type Activity = TaskChatActivityPhaseItem["items"][number];
const labels: Record<ToolFamily, string> = {
terminal: "Ran commands",
grep: "Searched files",
search: "Searched files",
read: "Read files",
edit: "Edited files",
web: "Searched the web",
plan: "Worked on a plan",
question: "Requested input",
agent: "Worked with agents",
safety: "Reviewed safety",
image: "Worked with images",
wait: "Waited",
mcp: "Used connected tools",
other: "Used tools",
};
/** Describe observed activities, never infer success from a finished group. */
export function completedActivitySummary(items: Activity[]) {
const categories = new Map<
string,
{ label: string; icon: ToolIcon; order: number }
>();
const add = (label: string, icon: ToolIcon, order = 0) => {
const existing = categories.get(label);
if (!existing || order < existing.order)
categories.set(label, { label, icon, order });
};
const tools: Array<{
family: ToolFamily;
icon: ToolIcon;
completed: boolean;
order: number;
}> = [];
for (const [order, item] of items.entries()) {
if (item.kind === "tool") {
const p = toolActivityPresentation({
name: item.rawName ?? item.name,
target: item.target,
});
tools.push({ ...p, order, completed: item.status === "completed" });
} else if (item.kind === "protocol") {
if (
item.surface === "provider_activity" &&
item.family === "tool_execution"
) {
const value = (label: string) =>
item.details.find((d) => d.label === label)?.value;
const p = toolActivityPresentation({
name: value("Name"),
operation: value("Operation"),
transport: value("Transport"),
namespace: value("Namespace"),
target: value("Target"),
});
tools.push({ ...p, order, completed: item.status === "completed" });
} else {
const p = protocolActivityPresentation(item);
if (!p) continue;
const family =
item.surface === "provider_activity" ? item.family : item.surface;
const label =
(
{
research: "Searched the web",
plan: "Worked on a plan",
delegation: "Worked with agents",
artifact: "Worked with artifacts",
context: "Managed context",
memory: "Checked memory",
model_identity: "Checked model settings",
review: "Worked in review mode",
hook: "Ran hooks",
safety: "Reviewed safety",
terminal: "Ran commands",
wait: "Waited",
provider_notice: "Received a provider update",
workspace_change: "Worked on files",
workspace_file: "Referenced files",
resource: "Added resources",
} as Record<string, string>
)[family] ?? "Used tools";
add(label, p.icon, order);
}
}
}
const completedFamilies = new Set(
tools.filter((tool) => tool.completed).map((tool) => tool.family),
);
// Merge repeated families and retries. Terminal is an action, not an exit-status claim.
for (const tool of tools) {
const succeeded = completedFamilies.has(tool.family);
const label =
tool.family === "read" && !succeeded
? "Checked files"
: tool.family === "edit" && !succeeded
? "Worked on files"
: labels[tool.family];
add(label, tool.icon, tool.order);
}
if (!categories.size) {
if (items.some((item) => item.kind === "thinking"))
add("Thought through the task", Brain);
else if (items.some((item) => item.kind === "marker"))
add("Activity stopped", CirclePause);
else add("Recorded usage", Gauge);
}
const values = [...categories.values()].sort((a, b) => a.order - b.order);
const join = (parts: string[]) =>
parts
.map((p, i) => (i ? p.charAt(0).toLowerCase() + p.slice(1) : p))
.join(", ");
const fullLabel = join(values.map((v) => v.label));
return {
label:
values.length > 3
? `${join(values.slice(0, 2).map((v) => v.label))}, and more`
: fullLabel,
fullLabel,
icon: values.length === 1 ? values[0].icon : Layers3,
};
}

View File

@ -630,6 +630,10 @@ export function DesignGuide() {
{ id: "design-runner-read", kind: "tool", name: "read", target: "TaskChatRunnerTurn.tsx", status: "completed", detail: "Found the activity groups." },
{ id: "design-runner-check", kind: "tool", name: "exec_command", target: "pnpm check:token-gates", status: "in_progress" },
] }} />
<TaskChatRunnerActivityGroup item={{ id: "design-runner-completed", kind: "activity_phase", active: false, summary: "", items: [
{ id: "design-completed-read", kind: "tool", name: "read", target: "TaskChatRunnerTurn.tsx", status: "completed", detail: "Read the activity groups." },
{ id: "design-completed-check", kind: "tool", name: "exec_command", target: "pnpm check:token-gates", status: "failed", detail: "A token check needs another pass." },
] }} />
</Section>
<Section title="Typography">

View File

@ -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 users 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 (
<div className="min-h-screen bg-background text-foreground">
<header className="flex flex-wrap items-center justify-between gap-3 border-b border-border px-6 py-3">
<div className="flex flex-col gap-1">
<h1 className="text-sm font-semibold">Completed activity</h1>
<p className="text-xs text-muted-foreground">
Completed activity · expand any summary to inspect its history
</p>
</div>
{mode === "live" && (
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
disabled={step >= 4}
onClick={() => setPlaying(!playing)}
>
{playing && step < 4 ? <Pause /> : <Play />}
{playing && step < 4 ? "Pause" : "Play"}
</Button>
<Button
variant="ghost"
size="sm"
disabled={step >= 4}
onClick={() => {
setPlaying(false);
setStep((s) => s + 1);
}}
>
<StepForward />
Next
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
setStep(0);
setReplay((r) => r + 1);
setPlaying(true);
}}
>
<RotateCcw />
Replay
</Button>
</div>
)}
</header>
<main
key={replay}
className={cn(
"mx-auto flex w-full flex-col gap-6 px-6 py-8",
narrow ? "max-w-sm" : "max-w-3xl",
)}
>
{mode === "gallery" ? (
scenarios.map((scenario) => (
<section key={scenario.id} className="flex min-w-0 flex-col gap-2">
<h2 className="text-sm font-medium">{scenario.title}</h2>
<p className="text-xs text-muted-foreground">{scenario.note}</p>
<CompletedActivityGroup
item={phase(scenario.id, scenario.items)}
defaultExpanded={expanded}
/>
</section>
))
) : mode === "live" ? (
<>
<p className="text-sm">
Ill read the activity component, then check that the preview is
reachable.
</p>
<CompletedActivityGroup
item={phase("live-group", liveItems, step < 3)}
defaultExpanded={expanded}
/>
{step >= 3 && (
<p className="text-sm">
The preview is reachable. The first request arrived before the
server was ready; the next one connected.
</p>
)}
{step >= 3 && (
<CompletedActivityGroup
item={phase(
"next-group",
[
{
...command,
status: step === 3 ? "in_progress" : "completed",
},
],
step === 3,
)}
defaultExpanded={expanded}
/>
)}
{step === 4 && (
<p className="text-sm">
Typecheck passed. The preview is ready for review.
</p>
)}
</>
) : (
<>
<p className="text-sm">
Ill check the activity renderer and the surrounding task layout.
</p>
<CompletedActivityGroup
item={phase("conversation-1", [thought, read, search])}
defaultExpanded={expanded}
/>
<p className="text-sm">
The grouping is already in place. Im updating how each group
reads after its work is done.
</p>
<CompletedActivityGroup
item={phase("conversation-2", [edit, command])}
defaultExpanded={expanded}
/>
<p className="text-sm">
The preview took a moment to start. Ill check the address again.
</p>
<CompletedActivityGroup
item={phase("conversation-3", [retry, recovered])}
defaultExpanded={expanded}
/>
<p className="text-sm">
The preview is ready. Completed groups now describe the work in a
few words, and you can expand any group for the full details.
</p>
</>
)}
</main>
</div>
);
}

View File

@ -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.

View File

@ -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.

View File

@ -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.",
}
: {}),
};

View File

@ -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<typeof CompletedActivityPreview>;
export default meta;
type Story = StoryObj<typeof meta>;
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" } };

View File

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