mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 04:02:18 +01:00
## 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>
406 lines
12 KiB
TypeScript
406 lines
12 KiB
TypeScript
import { useId, useState } from "react";
|
||
import {
|
||
Brain,
|
||
ChevronDown,
|
||
ChevronRight,
|
||
CirclePause,
|
||
Gauge,
|
||
} from "lucide-react";
|
||
import { useReducedMotion } from "motion/react";
|
||
import { MarkdownBody } from "@/components/MarkdownBody";
|
||
import { cn } from "@/lib/utils";
|
||
import { useTaskChatExpansion } from "./expansion-state";
|
||
import type { TaskChatActivityPhaseItem } from "./task-chat-model";
|
||
import {
|
||
protocolActivityIsRunning,
|
||
protocolActivityLabel,
|
||
protocolActivityPresentation,
|
||
} from "./task-chat-activity-presentation";
|
||
import {
|
||
TaskChatProtocolActivityDetails,
|
||
hasTaskChatProtocolActivityDetails,
|
||
} from "./TaskChatProtocolActivityRow";
|
||
import { TaskChatUsageReadout } from "./TaskChatUsageReadout";
|
||
import { toolActivityPresentation } from "./tool-taxonomy";
|
||
|
||
type Activity = TaskChatActivityPhaseItem["items"][number];
|
||
|
||
function presentation(item: Activity, active: boolean) {
|
||
if (item.kind === "tool") {
|
||
const tool = toolActivityPresentation({
|
||
name: item.rawName ?? item.name,
|
||
target: item.target,
|
||
});
|
||
const running =
|
||
active && (item.status === "pending" || item.status === "in_progress");
|
||
return {
|
||
icon: tool.icon,
|
||
label:
|
||
item.status === "failed"
|
||
? tool.failedLabel
|
||
: item.status === "interrupted"
|
||
? tool.interruptedLabel
|
||
: running
|
||
? tool.runningLabel
|
||
: tool.completedLabel,
|
||
target: item.target,
|
||
mono: true,
|
||
running,
|
||
};
|
||
}
|
||
if (item.kind === "thinking") {
|
||
const running = active && Boolean(item.streaming);
|
||
return {
|
||
icon: Brain,
|
||
label: running ? "Thinking" : "Thought",
|
||
target: item.lines
|
||
.filter((line) => line.trim())
|
||
.at(-1)
|
||
?.trim(),
|
||
mono: false,
|
||
running,
|
||
};
|
||
}
|
||
if (item.kind === "protocol") {
|
||
const protocol = protocolActivityPresentation(item);
|
||
if (!protocol) return null;
|
||
return {
|
||
icon: protocol.icon,
|
||
label: protocolActivityLabel(item, protocol),
|
||
target: protocol.detail,
|
||
mono: true,
|
||
running: active && protocolActivityIsRunning(item),
|
||
};
|
||
}
|
||
if (item.kind === "marker")
|
||
return {
|
||
icon: CirclePause,
|
||
label: item.label,
|
||
target: item.detail,
|
||
mono: false,
|
||
running: false,
|
||
};
|
||
const { used, size, inputTokens, outputTokens, costUsd } = item.usage;
|
||
const usage = [
|
||
size > 0
|
||
? `${used.toLocaleString()}/${size.toLocaleString()} ctx`
|
||
: undefined,
|
||
inputTokens != null || outputTokens != null
|
||
? `↑${(inputTokens ?? 0).toLocaleString()} ↓${(outputTokens ?? 0).toLocaleString()}`
|
||
: undefined,
|
||
costUsd != null ? `$${costUsd.toFixed(4)}` : undefined,
|
||
]
|
||
.filter(Boolean)
|
||
.join(" · ");
|
||
return {
|
||
icon: Gauge,
|
||
label: item.label ?? "Token usage",
|
||
target: usage || item.detail,
|
||
mono: false,
|
||
running: false,
|
||
};
|
||
}
|
||
|
||
function isFailure(item: Activity) {
|
||
return (
|
||
(item.kind === "tool" && item.status === "failed") ||
|
||
(item.kind === "protocol" &&
|
||
item.surface === "provider_activity" &&
|
||
item.status === "failed") ||
|
||
(item.kind === "marker" && item.tone === "error")
|
||
);
|
||
}
|
||
|
||
function ActivityContent({
|
||
item,
|
||
active,
|
||
}: {
|
||
item: Activity;
|
||
active: boolean;
|
||
}) {
|
||
const row = presentation(item, active);
|
||
if (!row) return null;
|
||
const Icon = row.icon;
|
||
return (
|
||
<span
|
||
className="flex min-w-0 flex-1 items-center gap-2"
|
||
data-activity-row={item.id}
|
||
data-activity-family={
|
||
item.kind === "protocol"
|
||
? item.surface === "provider_activity"
|
||
? item.family
|
||
: item.surface
|
||
: undefined
|
||
}
|
||
>
|
||
<span
|
||
className="flex size-5 shrink-0 items-center justify-center"
|
||
data-activity-icon
|
||
>
|
||
<Icon className="size-3.5" aria-hidden="true" />
|
||
</span>
|
||
<span className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden whitespace-nowrap">
|
||
<span
|
||
className={cn(
|
||
"max-w-full shrink-0 truncate text-xs",
|
||
row.running && "text-foreground",
|
||
)}
|
||
>
|
||
{row.label}
|
||
</span>
|
||
{row.target ? (
|
||
<span
|
||
title={row.target}
|
||
className={cn(
|
||
"truncate text-xs text-muted-foreground",
|
||
row.mono && "font-mono",
|
||
)}
|
||
>
|
||
{row.target}
|
||
</span>
|
||
) : null}
|
||
</span>
|
||
</span>
|
||
);
|
||
}
|
||
|
||
/** Only a new logical activity moves; token and status updates stay mounted. */
|
||
function RollingActivity({
|
||
item,
|
||
active,
|
||
}: {
|
||
item: Activity;
|
||
active: boolean;
|
||
}) {
|
||
const reducedMotion = useReducedMotion();
|
||
const [frame, setFrame] = useState({
|
||
current: item,
|
||
previous: null as Activity | null,
|
||
});
|
||
if (frame.current.id !== item.id)
|
||
setFrame({ current: item, previous: reducedMotion ? null : frame.current });
|
||
else if (frame.current !== item) setFrame({ ...frame, current: item });
|
||
return (
|
||
<span
|
||
className="relative flex h-8 min-w-0 flex-1 items-center overflow-hidden"
|
||
aria-live="polite"
|
||
aria-atomic="true"
|
||
data-testid="task-chat-activity-viewport"
|
||
>
|
||
{frame.previous && !reducedMotion ? (
|
||
<span
|
||
key={`exit-${item.id}`}
|
||
className="runner-activity-roll-out absolute inset-0 flex items-center"
|
||
aria-hidden="true"
|
||
onAnimationEnd={() =>
|
||
setFrame((current) =>
|
||
current.current.id === item.id
|
||
? { ...current, previous: null }
|
||
: current,
|
||
)
|
||
}
|
||
>
|
||
<ActivityContent item={frame.previous} active={false} />
|
||
</span>
|
||
) : null}
|
||
<span
|
||
key={item.id}
|
||
className={cn(
|
||
"relative flex w-full min-w-0 items-center",
|
||
frame.previous && !reducedMotion && "runner-activity-roll-in",
|
||
)}
|
||
>
|
||
<ActivityContent item={item} active={active} />
|
||
</span>
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function ActivityDetails({ item }: { item: Activity }) {
|
||
if (item.kind === "thinking")
|
||
return <MarkdownBody softBreaks>{item.lines.join("\n")}</MarkdownBody>;
|
||
if (item.kind === "usage") return <TaskChatUsageReadout item={item} />;
|
||
if (item.kind === "protocol")
|
||
return <TaskChatProtocolActivityDetails item={item} neutral />;
|
||
if (item.kind === "marker")
|
||
return (
|
||
<p className="whitespace-pre-wrap break-words">
|
||
{item.detail ?? item.label}
|
||
</p>
|
||
);
|
||
return (
|
||
<>
|
||
{item.target ? (
|
||
<p className="break-all font-mono">{item.target}</p>
|
||
) : null}
|
||
{item.detail ? (
|
||
<pre className="whitespace-pre-wrap break-words font-mono">
|
||
{item.detail}
|
||
</pre>
|
||
) : null}
|
||
{item.decision ? <p>Permission {item.decision}</p> : null}
|
||
{item.diff ? (
|
||
<p className="break-all font-mono">
|
||
{item.diff.path} · +{item.diff.added} −{item.diff.removed}
|
||
</p>
|
||
) : null}
|
||
</>
|
||
);
|
||
}
|
||
|
||
function hasActivityDetails(item: Activity): boolean {
|
||
if (item.kind === "protocol") return hasTaskChatProtocolActivityDetails(item);
|
||
if (item.kind === "thinking") return item.lines.some((line) => line.trim());
|
||
if (item.kind === "tool")
|
||
return Boolean(
|
||
item.target?.trim() || item.detail?.trim() || item.diff || item.decision,
|
||
);
|
||
if (item.kind === "marker") return Boolean(item.detail?.trim());
|
||
return Boolean(
|
||
item.detail ||
|
||
item.usage.size > 0 ||
|
||
item.usage.inputTokens != null ||
|
||
item.usage.outputTokens != null ||
|
||
item.usage.costUsd != null,
|
||
);
|
||
}
|
||
|
||
function ExpandedActivity({
|
||
item,
|
||
active,
|
||
}: {
|
||
item: Activity;
|
||
active: boolean;
|
||
}) {
|
||
const [open, setOpen] = useTaskChatExpansion(
|
||
`runner-detail:${item.id}`,
|
||
false,
|
||
);
|
||
const detailId = useId();
|
||
const expandable = hasActivityDetails(item);
|
||
const content = <ActivityContent item={item} active={active} />;
|
||
return (
|
||
<li className="min-w-0" data-activity-item-id={item.id}>
|
||
{expandable ? (
|
||
<button
|
||
type="button"
|
||
className="flex h-8 w-full min-w-0 items-center gap-2 rounded-sm text-left text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||
onClick={() => setOpen(!open)}
|
||
aria-expanded={open}
|
||
aria-controls={open ? detailId : undefined}
|
||
>
|
||
{content}
|
||
<ChevronRight
|
||
className={cn("size-3.5 shrink-0", open && "rotate-90")}
|
||
aria-hidden="true"
|
||
/>
|
||
</button>
|
||
) : (
|
||
<div className="flex h-8 w-full min-w-0 items-center gap-2 text-muted-foreground">
|
||
{content}
|
||
</div>
|
||
)}
|
||
{expandable && open ? (
|
||
<div
|
||
id={detailId}
|
||
className="flex min-w-0 flex-col gap-2 overflow-hidden rounded-md bg-muted/40 p-3 text-xs leading-relaxed text-muted-foreground"
|
||
data-testid="task-chat-runner-activity-detail"
|
||
>
|
||
<ActivityDetails item={item} />
|
||
</div>
|
||
) : null}
|
||
</li>
|
||
);
|
||
}
|
||
|
||
/** One rolling activity between commentary messages, with persistent optional history. */
|
||
export function TaskChatRunnerActivityGroup({
|
||
item,
|
||
defaultExpanded = false,
|
||
}: {
|
||
item: TaskChatActivityPhaseItem;
|
||
defaultExpanded?: boolean;
|
||
}) {
|
||
const [expanded, setExpanded] = useTaskChatExpansion(
|
||
item.id,
|
||
defaultExpanded,
|
||
);
|
||
const historyId = useId();
|
||
const activities = item.items.filter(
|
||
(activity) => presentation(activity, false) !== null,
|
||
);
|
||
const latest = activities.at(-1);
|
||
const failures = activities.filter(isFailure).length;
|
||
const countLabel = `${activities.length} ${activities.length === 1 ? "activity" : "activities"}`;
|
||
return (
|
||
<section
|
||
className="flex min-w-0 flex-col gap-2"
|
||
data-testid="task-chat-activity-phase"
|
||
data-activity-group={item.id}
|
||
data-expanded={expanded}
|
||
>
|
||
{item.interstitial ? (
|
||
<div
|
||
className="min-w-0 text-sm text-foreground/90"
|
||
data-testid="task-chat-phase-interstitial"
|
||
>
|
||
<MarkdownBody softBreaks linkIssueReferences>
|
||
{item.interstitial.text}
|
||
</MarkdownBody>
|
||
</div>
|
||
) : null}
|
||
{latest ? (
|
||
<div className="min-w-0">
|
||
<button
|
||
type="button"
|
||
className="flex min-h-8 w-full min-w-0 items-center gap-2 rounded-sm text-left text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||
data-testid="task-chat-activity-phase-toggle"
|
||
onClick={() => setExpanded(!expanded)}
|
||
aria-expanded={expanded}
|
||
aria-controls={expanded ? historyId : undefined}
|
||
aria-label={`${expanded ? "Collapse" : "Expand"} ${countLabel}`}
|
||
>
|
||
{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" />
|
||
</span>
|
||
<span>{countLabel}</span>
|
||
</span>
|
||
) : (
|
||
<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}
|
||
</span>
|
||
</button>
|
||
{expanded ? (
|
||
<ol
|
||
id={historyId}
|
||
className="flex min-w-0 flex-col gap-1"
|
||
aria-label="Activity history"
|
||
data-testid="task-chat-runner-activity-list"
|
||
>
|
||
{activities.map((activity, index) => (
|
||
<ExpandedActivity
|
||
key={activity.id}
|
||
item={activity}
|
||
active={item.active && index === activities.length - 1}
|
||
/>
|
||
))}
|
||
</ol>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
</section>
|
||
);
|
||
}
|