mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-07 06:16:06 -10:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Agents run on harnesses such as Codex and Claude, and the task chat shows what each run did > - Harnesses make automatic decisions: hooks block or allow tool calls, and Codex auto-review approves or denies actions > - Today Paperclip drops these decisions or shows them as generic rows. Codex `safety.review.*` always says `decision: "unknown"`, the native Rust runner drops `hook/*` and `item/autoApprovalReview/*`, and claude-agent-acp drops Claude hook messages > - An operator cannot see why an agent changed course or which action ran without a person's approval. This conflicts with Paperclip's approval-gate model > - This pull request records each hook and review decision with its reason and shows it as a small governance row in the task chat > - The benefit is that automatic decisions become auditable, and a blocked action explains itself without opening run logs ## Linked Issues or Issue Description Refs #19252 (precedent: canonical `plan.updated` events rendered as a task-chat card). Builds on #19259 (ACP `_meta` passthrough from ACPX), now merged. **Subsystem affected** Runner provider events (Rust native Codex normalizer, TS drivers, ACPX sidecar), the claude-agent-acp patch, the PRP provider-event schema, and the task chat activity UI. **Problem or motivation** Hook and auto-review outcomes reach Paperclip but are lost or flattened. Codex reviews always record an unknown decision. Native Codex runs drop hook and review notifications in the Rust normalizer. Claude hook messages never leave the adapter. Codex reuses one `run.id` for every call of a hook, and the id contains a host path. **Proposed solution** Map the real outcome, decision, action, risk and reason into additive optional fields on `paperclip.hook.v1` and `paperclip.safety.review.v1`. Forward Claude SDK hook messages. Render small grey rows: a stopped action is tinted and shows its reason, an approved review folds into the command it approved as an "auto-approved" mark, and routine hook passes stay out of activity summaries. **Alternatives considered** Separate rows for every approval. This duplicated each approved command, so approvals fold into the command row instead. ## What Changed - Runner (Rust): new `governance_events.rs`. `normalize_codex_notification` now maps `hook/started|completed` and `item/autoApprovalReview/started|completed`. Each hook call gets its own id (a digest of the run id and start time), so no host path is stored. - Runner (TS): new `governance-events.ts` with the same mapping for the TS Codex driver, and the Claude hook mapping. The ACPX sidecar publishes Claude hook updates as schema-validated `hook.*` rich events. - claude-agent-acp patch: turns on `includeHookEvents` and forwards `hook_started` / `hook_response` on `session_info_update._meta.claudeCode.hook`, with bounded output. - Protocol: additive optional fields. Hook records get `outcome`, `message`, `source` and `toolName`, and the `blocked` and `stopped` statuses. Review records get `actionKind`, `action`, `riskLevel`, `userConsent` (the reviewer's user-authorization level; keys that end in "authorization" are redacted as secrets), `rationale` and `decidedBy`, and the `timed_out` and `aborted` decisions. Regenerated the manifest and validators. - Fixed: the hook `blocking` flag read `executionMode === "blocking"`, but Codex sends `sync` / `async`. - UI: `native-run-events` keeps the full hook and review payloads. `governance-presentation.ts` writes the row labels. `fold-auto-reviews.ts` attaches an approved review to its command row. The rows show the tint, the reason line and the "auto-approved" mark. Activity summaries lead with a block or denial. - Storybook: "Task chat / Governance rows" shows every outcome, a Codex run, a Claude run and the live line. - Docs: added a governance activity section to `packages/paperclip-runner/docs/protocol-compatibility.md`. ## Verification - `pnpm --filter @paperclipai/paperclip-runner exec vitest run src/governance-events.test.ts src/provider-events.test.ts src/drivers/codex src/drivers/acpx src/cli`. 2305 tests pass. This includes a replay of real Codex 0.160 notifications from a session with hooks and auto-review (`test-fixtures/governance/codex-0.160-hooks-auto-review.json`, sanitized). - `cargo test --release -p paperclip-runner-core`. The full suite passes, and `governance_events::tests` replays the same fixture. - `cd ui && npx vitest run`. 11,047 tests pass. - `node scripts/typecheck-low-memory.mjs`, `pnpm check:token-gates`, and the runner contract checks (`check:protocol-manifest`, `check:protocol-types`, `check:capability-contract`, `check:protocol-coverage`, replay goldens, contract fingerprints) pass. - Live test on a local isolated instance. A Codex agent and a Claude agent each used a preconfigured home with a PreToolUse hook that blocks edits to `0001_init.sql`, plus UserPromptSubmit and PostToolUse hooks. Both runs showed "PreToolUse hook blocked a tool call" (Codex) or "PreToolUse hook blocked Bash" (Claude), with the hook's message. They also showed "UserPromptSubmit hook added context", and the group summary started with the block. - Auto-review cannot run in Paperclip today, because the native Codex runner only allows `approvalPolicy: never`. The review mapping and the approval fold are proven against real Codex 0.160 app-server output (fixture above) and in Storybook. ## Risks - Low risk to execution. These records are display-only and carry no authority. All new schema fields are optional, so older records stay valid and older UIs ignore the new fields. - The `auto-approved` fold depends on `targetItemId` matching the command's execution id. Real Codex 0.160 data confirms that it matches. When there is no match, the review stays as its own row. - In Paperclip's isolated homes, hooks do not run, because the runner never copies hooks into an isolated home. Rows appear only for agents with a preconfigured home, or with future managed hooks. - The claude-agent-acp patch changes. The ACPX profile check still passes, because the patch is Paperclip's ACPX layer and not the upstream release. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`) in Claude Code, with tool use (shell, browser and file editing) and extended reasoning. ## 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 - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [ ] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <[email protected]>
673 lines
24 KiB
TypeScript
673 lines
24 KiB
TypeScript
import { useId, useState, type ReactNode } from "react";
|
||
import { completedActivitySummary } from "./completed-activity-summary";
|
||
import {
|
||
Brain,
|
||
ChevronDown,
|
||
ChevronRight,
|
||
BookOpen,
|
||
CirclePause,
|
||
Gauge,
|
||
ShieldX,
|
||
} 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, TaskChatAutoReview } from "./task-chat-model";
|
||
import { AutoApprovedBadge, AutoReviewDetail } from "./TaskChatAutoReview";
|
||
import {
|
||
protocolActivityIsRunning,
|
||
protocolActivityLabel,
|
||
protocolActivityPresentation,
|
||
} from "./task-chat-activity-presentation";
|
||
import {
|
||
ResearchSourceStrip,
|
||
TaskChatProtocolActivityDetails,
|
||
WaitCountdown,
|
||
hasTaskChatProtocolActivityDetails,
|
||
verificationOutcome,
|
||
verificationTone,
|
||
} from "./TaskChatProtocolActivityRow";
|
||
import { TaskChatUsageReadout } from "./TaskChatUsageReadout";
|
||
import { TaskChatReasoningSummary } from "./TaskChatReasoningSummary";
|
||
import { latestReasoningHeadline } from "./reasoning-summary";
|
||
import { toolActivityPresentation, type ToolIcon } from "./tool-taxonomy";
|
||
import { useTaskChatShimmer } from "./TaskChatShimmer";
|
||
import { TaskChatDiffCounts, TaskChatDiffView } from "./TaskChatDiffView";
|
||
|
||
type Activity = TaskChatActivityPhaseItem["items"][number];
|
||
type ToolActivity = Extract<Activity, { kind: "tool" }>;
|
||
|
||
/** Families whose rows read as "<verb> <subject>", for example "Ran $ node --test". */
|
||
const SUBJECT_FAMILIES = new Set(["terminal", "read", "edit", "grep", "search", "web"]);
|
||
const READ_RUN_MIN = 3;
|
||
|
||
/** "120ms", "1.2s", "14s", "2m 03s". */
|
||
export function formatToolDuration(ms: number): string {
|
||
if (ms < 1_000) return `${Math.max(0, Math.round(ms))}ms`;
|
||
if (ms < 10_000) return `${(ms / 1_000).toFixed(1)}s`;
|
||
const seconds = Math.round(ms / 1_000);
|
||
if (seconds < 60) return `${seconds}s`;
|
||
return `${Math.floor(seconds / 60)}m ${String(seconds % 60).padStart(2, "0")}s`;
|
||
}
|
||
|
||
function toolPresentation(item: ToolActivity) {
|
||
// Classify by the native name only: a canonical operation is a weak guess
|
||
// for MCP tools (Claude reports most of them as "execute").
|
||
return toolActivityPresentation({
|
||
name: item.rawName ?? item.name,
|
||
target: item.target,
|
||
});
|
||
}
|
||
|
||
/** Only file reads fold; Skill, LSP and task lookups are also "read" family. */
|
||
const FILE_READ_TOOLS = new Set(["read", "notebookread", "notebook_read"]);
|
||
|
||
function isReadTool(item: Activity): item is ToolActivity {
|
||
return item.kind === "tool"
|
||
&& item.status === "completed"
|
||
&& !item.blockedReason
|
||
&& Boolean(item.target?.trim())
|
||
&& FILE_READ_TOOLS.has((item.rawName ?? item.name).trim().toLowerCase());
|
||
}
|
||
|
||
interface ActivityRowPresentation {
|
||
icon: ToolIcon;
|
||
label: string;
|
||
target?: string;
|
||
mono: boolean;
|
||
running: boolean;
|
||
/** Colors the leading icon, e.g. green or red for a verification check. */
|
||
iconClassName?: string;
|
||
/** Short plain-text outcome after the target, e.g. "6 tests passed". */
|
||
trailing?: ReactNode;
|
||
/** A governance row's reason, shown under the row when an action was stopped. */
|
||
note?: string;
|
||
/** Process exit code shown as a chip; null hides it. */
|
||
exitCode?: number | null;
|
||
/** Tool duration shown after the chip; null hides it. */
|
||
durationMs?: number | null;
|
||
/** Why a policy or rule refused the tool. */
|
||
blocked?: string;
|
||
}
|
||
|
||
function providerTrailing(item: Extract<Activity, { kind: "protocol" }>): Pick<ActivityRowPresentation, "iconClassName" | "trailing"> {
|
||
if (item.surface !== "provider_activity") return {};
|
||
if (item.family === "verification") {
|
||
return {
|
||
iconClassName: verificationTone(item),
|
||
trailing: verificationOutcome(item),
|
||
};
|
||
}
|
||
if (item.family === "wait" && item.status === "running") {
|
||
return { trailing: <WaitCountdown item={item} /> };
|
||
}
|
||
return {};
|
||
}
|
||
|
||
function presentation(item: Activity, active: boolean): ActivityRowPresentation | null {
|
||
if (item.kind === "tool") {
|
||
const tool = toolPresentation(item);
|
||
const running =
|
||
active && (item.status === "pending" || item.status === "in_progress");
|
||
const label =
|
||
item.status === "failed"
|
||
? tool.failedLabel
|
||
: item.status === "interrupted"
|
||
? tool.interruptedLabel
|
||
: running
|
||
? tool.runningLabel
|
||
: tool.completedLabel;
|
||
const terminal = tool.family === "terminal";
|
||
const subject = item.target?.trim();
|
||
// With a concrete subject, the verb alone reads better: "Read README.md".
|
||
// A finished command needs no verb at all: the icon and "$" say it ran.
|
||
// A failure or stop keeps its marker ("Read · failed") unless an exit
|
||
// chip already says how a command ended.
|
||
const marker = item.status === "failed" || item.status === "interrupted" ? label.split(" · ").at(1) : undefined;
|
||
const verb = subject && SUBJECT_FAMILIES.has(tool.family)
|
||
? terminal && !running && (item.status === "completed" || item.exitCode != null)
|
||
? ""
|
||
: `${label.split(" ")[0]!}${marker ? ` · ${marker}` : ""}`
|
||
: label;
|
||
// Exit 0 is only news for commands; for reads and searches it is noise.
|
||
const exitCode = item.exitCode ?? null;
|
||
const shownExit = exitCode === 0 && !terminal ? null : exitCode;
|
||
if (item.blockedReason) {
|
||
return {
|
||
icon: ShieldX,
|
||
label: "Blocked",
|
||
target: subject ? (terminal ? `$ ${subject}` : subject) : undefined,
|
||
mono: true,
|
||
running: false,
|
||
blocked: item.blockedReason,
|
||
};
|
||
}
|
||
return {
|
||
icon: tool.icon,
|
||
label: verb,
|
||
target: subject ? (terminal ? `$ ${subject}` : subject) : undefined,
|
||
mono: true,
|
||
running,
|
||
exitCode: running ? null : shownExit,
|
||
// Codex reports 0 for calls it did not time; 0ms is never informative.
|
||
durationMs: running || !item.durationMs ? null : item.durationMs,
|
||
};
|
||
}
|
||
if (item.kind === "thinking") {
|
||
const running = active && Boolean(item.streaming);
|
||
return {
|
||
icon: Brain,
|
||
label: running ? "Thinking" : "Thought",
|
||
target:
|
||
latestReasoningHeadline(item.lines.join("\n")) ??
|
||
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),
|
||
...providerTrailing(item),
|
||
...(protocol.tone === "blocked" || protocol.tone === "failed" ? { iconClassName: "text-destructive" } : {}),
|
||
note: protocol.note,
|
||
};
|
||
}
|
||
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 ActivityContent({
|
||
item,
|
||
active,
|
||
}: {
|
||
item: Activity;
|
||
active: boolean;
|
||
}) {
|
||
const row = presentation(item, active);
|
||
const shimmer = useTaskChatShimmer(item.id);
|
||
if (!row) return null;
|
||
const Icon = row.icon;
|
||
const autoReview = itemAutoReview(item);
|
||
const exitCode = "exitCode" in row ? row.exitCode : null;
|
||
const durationMs = "durationMs" in row ? row.durationMs : null;
|
||
const blocked = "blocked" in row ? row.blocked : undefined;
|
||
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={cn("size-3.5", row.iconClassName, blocked && "text-destructive")} aria-hidden="true" />
|
||
</span>
|
||
<span data-conversation-tool-running={row.running && shimmer && (item.kind === "tool" || (item.kind === "protocol" && item.surface === "provider_activity" && item.family === "tool_execution")) ? "true" : undefined} className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden whitespace-nowrap">
|
||
{row.label ? <span
|
||
className={cn(
|
||
"max-w-full shrink-0 truncate text-xs",
|
||
row.running && "text-foreground",
|
||
row.running && shimmer && item.kind === "thinking" && "shimmer-text",
|
||
)}
|
||
>
|
||
{row.label}
|
||
</span> : null}
|
||
{row.target ? (
|
||
<span
|
||
title={row.target}
|
||
className={cn(
|
||
"truncate text-xs text-muted-foreground",
|
||
row.mono && "font-mono",
|
||
)}
|
||
>
|
||
{row.target}
|
||
</span>
|
||
) : null}
|
||
{blocked ? (
|
||
<span title={blocked} className="min-w-0 truncate text-xs text-destructive" data-tool-blocked-reason>
|
||
{blocked}
|
||
</span>
|
||
) : null}
|
||
{row.trailing ? (
|
||
<span className="min-w-0 truncate text-xs text-muted-foreground" data-testid="task-chat-activity-trailing">
|
||
{row.trailing}
|
||
</span>
|
||
) : null}
|
||
</span>
|
||
{exitCode != null || durationMs != null ? (
|
||
<span className="flex shrink-0 items-center gap-2 text-(length:--text-micro) tabular-nums" data-tool-outcome>
|
||
{exitCode != null ? (
|
||
<span
|
||
className={cn(
|
||
"rounded-sm px-1 font-mono leading-4",
|
||
exitCode === 0 ? "bg-muted text-muted-foreground" : "bg-destructive/10 text-destructive",
|
||
)}
|
||
data-tool-exit-code={exitCode}
|
||
>
|
||
exit {exitCode}
|
||
</span>
|
||
) : null}
|
||
{durationMs != null ? (
|
||
<span className="text-muted-foreground" data-tool-duration>{formatToolDuration(durationMs)}</span>
|
||
) : null}
|
||
</span>
|
||
) : null}
|
||
{item.kind === "tool" && item.diff && (item.diff.added > 0 || item.diff.removed > 0) ? (
|
||
<TaskChatDiffCounts
|
||
added={item.diff.added}
|
||
removed={item.diff.removed}
|
||
className="text-xs"
|
||
/>
|
||
) : null}
|
||
{autoReview ? <AutoApprovedBadge review={autoReview} /> : null}
|
||
</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 itemAutoReview(item: Activity): TaskChatAutoReview | undefined {
|
||
if (item.kind === "tool") return item.autoReview;
|
||
if (item.kind === "protocol" && item.surface === "provider_activity") return item.autoReview;
|
||
return undefined;
|
||
}
|
||
|
||
function ActivityDetails({ item }: { item: Activity }) {
|
||
// Protocol rows render their review inside TaskChatProtocolActivityDetails.
|
||
const autoReview = item.kind === "tool" ? item.autoReview : undefined;
|
||
return (
|
||
<>
|
||
<ActivityDetailsBody item={item} />
|
||
{autoReview ? <AutoReviewDetail review={autoReview} /> : null}
|
||
</>
|
||
);
|
||
}
|
||
|
||
function ActivityDetailsBody({ item }: { item: Activity }) {
|
||
if (item.kind === "thinking")
|
||
return <TaskChatReasoningSummary text={item.lines.join("\n")} streaming={Boolean(item.streaming)} />;
|
||
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>
|
||
);
|
||
if (item.diff?.lines?.length)
|
||
return (
|
||
<TaskChatDiffView lines={item.diff.lines} truncated={item.diff.truncated} />
|
||
);
|
||
return (
|
||
<>
|
||
{item.description ? <p>{item.description}</p> : null}
|
||
{item.target ? (
|
||
<p className="break-all font-mono">{item.target}</p>
|
||
) : null}
|
||
{item.blockedReason ? <p className="text-destructive">Blocked: {item.blockedReason}</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 (itemAutoReview(item)) return true;
|
||
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 || item.blockedReason || item.description,
|
||
);
|
||
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} />;
|
||
const row = presentation(item, active);
|
||
const note = row?.note;
|
||
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>
|
||
)}
|
||
{note ? (
|
||
<p
|
||
className="-mt-1 mb-1 min-w-0 whitespace-pre-wrap break-words pl-7 text-xs text-muted-foreground"
|
||
data-testid="task-chat-governance-note"
|
||
>
|
||
{note}
|
||
</p>
|
||
) : null}
|
||
{!open && item.kind === "protocol" && item.surface === "provider_activity" && item.family === "research" && item.status !== "running" ? (
|
||
<ResearchSourceStrip item={item} />
|
||
) : null}
|
||
{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>
|
||
);
|
||
}
|
||
|
||
type HistoryRow = { kind: "single"; item: Activity; index: number } | { kind: "reads"; items: ToolActivity[]; index: number };
|
||
|
||
/** Fold runs of completed file reads into one row; everything else stays one row per activity. */
|
||
export function foldReadRuns(activities: Activity[]): HistoryRow[] {
|
||
const rows: HistoryRow[] = [];
|
||
for (let index = 0; index < activities.length; ) {
|
||
let end = index;
|
||
while (end < activities.length && isReadTool(activities[end]!)) end += 1;
|
||
if (end - index >= READ_RUN_MIN) {
|
||
rows.push({ kind: "reads", items: activities.slice(index, end) as ToolActivity[], index: end - 1 });
|
||
index = end;
|
||
} else {
|
||
rows.push({ kind: "single", item: activities[index]!, index });
|
||
index += 1;
|
||
}
|
||
}
|
||
return rows;
|
||
}
|
||
|
||
function readRunPreview(items: ToolActivity[]): string {
|
||
const names = items.flatMap((item) => item.target?.trim() ? [item.target.trim().split("/").at(-1)!] : []);
|
||
return names.length > 3 ? `${names.slice(0, 3).join(", ")} +${names.length - 3}` : names.join(", ");
|
||
}
|
||
|
||
function ReadRunActivity({ items }: { items: ToolActivity[] }) {
|
||
const id = `runner-reads:${items[0]!.id}`;
|
||
const [open, setOpen] = useTaskChatExpansion(id, false);
|
||
const listId = useId();
|
||
return (
|
||
<li className="min-w-0" data-activity-read-run={items.length}>
|
||
<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 ? listId : undefined}
|
||
>
|
||
<span className="flex min-w-0 flex-1 items-center gap-2">
|
||
<span className="flex size-5 shrink-0 items-center justify-center">
|
||
<BookOpen 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="shrink-0 text-xs">Read {items.length} files</span>
|
||
<span className="truncate font-mono text-xs text-muted-foreground">{readRunPreview(items)}</span>
|
||
</span>
|
||
</span>
|
||
<ChevronRight className={cn("size-3.5 shrink-0", open && "rotate-90")} aria-hidden="true" />
|
||
</button>
|
||
{open ? (
|
||
<ol id={listId} className="ml-2.5 flex min-w-0 flex-col border-l border-border pl-2" aria-label="Files read">
|
||
{items.map((item) => (
|
||
<ExpandedActivity key={item.id} item={item} active={false} />
|
||
))}
|
||
</ol>
|
||
) : 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 summary = completedActivitySummary(activities);
|
||
const SummaryIcon = summary.icon;
|
||
const countLabel = `${activities.length} ${activities.length === 1 ? "activity" : "activities"}`;
|
||
const editTotals = activities.reduce(
|
||
(sum, activity) =>
|
||
activity.kind === "tool" && activity.diff
|
||
? { added: sum.added + activity.diff.added, removed: sum.removed + activity.diff.removed }
|
||
: sum,
|
||
{ added: 0, removed: 0 },
|
||
);
|
||
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 px-1 text-sm text-foreground/90"
|
||
data-testid="task-chat-phase-interstitial"
|
||
data-conversation-text={item.interstitial.channel === "final" ? "final" : "progress"}
|
||
>
|
||
<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"} ${item.active ? countLabel : `${summary.fullLabel.toLowerCase()} (${countLabel})`}`}
|
||
>
|
||
{!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" />
|
||
</span>
|
||
<span>{countLabel}</span>
|
||
</span>
|
||
) : (
|
||
<RollingActivity item={latest} active={item.active} />
|
||
)}
|
||
<span className="flex shrink-0 items-center gap-1 text-xs">
|
||
{!item.active && (editTotals.added > 0 || editTotals.removed > 0) ? (
|
||
<TaskChatDiffCounts
|
||
added={editTotals.added}
|
||
removed={editTotals.removed}
|
||
className="mr-1"
|
||
/>
|
||
) : null}
|
||
{expanded ? countLabel : item.active ? activities.length : null}
|
||
<ChevronRight
|
||
className={cn("size-3.5", expanded && "rotate-90")}
|
||
aria-hidden="true"
|
||
/>
|
||
</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"
|
||
>
|
||
{foldReadRuns(activities).map((row) =>
|
||
row.kind === "reads" ? (
|
||
<ReadRunActivity key={row.items[0]!.id} items={row.items} />
|
||
) : (
|
||
<ExpandedActivity
|
||
key={row.item.id}
|
||
item={row.item}
|
||
active={item.active && row.index === activities.length - 1}
|
||
/>
|
||
),
|
||
)}
|
||
</ol>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
</section>
|
||
);
|
||
}
|