Files
PaperClipAI/ui/src/components/task-chat/TaskChatProtocolActivityRow.tsx
T
DottaandPaperclip ce09ea40b0 feat(ui): show one rolling runner activity per commentary group (#13255)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Task chat shows commentary and runner activity as work proceeds.
> - The expanded activity list uses multiple text lines and an indented
rail.
> - The folded view can repeat reasoning and the current tool in
separate places.
> - This pull request shows one rolling activity between commentary
messages.
> - Users can open a group to follow its full history and inspect each
item.
> - The benefit is a compact feed with aligned icons and stable row
height.

## Linked Issues or Issue Description

**What existing behavior does this improve?**

The runner activity groups in task chat, during live work and in saved
history.

**Current behavior**

Activity is spread across a group summary, a reasoning ticker, and a
current-tool row. Expanded rows have different alignment and can span
several lines.

**Proposed behavior**

Keep one latest activity per commentary group. Roll new activities
upward. Keep each expanded history row on one line. Use neutral failure
text without an X icon. Keep the full details available by opening an
individual row.

**Additional context**

Refs: #13246. That open PR covers related folded activity, steering
timestamps, and status alignment. This change implements the separately
reviewed activity-group design, including animated replacement,
single-line expanded history, and neutral failures. It does not change
steering timestamps or status pills.

## What Changed

- Add a shared runner activity group with persistent expansion and
reduced-motion support.
- Use the group in the production runner turn and remove duplicate
narration and current-tool rows.
- Preserve commentary, plan cards, request receipts, and final-response
placement.
- Keep tool output, reasoning, research sources, file previews, and
failure details accessible.
- Render the production turn in desktop and mobile animated Storybook
fixtures.
- Add tests for animation identity, failure visibility, expansion
persistence, and timeline order.
- Validate resource link schemes and omit disclosure controls for items
with no details.

## Verification

- Focused activity, runner turn, protocol detail, and task-thread tests
pass: 161 tests.
- `pnpm check:token-gates` passes.
- Browser checks confirm a 32-pixel compact row, centered icons, and
one-line expanded rows.
- `pnpm -r typecheck` and `pnpm build` pass. The final UI build also
passes.
- Two real native Codex runs completed in an isolated local instance.
Each run created the expected file, performed separate delayed tool
calls, reported a deliberate missing-file failure, recovered, and
finished the task.
- Browser verification confirmed that the open three-row activity group
remained expanded after the second run moved into saved history. Saved
error output remained accessible without red styling or an X.
- The complete UI suite passes: 581 files and 5,979 tests.
- `pnpm --filter @paperclipai/ui build-storybook` passes for the final
production fixtures.
- Greptile rates the latest commit 5/5, with zero new comments and no
unresolved review threads. The security scan passes.
- All repository test shards pass in CI. The duplicate local `pnpm
test:run` was stopped after CI completed the full test coverage. The CI
build failed twice because the worker ran out of disk space compiling
the Rust runner tests (`No space left on device`, error 28). The build
and its aggregate verify gate remain blocked by CI capacity. GitHub
squash auto-merge is enabled and will wait for required checks to pass.

## Risks

- This changes presentation and disclosure behavior. Users open a group
to see earlier activity.
- Animation uses stable item IDs. A provider that replaces IDs can
trigger another transition.
- No API, database, or runner protocol contracts change.

> Reviewed `ROADMAP.md`. This improves an existing task-chat surface.

## Model Used

OpenAI GPT-6 through Codex, with reasoning, tool use, code execution,
and browser testing. The runtime does not expose a more specific model
version or context-window size.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-09-11 20:06:31 +00:00

358 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useId, useState, type ReactNode } from "react";
import {
AlertTriangle,
Check,
ChevronRight,
Circle,
ExternalLink,
Loader2,
X,
} from "lucide-react";
import { MarkdownBody } from "@/components/MarkdownBody";
import { WorkspaceFileLink } from "@/components/WorkspaceFileLink";
import { cn } from "@/lib/utils";
import type {
TaskChatProtocolDetail,
TaskChatProtocolItem,
TaskChatProtocolStep,
TaskChatProviderActivityItem,
TaskChatWorkspaceChangeItem,
TaskChatWorkspaceFileItem,
} from "./task-chat-model";
import {
protocolActivityIsRunning,
protocolActivityLabel,
protocolActivityPresentation,
} from "./task-chat-activity-presentation";
const COMPACT_RESEARCH_RESULT_LIMIT = 5;
const COMPACT_WORKSPACE_FILE_LIMIT = 8;
function StatusIcon({ status }: { status: "running" | "completed" | "failed" | "interrupted" | "informational" }) {
if (status === "running") return <Loader2 className="h-3.5 w-3.5 animate-spin text-(--status-agent-running)" aria-hidden />;
if (status === "failed" || status === "interrupted") return <X className="h-3.5 w-3.5 text-destructive" aria-hidden />;
if (status === "completed") return <Check className="h-3.5 w-3.5 text-(--status-task-icon-done)" aria-hidden />;
return <Circle className="h-3.5 w-3.5 text-muted-foreground" aria-hidden />;
}
function stepStatusIcon(status: TaskChatProtocolStep["status"], neutral = false) {
if (neutral && (status === "blocked" || status === "failed")) return <Circle className="h-3 w-3 text-muted-foreground" aria-hidden />;
if (status === "in_progress") return <Loader2 className="h-3 w-3 animate-spin text-(--status-agent-running)" aria-hidden />;
if (status === "completed") return <Check className="h-3 w-3 text-(--status-task-icon-done)" aria-hidden />;
if (status === "blocked" || status === "failed") return <X className="h-3 w-3 text-destructive" aria-hidden />;
return <Circle className="h-3 w-3 text-muted-foreground" aria-hidden />;
}
function DetailList({ details }: { details: readonly TaskChatProtocolDetail[] }) {
if (details.length === 0) return null;
return (
<dl className="flex min-w-0 flex-col gap-1.5" data-testid="task-chat-provider-detail-list">
{details.map((detail) => (
<div className="grid min-w-0 grid-cols-1 gap-0.5 sm:grid-cols-(--gtc-task-chat-details) sm:gap-3" key={`${detail.label}:${detail.value}`}>
<dt className="text-muted-foreground">{detail.label}</dt>
<dd className={cn("min-w-0 break-words text-foreground", detail.mono && "font-mono")}>{detail.value}</dd>
</div>
))}
</dl>
);
}
function sourceHostname(href: string): string {
try {
return new URL(href).hostname.replace(/^www\./, "");
} catch {
return href;
}
}
function ResearchDetails({ item }: { item: TaskChatProviderActivityItem }) {
const [showAllResults, setShowAllResults] = useState(false);
const query = item.details.find((detail) => detail.label.toLowerCase() === "query");
const additionalDetails = item.details.filter((detail) => !["action", "query", "status"].includes(detail.label.toLowerCase()));
const visibleLinks = showAllResults ? item.links : item.links.slice(0, COMPACT_RESEARCH_RESULT_LIMIT);
const hiddenResultCount = item.links.length - visibleLinks.length;
return (
<div className="flex min-w-0 flex-col gap-2.5">
{query ? (
<div className="min-w-0 rounded-sm bg-muted/40 px-2.5 py-2" data-testid="task-chat-research-query">
<p className="text-(length:--text-nano) font-medium uppercase tracking-wide text-muted-foreground">Query</p>
<p className="mt-0.5 min-w-0 break-words font-mono text-(length:--text-micro) text-foreground">{query.value}</p>
</div>
) : null}
{item.links.length > 0 ? (
<div className="min-w-0">
<p className="mb-1 text-(length:--text-nano) font-medium uppercase tracking-wide text-muted-foreground">
{item.links.length} {item.links.length === 1 ? "result" : "results"}
</p>
<ol className="divide-y divide-border/60" aria-label="Research sources">
{visibleLinks.map((link, index) => (
<li className="min-w-0 py-1.5 first:pt-0 last:pb-0" key={`${link.href}:${index}`}>
<a className="flex min-w-0 items-center gap-1 font-medium text-foreground hover:underline" href={link.href} target="_blank" rel="noreferrer">
<span className="truncate">{link.label}</span>
<ExternalLink className="h-3 w-3 shrink-0" aria-hidden />
</a>
<p className="mt-0.5 flex min-w-0 gap-2 text-muted-foreground">
<span className="shrink-0">{sourceHostname(link.href)}</span>
{link.description ? <span className="min-w-0 truncate">{link.description}</span> : null}
</p>
</li>
))}
</ol>
{hiddenResultCount > 0 ? (
<button
type="button"
className="mt-1.5 text-muted-foreground hover:text-foreground"
onClick={() => setShowAllResults(true)}
>
Show {hiddenResultCount} more {hiddenResultCount === 1 ? "result" : "results"}
</button>
) : showAllResults && item.links.length > COMPACT_RESEARCH_RESULT_LIMIT ? (
<button
type="button"
className="mt-1.5 text-muted-foreground hover:text-foreground"
onClick={() => setShowAllResults(false)}
>
Show fewer results
</button>
) : null}
</div>
) : null}
<DetailList details={additionalDetails} />
{item.output || item.outputTruncated ? (
<p className="text-muted-foreground">Additional provider output is available in Runner Inspector.</p>
) : null}
</div>
);
}
function ProviderDetails({ item, neutral = false }: { item: TaskChatProviderActivityItem; neutral?: boolean }) {
if (item.family === "research") return <ResearchDetails item={item} />;
return (
<div className="flex min-w-0 flex-col gap-2">
{item.steps.length > 0 ? (
<ol className="flex flex-col gap-1" aria-label="Plan steps">
{item.steps.map((step) => (
<li className="flex min-w-0 items-start gap-2" key={step.id}>
<span className="mt-0.5 shrink-0">{stepStatusIcon(step.status, neutral)}</span>
<span className={cn("min-w-0", step.status === "completed" && "text-muted-foreground line-through")}>{step.label}</span>
</li>
))}
</ol>
) : null}
{item.links.length > 0 ? (
<ul className="flex flex-col gap-1.5" aria-label="Research sources">
{item.links.map((link) => (
<li key={link.href}>
<a className="inline-flex min-w-0 items-center gap-1 font-medium text-primary hover:underline" href={link.href} target="_blank" rel="noreferrer">
<span className="truncate">{link.label}</span>
<ExternalLink className="h-3 w-3 shrink-0" aria-hidden />
</a>
{link.description ? <p className="mt-0.5 text-muted-foreground">{link.description}</p> : null}
</li>
))}
</ul>
) : null}
{item.children.length > 0 ? (
<ul className="flex flex-col gap-1.5" aria-label="Delegated agents">
{item.children.map((child) => (
<li className="flex min-w-0 flex-col gap-0.5" key={child.id}>
<span className="flex min-w-0 items-center gap-2">
<span className="font-medium text-foreground">{child.title}</span>
<span className="capitalize text-muted-foreground">{child.status}</span>
{child.metadata ? <span className="min-w-0 truncate font-mono text-(length:--text-micro)">{child.metadata}</span> : null}
</span>
{child.summary ? <span className="text-muted-foreground">{child.summary}</span> : null}
</li>
))}
</ul>
) : null}
{item.details.length > 0 ? (
<DetailList details={item.details} />
) : null}
{item.output ? (
<pre className="max-h-(--sz-64) overflow-auto whitespace-pre-wrap rounded-sm bg-muted/50 p-2 font-mono text-(length:--text-micro) text-foreground">{item.output}</pre>
) : null}
{item.outputTruncated ? <p className="text-muted-foreground">Output truncated to 8 KiB.</p> : null}
</div>
);
}
function WorkspaceChangeDetails({ item }: { item: TaskChatWorkspaceChangeItem }) {
if (item.files.length === 0) return <p className="text-muted-foreground">No changed-file details were reported.</p>;
const visibleFiles = item.files.slice(0, COMPACT_WORKSPACE_FILE_LIMIT);
const hiddenFileCount = item.files.length - visibleFiles.length;
return (
<div className="min-w-0" data-testid="task-chat-workspace-change-details">
<ul className="flex min-w-0 flex-col divide-y divide-border/60" aria-label="Changed files">
{visibleFiles.map((file) => (
<li className="flex min-w-0 items-center gap-2 py-1.5 first:pt-0 last:pb-0" key={`${file.operation}:${file.path}`}>
<span className="min-w-0 flex-1 truncate font-mono text-foreground" title={file.previousPath ? `${file.previousPath} → ${file.path}` : file.path}>
{file.previousPath ? `${file.previousPath} → ${file.path}` : file.path}
</span>
<span className="shrink-0 capitalize text-muted-foreground">{file.operation.replaceAll("_", " ")}</span>
{file.additions != null || file.deletions != null ? (
<span className="shrink-0 font-mono text-(length:--text-micro) text-muted-foreground">
{file.additions == null ? "" : `+${file.additions}`} {file.deletions == null ? "" : `−${file.deletions}`}
</span>
) : null}
</li>
))}
</ul>
{hiddenFileCount > 0 ? <p className="mt-1.5 text-muted-foreground">{hiddenFileCount} more {hiddenFileCount === 1 ? "file" : "files"} not shown</p> : null}
</div>
);
}
function WorkspaceFileDetails({ item }: { item: TaskChatWorkspaceFileItem }) {
const workspaceFileRef = {
path: item.path,
resourceKind: "file" as const,
line: item.line,
column: null,
raw: item.path,
};
return (
<div className="flex min-w-0 flex-col gap-2">
<WorkspaceFileLink workspaceFileRef={workspaceFileRef} label={item.path} />
{item.preview ? (
item.presentation === "document" ? (
<div className="max-h-(--sz-64) overflow-auto rounded-sm bg-muted/30 p-2 text-foreground"><MarkdownBody>{item.preview}</MarkdownBody></div>
) : (
<pre className="max-h-(--sz-64) overflow-auto whitespace-pre-wrap rounded-sm bg-muted/50 p-2 font-mono text-(length:--text-micro) text-foreground">{item.preview}</pre>
)
) : <p className="text-muted-foreground">Preview unavailable.</p>}
{item.previewTruncated ? <p className="text-muted-foreground">Preview truncated by the runner.</p> : null}
</div>
);
}
function detailContent(item: TaskChatProtocolItem, neutral = false): ReactNode | null {
switch (item.surface) {
case "provider_activity": {
const expandable = item.details.length > 0 || item.steps.length > 0 || item.links.length > 0 || item.children.length > 0 || Boolean(item.output) || Boolean(item.outputTruncated);
return expandable ? <ProviderDetails item={item} neutral={neutral} /> : null;
}
case "workspace_change": return <WorkspaceChangeDetails item={item} />;
case "workspace_file": return <WorkspaceFileDetails item={item} />;
case "resource":
case "runtime_request":
case "run_result":
case "run_terminal":
return null;
}
}
function safeActivityHref(value: string | null | undefined): string | undefined {
if (!value) return undefined;
try {
// A fixed HTTPS base accepts relative app paths and fragments as well as
// external HTTP(S) resources, but never executable or local-file schemes.
const url = new URL(value, "https://paperclip.invalid");
return url.protocol === "https:" || url.protocol === "http:" ? value : undefined;
} catch {
return undefined;
}
}
export function hasTaskChatProtocolActivityDetails(item: TaskChatProtocolItem): boolean {
if (item.surface === "resource") return Boolean(safeActivityHref(item.href));
return detailContent(item) !== null || (item.surface === "provider_activity" && Boolean(item.summary?.trim()));
}
export function TaskChatProtocolActivityDetails({ item, neutral = false }: { item: TaskChatProtocolItem; neutral?: boolean }) {
const detail = detailContent(item, neutral);
if (item.surface === "resource") {
const href = safeActivityHref(item.href);
return href ? <a href={href} className="text-foreground underline">{item.title}</a> : <p>{item.title}</p>;
}
return <>{item.surface === "provider_activity" && item.summary ? <p className="whitespace-pre-wrap break-words">{item.summary}</p> : null}{detail}</>;
}
function itemStatus(item: TaskChatProtocolItem): "running" | "completed" | "failed" | "interrupted" | "informational" {
if (item.surface === "provider_activity") return item.status;
if (item.surface === "workspace_change") return item.complete ? "completed" : "running";
return "completed";
}
export function TaskChatProtocolActivityRow({ item }: { item: TaskChatProtocolItem }) {
const [open, setOpen] = useState(false);
const detailId = `task-chat-protocol-activity-${useId().replaceAll(":", "")}`;
const presentation = protocolActivityPresentation(item);
if (!presentation) return null;
if (item.surface === "provider_activity" && item.family === "provider_notice") {
const summary = item.summary
?? item.details.find((entry) => entry.label === "Summary")?.value
?? "The provider reported a notice without a message.";
return (
<div className="flex min-w-0 flex-col gap-1.5 py-1 text-xs" data-testid="task-chat-protocol-activity-row" data-activity-family="provider_notice">
<div className="flex items-center gap-2 text-muted-foreground">
<AlertTriangle className="h-3.5 w-3.5 shrink-0" aria-hidden data-testid="task-chat-protocol-activity-icon" />
<span className="font-medium">{item.status === "failed" ? "Error" : "Warning"}</span>
</div>
<p className="min-w-0 whitespace-pre-wrap break-words text-foreground">{summary}</p>
</div>
);
}
const detail = detailContent(item);
const expandable = detail !== null;
const Icon = presentation.icon;
const label = protocolActivityLabel(item, presentation);
const active = protocolActivityIsRunning(item);
const status = itemStatus(item);
const row = (
<>
<span className="flex w-5 shrink-0 items-center justify-center">
<Icon className="h-3.5 w-3.5 shrink-0" aria-hidden data-testid="task-chat-protocol-activity-icon" />
</span>
<span className={cn("shrink-0 font-medium", active && "shimmer-text shimmer-text-muted")}>{label}{active ? "…" : ""}</span>
{presentation.detail ? <span className="task-chat-collapsed-line-fade min-w-0 flex-1 font-mono text-(length:--text-micro)">{presentation.detail}</span> : null}
<span className="ml-auto flex shrink-0 items-center gap-2">
{expandable ? (
<ChevronRight
className={cn("tc-hover-disclosure-caret h-3 w-3 transition-[opacity,transform] group-hover/activity:opacity-80 group-focus-visible/activity:opacity-80", open ? "rotate-90 opacity-80" : "opacity-0")}
aria-hidden
/>
) : null}
<StatusIcon status={status} />
</span>
</>
);
const resourceHref = item.surface === "resource" ? safeActivityHref(item.href) : undefined;
if (resourceHref) {
return (
<a className="group/activity -mx-1.5 flex min-h-6 w-full min-w-0 max-w-full items-center gap-2 overflow-hidden rounded-sm px-1.5 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground" href={resourceHref} data-testid="task-chat-protocol-activity-row">
{row}
</a>
);
}
const activityRow = expandable ? (
<button
type="button"
onClick={() => setOpen((value) => !value)}
aria-expanded={open}
aria-controls={detailId}
className="group/activity -mx-1.5 flex min-h-6 w-full min-w-0 max-w-full items-center gap-2 overflow-hidden rounded-sm px-1.5 py-1 text-left text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{row}
</button>
) : (
<div className="group/activity -mx-1.5 flex min-h-6 w-full min-w-0 max-w-full items-center gap-2 overflow-hidden rounded-sm px-1.5 py-1 text-muted-foreground">
{row}
</div>
);
return (
<div className="flex min-w-0 flex-col text-xs" data-testid="task-chat-protocol-activity-row" data-activity-family={item.surface === "provider_activity" ? item.family : item.surface}>
{activityRow}
{open && detail ? (
<div id={detailId} className="ml-2.5 mt-0.5 border-l-2 border-border py-1 pl-2.5" data-testid="task-chat-protocol-activity-detail">
{detail}
</div>
) : null}
</div>
);
}