Files
PaperClipAI/ui/src/components/task-chat/TaskChatProtocolActivityRow.tsx
T
DottaandClaude Opus 5.5 2575b941d0 feat(task-chat): show hook and auto-review decisions as governance rows (#19266)
## 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 <noreply@anthropic.com>
2026-11-05 14:35:57 -06:00

463 lines
23 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 {
Check,
ChevronRight,
Circle,
ExternalLink,
Loader2,
X,
} from "lucide-react";
import { MarkdownBody } from "@/components/MarkdownBody";
import { WorkspaceFileLink } from "@/components/WorkspaceFileLink";
import { useSecondTick } from "@/hooks/useSecondTick";
import { cn } from "@/lib/utils";
import { useTaskChatShimmer } from "./TaskChatShimmer";
import { AutoApprovedBadge, AutoReviewDetail } from "./TaskChatAutoReview";
import type {
TaskChatProtocolDetail,
TaskChatProtocolItem,
TaskChatProtocolStep,
TaskChatProviderActivityItem,
TaskChatWorkspaceChangeItem,
TaskChatWorkspaceFileItem,
} from "./task-chat-model";
import {
durationLabel,
protocolActivityIsRunning,
protocolActivityLabel,
protocolActivityPresentation,
providerNoticeSeverity,
} from "./task-chat-activity-presentation";
const COMPACT_RESEARCH_RESULT_LIMIT = 5;
const COMPACT_WORKSPACE_FILE_LIMIT = 8;
const RESEARCH_SOURCE_PREVIEW_LIMIT = 3;
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 />;
}
export 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>
);
}
/** One-line outcome for a check: its detail, or why it did not run. */
export function verificationOutcome(item: TaskChatProviderActivityItem): string | undefined {
const detail = item.details.find((entry) => entry.label === "Detail")?.value;
if (detail) return detail;
const reason = item.details.find((entry) => entry.label === "Reason Code")?.value;
return reason ? reason.replaceAll("_", " ").replace(/^\w/, (letter) => letter.toUpperCase()) : undefined;
}
/** The full check: command and untruncated outcome, wrapped. */
function VerificationDetails({ item }: { item: TaskChatProviderActivityItem }) {
const outcome = verificationOutcome(item);
return (
<div className="flex min-w-0 flex-col gap-1.5" data-testid="task-chat-verification-details">
{item.summary ? <p className="min-w-0 break-all font-mono text-foreground">{item.summary}</p> : null}
{outcome ? <p className="min-w-0 whitespace-pre-wrap break-words">{outcome}</p> : null}
</div>
);
}
function detailContent(item: TaskChatProtocolItem, neutral = false): ReactNode | null {
switch (item.surface) {
case "provider_activity": {
if (item.family === "verification") return verificationOutcome(item) ? <VerificationDetails item={item} /> : null;
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 === "provider_activity" && item.autoReview) return true;
// A check expands only when it has an outcome that may not fit on one line.
if (item.surface === "provider_activity" && item.family === "verification") return detailContent(item) !== null;
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.family !== "verification" && item.summary ? <p className="whitespace-pre-wrap break-words">{item.summary}</p> : null}{detail}{item.surface === "provider_activity" && item.autoReview ? <AutoReviewDetail review={item.autoReview} /> : null}</>;
}
/** Source domains under a finished web search, so results are visible without expanding. */
export function ResearchSourceStrip({ item }: { item: TaskChatProviderActivityItem }) {
const seen = new Set<string>();
const sources = item.links.flatMap((link) => {
const host = sourceHostname(link.href);
if (seen.has(host)) return [];
seen.add(host);
return [{ ...link, host }];
});
if (sources.length === 0) return null;
const visible = sources.slice(0, RESEARCH_SOURCE_PREVIEW_LIMIT);
const hidden = item.links.length - visible.length;
return (
<div className="ml-7 flex min-w-0 flex-wrap items-center gap-1 pb-0.5" data-testid="task-chat-research-sources">
{visible.map((source) => (
<a
key={source.href}
className="inline-flex min-w-0 max-w-full items-center gap-1 rounded-full border border-border px-2 py-0.5 text-(length:--text-nano) text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground"
href={source.href}
target="_blank"
rel="noreferrer"
title={source.label}
>
<span className="truncate">{source.host}</span>
</a>
))}
{hidden > 0 ? <span className="text-(length:--text-nano) text-muted-foreground">+{hidden} more</span> : null}
</div>
);
}
/** Live "12s left" for an intentional wait with a planned duration. */
export function WaitCountdown({ item }: { item: TaskChatProviderActivityItem }) {
useSecondTick(true);
const planned = Number(item.details.find((detail) => detail.label === "Planned Duration Ms")?.value);
const startedAtMs = item.startedAt ? Date.parse(item.startedAt) : Number.NaN;
if (!Number.isFinite(planned) || !Number.isFinite(startedAtMs)) return null;
const remaining = Math.max(0, planned - (Date.now() - startedAtMs));
return <span className="shrink-0 tabular-nums" data-testid="task-chat-wait-countdown">{durationLabel(String(remaining))} left</span>;
}
export function verificationTone(item: TaskChatProviderActivityItem): string | undefined {
const status = item.details.find((detail) => detail.label === "Status")?.value;
if (status === "passed") return "text-(--status-task-icon-done)";
if (status === "failed") return "text-destructive";
return undefined;
}
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 shimmer = useTaskChatShimmer(item.id);
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 NoticeIcon = presentation.icon;
const severity = providerNoticeSeverity(item);
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">
<NoticeIcon className="size-(--sz-protocol-notice-icon) shrink-0" aria-hidden data-testid="task-chat-protocol-activity-icon" />
<span className="font-medium">{severity === "error" ? "Error" : severity === "info" ? "Provider update" : "Warning"}</span>
</div>
<p className="min-w-0 whitespace-pre-wrap break-words text-foreground">{summary}</p>
{item.details.some(detail => detail.label !== "Summary") ? (
<details className="text-muted-foreground">
<summary className="cursor-pointer">Details</summary>
<DetailList details={item.details.filter(detail => detail.label !== "Summary")} />
</details>
) : null}
</div>
);
}
const verification = item.surface === "provider_activity" && item.family === "verification" ? item : null;
const runningWait = item.surface === "provider_activity" && item.family === "wait" && item.status === "running" ? item : null;
const research = item.surface === "provider_activity" && item.family === "research" && item.status !== "running" ? item : null;
const detail = item.surface === "provider_activity" && item.autoReview
? <>{detailContent(item)}<AutoReviewDetail review={item.autoReview} /></>
: 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={cn("h-3.5 w-3.5 shrink-0", verification && verificationTone(verification), (presentation.tone === "blocked" || presentation.tone === "failed") && "text-destructive")} aria-hidden data-testid="task-chat-protocol-activity-icon" />
</span>
<span data-conversation-tool-running={active && shimmer && item.surface === "provider_activity" && item.family === "tool_execution" ? "true" : undefined} className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
<span className={cn("shrink-0 font-medium", active && shimmer && "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}
{verification && verificationOutcome(verification) ? (
<span className="min-w-0 shrink truncate" data-testid="task-chat-verification-detail">
{verificationOutcome(verification)}
</span>
) : null}
{runningWait ? <WaitCountdown item={runningWait} /> : null}
</span>
<span className="ml-auto flex shrink-0 items-center gap-2">
{item.surface === "provider_activity" && item.autoReview ? <AutoApprovedBadge review={item.autoReview} /> : null}
{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}
{verification ? 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}
{presentation.note ? (
<p className="min-w-0 whitespace-pre-wrap break-words pb-1 pl-7 text-muted-foreground" data-testid="task-chat-governance-note">
{presentation.note}
</p>
) : null}
{research && !open ? <ResearchSourceStrip item={research} /> : null}
{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>
);
}