Files
PaperClipAI/ui/src/components/task-chat/TaskChatProtocolCard.tsx
T
DottaandClaude Opus 5.5 22281d4b3a feat(task-chat): approve harness-proposed plans (Codex plan mode, Claude ExitPlanMode) (#19291)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Agents run through the native runner. Codex and Claude Code each
have their own plan mode, and each produces a proposed-plan document.
> - Paperclip already has a planning work mode, an issue `plan`
document, and a `request_confirmation` approval gate for plans.
> - These parts were not connected. The Codex proposed-plan item was
transcript text only. The server path never put Codex into plan mode.
Claude was never put into plan mode, and its ExitPlanMode plan was
dropped. A planning run could close its task as done with no approval.
> - This pull request turns a harness's own proposed plan into the
issue's Plan document and the existing approval gate, for Codex and
Claude.
> - The benefit is that users approve or send back the agent's real plan
from the task chat. Approve resumes the agent. Request changes sends the
reason back to the agent.

## Linked Issues or Issue Description

Refs #19252

**Subsystem affected**
Native runner provider events (Codex and ACPX Claude), the runner Codex
provider (Rust), server native plan synchronization, and the task chat
UI.

**Problem or motivation**
Codex and Claude both produce a proposed-plan document in their native
plan mode. Paperclip did not use it. In live tests on the server path, a
Codex planning run was not in Codex plan mode, saved its own
`implementation-plan` document, and marked the task done without
approval. Claude planning runs were not in Claude plan mode, and the
ExitPlanMode plan text never reached Paperclip.

**Proposed solution**
Add a canonical `plan.proposed` event for a harness's proposed plan. Put
Codex and Claude into their native plan mode for planning runs. Save a
completed proposal as the issue `plan` document with the existing
`request_confirmation` gate. Agents can also propose a plan on a
standard task; the work mode stays standard.

**Alternatives considered**
Reuse `plan.updated`. Its schema is the within-turn checklist and is not
the durable Plan document, so a separate event keeps the two clear.
Stronger prompt text without native plan mode. Live tests showed that
the model kept its own document workflow.

**Roadmap alignment**
This reuses the issue plan document, planning work mode and approval
gate that already exist. It adds one canonical provider event.

## What Changed

- `packages/paperclip-runner/protocol/schemas/*`: add the
`plan.proposed` event (`paperclip.plan.proposed.v1`: `proposalId`,
`status`, `markdown` up to 60k characters, `source`). Regenerate the
schema bundle, validators and manifest.
- `packages/paperclip-runner/src/provider-events.ts`: map the Codex
`plan` item and a `<proposed_plan>` block in a completed Codex message
to `plan.proposed`. Add `claudePlanProposalEvent`.
-
`packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs`:
send Codex's native `collaborationMode: plan` on `turn/start` for a
read-only (planning) Codex run. Before this change the server path never
sent it.
- `runner-core/src/acpx_event_payload.rs`: admit `plan.proposed` as ACPX
rich display activity.
- `drivers/acpx/codex-runtime-adapter.ts`: set Claude Code to native
plan mode for a read-only run. Capture Claude's ExitPlanMode plan from
its permission request, record it, and decline the native "Ready to
code?" prompt.
- `drivers/acpx/codex-acpx-driver.ts`, `cli/acpx-runtime-sidecar.ts`:
emit the captured proposal as a rich event. Complete that turn instead
of reporting it as cancelled.
- `backends/codex-native-backend.ts`: per-harness plan-mode
instructions, and a short opt-in hint for Claude on standard tasks.
- `server/src/services/native-runtime/native-session-executor.ts`:
synchronize a completed `plan.proposed` into the `plan` document and a
`request_confirmation` in planning and standard mode. Do not change the
issue status during the run (that revoked the run's lease). The run
finalizer turns the pending confirmation into the review wait.
- `server/src/services/heartbeat-run-summary.ts`: keep a
`<proposed_plan>` block out of the posted reply.
- UI: new `plan_proposal` provider family. The task chat shows a live
"Writing plan" card while a proposal streams and a one-line record when
it completes. Resolved plan reviews keep a receipt ("Approved Plan v1",
"Requested changes to Plan v1: reason"). Plan previews skip section
headings.
- Storybook: `Tasks and conversations/.../Task chat/Proposed plan
approval` shows the journey.
- `doc/SPEC-implementation.md`: describe harness-proposed plan approval.

## Verification

- `cargo test -p paperclip-runner-core --lib` (new:
`read_only_codex_turns_use_native_plan_collaboration_mode`)
- `cd packages/paperclip-runner && npx vitest run
src/provider-events.test.ts
src/drivers/acpx/codex-runtime-adapter.test.ts`
- `cd server && npx vitest run
src/__tests__/native-plan-proposal-sync.test.ts
src/__tests__/heartbeat-run-summary.test.ts`
- `cd ui && npx vitest run
src/components/transcript/native-run-events.test.ts
src/components/task-chat`
- Live test on a local worktree instance with real harnesses:
- Codex (`gpt-5.6-sol`), planning task: Codex asked one question, then
produced a native plan item. The task showed Plan v1 with Approve plan /
Request changes and moved to in review. Approve changed the work mode to
standard. Codex then implemented the plan and its tests passed.
- Codex, Request changes with a reason: Codex woke in planning mode and
produced Plan v2 that includes the change.
- Claude (`claude-opus-5-5`), planning task: Claude was in plan mode.
ExitPlanMode became Plan v1 and the turn completed. Approve resumed
Claude in standard mode, and Claude implemented the plan.
- Claude, standard task that asks for a plan first: Claude stopped
before code changes and asked for plan approval. The work mode stayed
standard.
- Storybook: open `Proposed plan approval` and check each stage.

## Risks

- Codex planning runs now really use Codex plan mode on the server path.
Codex can ask a clarifying question before it proposes. This matches
Codex plan mode.
- Claude planning runs now use Claude plan mode. A declined ExitPlanMode
ends the Claude turn; the runner reports it as completed. Claude's tool
row for that request records a rejection from Claude's side.
- Codex does not stream the proposed plan on the server path yet, so the
live card shows a placeholder until the plan completes.
- A `<proposed_plan>` block in any completed Codex message on a standard
task now asks for approval. This is the documented opt-in path.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- Anthropic Claude Opus 5.5 (`claude-opus-5-5`), via Claude Code with
tool use (shell, file edits, browser). Long context. No extended
thinking mode was used.

## 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 20:53:33 +00:00

1184 lines
39 KiB
TypeScript

import { useContext, useEffect, useMemo, useState } from "react";
import { TaskChatStatusNotice } from "./TaskChatStatusNotice";
import { isStatusNotice } from "./status-notice-presentation";
import {
AlertTriangle,
Bot,
Check,
ChevronDown,
ChevronRight,
Circle,
CircleCheck,
CircleHelp,
Clock3,
ExternalLink,
FileCode2,
FileText,
GitBranch,
Loader2,
PackageCheck,
ShieldCheck,
TerminalSquare,
X,
} from "lucide-react";
import { MarkdownBody } from "@/components/MarkdownBody";
import type { MentionOption } from "@/components/MarkdownEditor";
import { WorkspaceFileLink } from "@/components/WorkspaceFileLink";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import {
clearDraft,
loadStructuredDraft,
saveStructuredDraft,
} from "@/lib/composer-draft";
import type {
TaskChatProtocolItem,
TaskChatProtocolStep,
TaskChatProviderActivityItem,
TaskChatRuntimeRequestDecision,
TaskChatRuntimeRequestItem,
TaskChatWorkspaceChangeItem,
TaskChatWorkspaceFileItem,
} from "./task-chat-model";
import { QuestionForm, QuestionResponseSummary } from "./QuestionForm";
import { TaskChatComposerTakeoverHeader, TaskChatRequestHistoryContext } from "./TaskChatComposerTakeoverContext";
import { RichWorkProductCard } from "./RichWorkProductCard";
import { planPreviewContent, TaskChatPlanPreviewCard } from "./TaskChatPlanPreviewCard";
import { TaskChatDiffCounts, TaskChatDiffView } from "./TaskChatDiffView";
import { parseUnifiedDiffLines } from "./diff-lines";
import { useArtifactsV1Enabled } from "@/hooks/useArtifactsV1Enabled";
import { ArtifactFileRow, ArtifactRow, fileRowSourceFromAttachment, fileRowSourceFromWorkProduct, subjectFromAttachment, subjectFromDocumentSummary, subjectFromWorkProduct, workProductStateBadge } from "@/components/artifacts/v1";
export interface TaskChatProtocolCardProps {
item: TaskChatProtocolItem;
presentation?: "timeline" | "takeover";
draftKey?: string;
imageUploadHandler?: (file: File) => Promise<string>;
mentions?: MentionOption[];
onRuntimeRequestDecision?: (
item: TaskChatRuntimeRequestItem,
decision: TaskChatRuntimeRequestDecision,
) => void | Promise<void>;
}
const FAMILY_ICON = {
plan: GitBranch,
plan_proposal: FileText,
tool_execution: TerminalSquare,
research: FileText,
delegation: Bot,
model_identity: Bot,
context: PackageCheck,
artifact: PackageCheck,
review: ShieldCheck,
hook: GitBranch,
memory: FileText,
safety: ShieldCheck,
terminal: TerminalSquare,
wait: Clock3,
verification: CircleCheck,
provider_notice: AlertTriangle,
} as const;
function statusTone(status: string): string {
if (
["failed", "blocked", "denied", "cancelled", "interrupted"].includes(status)
)
return "text-destructive";
if (["running", "pending", "in_progress", "waiting"].includes(status))
return "text-(--status-agent-running)";
if (["completed", "done", "succeeded", "passed", "resolved"].includes(status))
return "text-(--status-task-icon-done)";
return "text-muted-foreground";
}
function StatusIcon({
status,
className,
}: {
status: string;
className?: string;
}) {
if (["running", "pending", "in_progress", "waiting"].includes(status)) {
return (
<Loader2
aria-hidden
className={cn("h-4 w-4 animate-spin", statusTone(status), className)}
/>
);
}
if (
["failed", "blocked", "denied", "cancelled", "interrupted"].includes(status)
) {
return (
<X aria-hidden className={cn("h-4 w-4", statusTone(status), className)} />
);
}
if (
["completed", "done", "succeeded", "passed", "resolved"].includes(status)
) {
return (
<Check
aria-hidden
className={cn("h-4 w-4", statusTone(status), className)}
/>
);
}
return (
<Circle
aria-hidden
className={cn("h-4 w-4", statusTone(status), className)}
/>
);
}
function CardShell({
icon: Icon,
title,
status,
summary,
children,
testId,
presentation = "timeline",
}: {
icon: typeof Circle;
title: string;
status: string | null;
summary?: string;
children?: React.ReactNode;
testId: string;
presentation?: "timeline" | "takeover";
}) {
const titleRow = (
<div className="flex min-w-0 items-center gap-2">
<Icon aria-hidden className="h-4 w-4 shrink-0 text-muted-foreground" />
<strong className="min-w-0 truncate text-sm font-medium text-foreground">
{title}
</strong>
{status && status !== "pending" ? (
<span
className={cn(
"inline-flex shrink-0 items-center gap-1 text-xs capitalize",
statusTone(status),
)}
>
<StatusIcon status={status} className="h-3.5 w-3.5" />
{status.replaceAll("_", " ")}
</span>
) : null}
</div>
);
if (presentation === "takeover") {
return (
<article className="overflow-hidden" data-testid={testId}>
<TaskChatComposerTakeoverHeader>
{titleRow}
</TaskChatComposerTakeoverHeader>
{summary ? (
<p
className={cn(
"text-sm text-muted-foreground",
Boolean(children) && "mb-3",
)}
>
{summary}
</p>
) : null}
{children}
</article>
);
}
return (
<article
className={cn(
"overflow-hidden",
presentation === "timeline" &&
"rounded-md border border-border bg-card/60",
)}
data-testid={testId}
>
<header className="flex min-w-0 items-start gap-2 px-3 py-2.5">
<Icon
aria-hidden
className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground"
/>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
<strong className="text-sm font-medium text-foreground">
{title}
</strong>
{status ? (
<span
className={cn(
"inline-flex items-center gap-1 text-xs capitalize",
statusTone(status),
)}
>
<StatusIcon status={status} className="h-3.5 w-3.5" />
{status.replaceAll("_", " ")}
</span>
) : null}
</div>
{summary ? (
<p className="mt-1 text-sm text-muted-foreground">{summary}</p>
) : null}
</div>
</header>
{children ? (
<div className="border-t border-border/70 px-3 py-2.5">{children}</div>
) : null}
</article>
);
}
function PlanSteps({ steps }: { steps: TaskChatProtocolStep[] }) {
if (steps.length === 0) return null;
return (
<ol className="flex flex-col gap-1.5" aria-label="Plan steps">
{steps.map((step) => (
<li key={step.id} className="flex items-start gap-2 text-sm">
<StatusIcon status={step.status} className="mt-0.5 shrink-0" />
<span
className={cn(
"min-w-0 flex-1",
step.status === "completed" &&
"text-muted-foreground line-through",
)}
>
{step.label}
</span>
</li>
))}
</ol>
);
}
/**
* A harness's proposed plan. While it streams it is the live plan card; once
* complete, the synchronized Plan document and its approval card carry it, so
* the turn keeps only a one-line record.
*/
function PlanProposalCard({ item }: { item: TaskChatProviderActivityItem }) {
if (item.status === "running") {
return <TaskChatPlanPreviewCard source={{ kind: "live", activity: item }} testId="task-chat-plan-proposal" />;
}
const title = item.output ? planPreviewContent(item.output).title : "Plan";
// The saved Plan document normally carries the proposal, but a conflicting
// edit can keep it out. The proposal itself stays readable here.
return (
<details className="group" data-testid="task-chat-plan-proposal">
<summary className="flex cursor-pointer list-none items-center gap-2 rounded-sm px-1 py-1 text-sm text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<FileText aria-hidden className="h-3.5 w-3.5 shrink-0" />
<span className="min-w-0 truncate">Proposed a plan · <span className="text-foreground">{title}</span></span>
</summary>
{item.output ? (
<div className="mt-1 rounded-md border border-border bg-muted/25 px-4 py-3 text-sm" data-testid="task-chat-plan-proposal-body">
<MarkdownBody mediaMode="reference">{item.output}</MarkdownBody>
</div>
) : null}
</details>
);
}
function ProviderActivityCard({
item,
}: {
item: TaskChatProviderActivityItem;
}) {
if (item.family === "plan_proposal") return <PlanProposalCard item={item} />;
const Icon = FAMILY_ICON[item.family];
const hasDetails =
item.details.length > 0 ||
item.links.length > 0 ||
item.children.length > 0 ||
Boolean(item.output);
return (
<CardShell
icon={Icon}
title={item.title}
status={item.status}
summary={item.summary}
testId={`task-chat-provider-${item.family}`}
>
{item.steps.length > 0 ? <PlanSteps steps={item.steps} /> : null}
{item.children.length > 0 ? (
<ul className="flex flex-col gap-2" aria-label="Delegated agents">
{item.children.map((child) => (
<li
key={child.id}
className="rounded-sm bg-muted/40 px-2.5 py-2 text-sm"
>
<div className="flex items-center gap-2">
<StatusIcon status={child.status} className="shrink-0" />
<strong className="font-medium">{child.title}</strong>
<span
className={cn(
"ml-auto text-xs capitalize",
statusTone(child.status),
)}
>
{child.status}
</span>
</div>
{child.metadata ? (
<p className="mt-1 text-xs text-muted-foreground">
{child.metadata}
</p>
) : null}
{child.summary ? (
<p className="mt-1 text-sm text-muted-foreground">
{child.summary}
</p>
) : null}
</li>
))}
</ul>
) : null}
{item.links.length > 0 ? (
<ul className="flex flex-col gap-2" aria-label="Research sources">
{item.links.map((link) => (
<li key={link.href}>
<a
className="inline-flex items-center gap-1 text-sm font-medium text-primary hover:underline"
href={link.href}
target="_blank"
rel="noreferrer"
>
{link.label}
<ExternalLink aria-hidden className="h-3.5 w-3.5" />
</a>
{link.description ? (
<p className="text-xs text-muted-foreground">
{link.description}
</p>
) : null}
</li>
))}
</ul>
) : null}
{hasDetails ? (
<details
className={cn(
(item.steps.length > 0 ||
item.links.length > 0 ||
item.children.length > 0) &&
"mt-2",
)}
>
<summary className="flex cursor-pointer list-none items-center gap-1 text-xs font-medium text-muted-foreground">
<ChevronDown aria-hidden className="h-3.5 w-3.5" /> Details
</summary>
{item.details.length > 0 ? (
<dl className="mt-2 flex min-w-0 flex-col gap-1.5 text-xs">
{item.details.map((detail) => (
<div
key={`${detail.label}:${detail.value}`}
className="grid min-w-0 grid-cols-1 gap-0.5 sm:grid-cols-(--gtc-task-chat-details) sm:gap-3"
>
<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>
) : null}
{item.output ? (
<pre className="mt-2 max-h-(--sz-64) overflow-auto whitespace-pre-wrap rounded-sm bg-muted/50 p-2 font-mono text-xs text-foreground">
{item.output}
</pre>
) : null}
{item.outputTruncated ? (
<p className="mt-1 text-xs text-muted-foreground">
Output truncated to 8 KiB.
</p>
) : null}
</details>
) : null}
</CardShell>
);
}
function WorkspaceChangeFileRow({
file,
open,
onToggle,
}: {
file: TaskChatWorkspaceChangeItem["files"][number];
open: boolean;
onToggle: () => void;
}) {
const parsed = useMemo(
() => (file.diff ? parseUnifiedDiffLines(file.diff) : null),
[file.diff],
);
const expandable = file.binary || Boolean(parsed?.lines.length) || file.diffTruncated === true;
return (
<div className="flex min-w-0 flex-col" data-testid="task-chat-workspace-change-file">
<button
type="button"
aria-expanded={expandable ? open : undefined}
disabled={!expandable}
className={cn(
"group/file flex min-w-0 items-center gap-2 rounded-sm px-2 py-1 text-left text-xs",
expandable ? "hover:bg-muted/60" : "cursor-default",
)}
onClick={expandable ? onToggle : undefined}
>
<ChevronRight
aria-hidden
className={cn(
"h-3 w-3 shrink-0 text-muted-foreground transition-transform",
open && "rotate-90",
!expandable && "invisible",
)}
/>
<span className="min-w-0 flex-1 truncate font-mono text-foreground">
{file.previousPath ? `${file.previousPath} → ${file.path}` : file.path}
</span>
{file.operation !== "modify" ? (
<span className="shrink-0 text-(length:--text-micro) capitalize text-muted-foreground">
{file.binary ? "binary · " : ""}
{OPERATION_LABEL[file.operation] ?? file.operation.replaceAll("_", " ")}
</span>
) : file.binary ? (
<span className="shrink-0 text-(length:--text-micro) text-muted-foreground">binary</span>
) : null}
<TaskChatDiffCounts added={file.additions} removed={file.deletions} />
</button>
{open ? (
file.binary ? (
<p className="ml-7 mt-1 text-xs text-muted-foreground">Binary file; text diff is unavailable.</p>
) : parsed?.lines.length ? (
<TaskChatDiffView
className="ml-7 mt-1"
lines={parsed.lines}
truncated={parsed.truncated || file.diffTruncated === true}
/>
) : (
<p className="ml-7 mt-1 text-xs text-muted-foreground">This patch is too large to show here.</p>
)
) : null}
</div>
);
}
const OPERATION_LABEL: Record<string, string> = {
create: "new",
delete: "deleted",
rename: "renamed",
mode_change: "mode change",
};
function WorkspaceChangeCard({ item }: { item: TaskChatWorkspaceChangeItem }) {
const [openPaths, setOpenPaths] = useState<ReadonlySet<string>>(() => new Set());
const [expanded, setExpanded] = useState(false);
const visibleFiles = expanded ? item.files : item.files.slice(0, 5);
const fileCount = item.totals.files || item.files.length;
const fileLabel = `${fileCount} ${fileCount === 1 ? "file" : "files"}`;
const summary = item.complete ? `${fileLabel} changed` : `${fileLabel} so far`;
const toggle = (path: string) =>
setOpenPaths((current) => {
const next = new Set(current);
if (next.has(path)) next.delete(path);
else next.add(path);
return next;
});
return (
<CardShell
icon={GitBranch}
title={item.complete ? "Changed in this turn" : "Changing files"}
status={item.complete ? null : "running"}
testId="task-chat-workspace-change"
>
<div className="flex min-w-0 items-center gap-2 px-2 pb-1 text-xs text-muted-foreground">
<span>{summary}</span>
<TaskChatDiffCounts added={item.totals.additions} removed={item.totals.deletions} />
{item.files.length > 1 ? (
<button
type="button"
className="ml-auto text-foreground underline-offset-2 hover:underline"
onClick={() =>
setOpenPaths((current) =>
current.size > 0 ? new Set() : new Set(item.files.map((file) => file.path)),
)
}
>
{openPaths.size > 0 ? "Collapse all" : "Expand all"}
</button>
) : null}
</div>
<div className="flex min-w-0 flex-col gap-0.5">
{visibleFiles.map((file) => (
<WorkspaceChangeFileRow
key={`${file.operation}:${file.path}`}
file={file}
open={openPaths.has(file.path)}
onToggle={() => toggle(file.path)}
/>
))}
</div>
{item.files.length > 5 ? (
<Button
type="button"
size="sm"
variant="ghost"
className="mt-1"
onClick={() => setExpanded((value) => !value)}
>
{expanded ? "Show fewer files" : `Show ${item.files.length - 5} more files`}
</Button>
) : null}
{fileCount > item.files.length ? (
<p className="mt-1 px-2 text-xs text-muted-foreground" data-testid="task-chat-workspace-change-omitted">
{fileCount - item.files.length} more {fileCount - item.files.length === 1 ? "file" : "files"} changed.
This change set is too large to list them here.
</p>
) : null}
</CardShell>
);
}
function WorkspaceFileCard({ item }: { item: TaskChatWorkspaceFileItem }) {
const [open, setOpen] = useState(false);
const extension = item.displayName.includes(".")
? item.displayName.split(".").at(-1)?.toUpperCase()
: "FILE";
const label =
item.presentation === "generic" ? "File" : titleCaseKey(item.presentation);
const workspaceFileRef = useMemo(
() => ({
path: item.path,
resourceKind: "file" as const,
line: item.line,
column: null,
raw: item.path,
}),
[item.line, item.path],
);
return (
<>
<CardShell
icon={FileCode2}
title={item.displayName}
status={
item.source === "runner_verified" ? "completed" : "informational"
}
summary={`${label} · ${extension}${item.line ? ` · line ${item.line}` : ""}`}
testId="task-chat-workspace-file"
>
<div className="flex flex-wrap items-center gap-2">
<WorkspaceFileLink
workspaceFileRef={workspaceFileRef}
label={item.path}
/>
<Button
className="ml-auto"
type="button"
size="sm"
variant="outline"
onClick={() => setOpen(true)}
>
Preview
</Button>
</div>
</CardShell>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="w-full max-w-(--pct-90) overflow-hidden">
<DialogHeader>
<DialogTitle>{item.displayName}</DialogTitle>
</DialogHeader>
<p className="font-mono text-xs text-muted-foreground">{item.path}</p>
{item.preview == null ? (
<p className="rounded-sm bg-muted/50 p-3 text-sm text-muted-foreground">
Preview unavailable. The verified workspace reference is still
available above.
</p>
) : item.presentation === "document" ? (
<div className="max-h-(--sz-70vh) overflow-auto rounded-sm bg-muted/30 p-3">
<MarkdownBody>{item.preview}</MarkdownBody>
</div>
) : item.presentation === "image" &&
item.mediaType?.startsWith("image/") &&
item.preview.startsWith("data:") ? (
<img
className="max-h-(--sz-70vh) max-w-full object-contain"
alt={item.displayName}
src={item.preview}
/>
) : (
<pre className="max-h-(--sz-70vh) overflow-auto whitespace-pre-wrap rounded-sm bg-muted/50 p-3 font-mono text-xs">
{item.preview}
</pre>
)}
{item.previewTruncated ? (
<p className="text-xs text-muted-foreground">
Preview truncated by the runner.
</p>
) : null}
</DialogContent>
</Dialog>
</>
);
}
function titleCaseKey(value: string): string {
return value
.replaceAll("_", " ")
.replace(/\b\w/g, (letter) => letter.toUpperCase());
}
function RuntimeQuestionHistory({
item,
}: {
item: TaskChatRuntimeRequestItem;
}) {
const questionSet = item.questionSet;
if (!questionSet) return null;
return (
<div
className="flex flex-col gap-2"
data-testid="task-chat-runtime-request-history"
>
{item.response ? (
<QuestionResponseSummary
questionSet={questionSet}
response={item.response}
/>
) : (
<div className="flex flex-col gap-2 text-sm">
{questionSet.questions.map((question) => (
<div key={question.id}>
{question.header ? (
<p className="text-xs font-medium text-muted-foreground">
{question.header}
</p>
) : null}
<p className="text-foreground">{question.prompt}</p>
</div>
))}
<p className="text-xs text-muted-foreground">
{item.status === "resolved"
? "Answer details were not recorded by this older runtime."
: `No answers were submitted; this request was ${item.status}.`}
</p>
</div>
)}
</div>
);
}
function RuntimeQuestionReceipt({
item,
}: {
item: TaskChatRuntimeRequestItem;
}) {
const label =
item.status === "resolved"
? "Questions answered"
: item.status === "cancelled"
? "Questions cancelled"
: item.status === "expired"
? "Questions expired"
: "Questions resolved";
return (
<details className="group" data-testid="task-chat-runtime-request">
<summary className="flex min-h-8 cursor-pointer list-none items-center gap-2 rounded-sm px-1 py-1.5 text-sm leading-5 text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<span
className="flex w-6 shrink-0 items-center justify-center"
data-testid="task-chat-runtime-request-icon-slot"
>
<CircleHelp
aria-hidden
className="h-3.5 w-3.5 shrink-0 text-muted-foreground"
data-testid="task-chat-runtime-request-icon"
/>
</span>
<span>{label}</span>
<ChevronRight
aria-hidden
className="ml-auto h-3 w-3 shrink-0 text-muted-foreground opacity-0 transition-[opacity,transform] group-hover:opacity-70 group-focus-visible:opacity-70 group-open:rotate-90"
data-testid="task-chat-runtime-request-caret"
/>
</summary>
<div className="pb-3 pl-7 pr-1">
<div className="mb-3">
<p className="text-sm font-medium text-foreground">
{item.questionSet?.title ?? "Runtime input"}
</p>
<p className="mt-1 text-sm text-muted-foreground">{item.prompt}</p>
</div>
<RuntimeQuestionHistory item={item} />
</div>
</details>
);
}
function RuntimeRequestCard({
item,
onDecision,
presentation,
draftKey: requestedDraftKey,
imageUploadHandler,
mentions,
}: {
item: TaskChatRuntimeRequestItem;
onDecision?: TaskChatProtocolCardProps["onRuntimeRequestDecision"];
presentation: "timeline" | "takeover";
draftKey?: string;
imageUploadHandler?: (file: File) => Promise<string>;
mentions?: MentionOption[];
}) {
const historyIssueId = useContext(TaskChatRequestHistoryContext);
const draftKey = requestedDraftKey ?? (historyIssueId
? `paperclip:task-input:${historyIssueId}:runtime:${item.runId}:${item.requestId}`
: undefined);
const title =
item.questionSet?.title ??
(item.requestType === "permission"
? "Runtime permission"
: "Runtime input");
const fields =
item.fields.length > 0
? item.fields
: [{ name: "answer", label: "Response", placeholder: null }];
const [values, setValues] = useState<Record<string, string>>(() =>
draftKey ? loadStructuredDraft(draftKey, {}) : {},
);
const [expanded, setExpanded] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!draftKey) setValues({});
setSubmitting(false);
setError(null);
}, [draftKey, item.requestId, item.status]);
useEffect(() => {
if (draftKey) saveStructuredDraft(draftKey, values);
}, [draftKey, values]);
if (
presentation === "timeline" &&
item.requestType === "input" &&
item.status !== "pending" &&
item.questionSet
) {
return <RuntimeQuestionReceipt item={item} />;
}
const submit = async (decision: TaskChatRuntimeRequestDecision) => {
if (!onDecision || item.status !== "pending" || submitting) return;
setSubmitting(true);
setError(null);
try {
await onDecision(item, decision);
if (draftKey) clearDraft(draftKey);
} catch (cause) {
setSubmitting(false);
setError(
cause instanceof Error
? cause.message
: "The runtime request could not be resolved.",
);
}
};
const canSubmitInput = fields.some(
(field) => (values[field.name] ?? "").trim().length > 0,
);
if (
presentation === "takeover" &&
item.requestType === "input" &&
item.status === "pending" &&
item.questionSet
) {
return (
<QuestionForm
id={item.id}
questionSet={item.questionSet}
draftKey={draftKey}
disabled={!onDecision || submitting}
imageUploadHandler={imageUploadHandler}
mentions={mentions}
onSubmit={(response) => submit({ action: "submit", response })}
/>
);
}
const content = (
<CardShell
icon={ShieldCheck}
title={title}
status={item.status}
summary={item.prompt}
testId="task-chat-runtime-request"
presentation={presentation}
>
{item.requestType === "input" &&
item.status === "pending" &&
item.questionSet ? (
<QuestionForm
id={item.id}
questionSet={item.questionSet}
draftKey={draftKey}
disabled={!onDecision || submitting}
imageUploadHandler={imageUploadHandler}
mentions={mentions}
onSubmit={(response) => submit({ action: "submit", response })}
onCancel={
presentation === "timeline"
? () => submit({ action: "cancel" })
: undefined
}
/>
) : item.requestType === "input" &&
item.status !== "pending" &&
item.questionSet ? (
<RuntimeQuestionHistory item={item} />
) : item.requestType === "input" && item.status === "pending" ? (
<form
className="flex flex-col gap-3"
onSubmit={(event) => {
event.preventDefault();
if (canSubmitInput) void submit({ action: "submit", values });
}}
>
{fields.map((field) => {
const inputId = `${item.id}:${field.name}`;
return (
<label
key={field.name}
className="flex flex-col gap-1.5 text-sm"
htmlFor={inputId}
>
<span className="font-medium text-foreground">
{field.label}
</span>
<Textarea
id={inputId}
value={values[field.name] ?? ""}
placeholder={field.placeholder ?? undefined}
disabled={!onDecision || submitting}
rows={2}
className="resize-y border-0 bg-muted/35 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0"
onChange={(event) =>
setValues((current) => ({
...current,
[field.name]: event.target.value,
}))
}
/>
</label>
);
})}
<div className="flex flex-wrap gap-2">
<Button
type="submit"
size="sm"
disabled={!onDecision || submitting || !canSubmitInput}
>
{submitting ? "Submitting…" : "Submit response"}
</Button>
{item.choices.some((choice) => choice.key === "decline") ? (
<Button
type="button"
size="sm"
variant="outline"
disabled={!onDecision || submitting}
onClick={() => void submit({ action: "decline" })}
>
Deny
</Button>
) : null}
{presentation === "timeline" ? (
<Button
type="button"
size="sm"
variant="outline"
disabled={!onDecision || submitting}
onClick={() => void submit({ action: "cancel" })}
>
Cancel
</Button>
) : null}
</div>
</form>
) : item.status === "pending" && item.choices.length > 0 ? (
<div className="flex flex-wrap gap-2">
{item.choices
.filter(
(choice) =>
presentation === "timeline" || choice.key !== "cancel",
)
.map((choice) => (
<Button
key={choice.key}
type="button"
size="sm"
variant="outline"
disabled={
item.status !== "pending" || !onDecision || submitting
}
onClick={() => {
if (
choice.key === "accept" ||
choice.key === "accept_for_session" ||
choice.key === "decline" ||
choice.key === "cancel"
) {
void submit({ action: choice.key });
}
}}
>
{submitting ? "Submitting…" : choice.label}
</Button>
))}
</div>
) : null}
{item.status === "pending" && !onDecision ? (
<p className="mt-2 text-xs text-muted-foreground">
Resolve this request in the active runtime session.
</p>
) : null}
{error ? (
<p className="mt-2 text-xs text-destructive" role="alert">
{error}
</p>
) : null}
</CardShell>
);
if (historyIssueId && presentation === "timeline" && item.status === "pending") {
return (
<details open={expanded} onToggle={event => {
if (event.currentTarget.open && draftKey) setValues(loadStructuredDraft(draftKey, {}));
setExpanded(event.currentTarget.open);
}} className="group w-full" data-testid="task-chat-pending-runtime-request">
<summary className="flex cursor-pointer list-none items-center gap-2 rounded-sm px-1 py-1.5 text-sm text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<CircleHelp aria-hidden className="h-3.5 w-3.5 shrink-0" />
<span className="min-w-0 flex-1"><span className="block text-xs">{title}</span><span className="block truncate">{item.prompt}</span></span>
<ChevronRight aria-hidden className="h-3 w-3 shrink-0 group-open:rotate-90" />
</summary>
{expanded ? content : null}
</details>
);
}
return content;
}
function ResultCard({
item,
}: {
item: Extract<TaskChatProtocolItem, { surface: "run_result" }>;
}) {
const hasDetails =
item.verification.length > 0 ||
item.remainingWork.length > 0 ||
item.artifacts.length > 0 ||
item.blocker;
return (
<CardShell
icon={PackageCheck}
title="Run result"
status={item.disposition}
summary={item.summary}
testId="task-chat-run-result"
>
{hasDetails ? (
<div className="flex flex-col gap-3 text-sm">
{item.blocker ? (
<div className="rounded-sm bg-muted/50 p-2">
<strong>Blocked: {item.blocker.reasonCode}</strong>
<p className="mt-1 text-muted-foreground">
{item.blocker.unblockAction}
</p>
</div>
) : null}
{item.verification.length > 0 ? (
<ul
className="flex flex-col gap-1"
aria-label="Verification results"
>
{item.verification.map((check, index) => (
<li
key={`${check.commandOrCheck}:${index}`}
className="flex items-start gap-2"
>
<StatusIcon
status={check.status}
className="mt-0.5 shrink-0"
/>
<span>
<span className="font-mono text-xs">
{check.commandOrCheck}
</span>
{check.detail ? (
<span className="block text-xs text-muted-foreground">
{check.detail}
</span>
) : null}
</span>
</li>
))}
</ul>
) : null}
{item.remainingWork.length > 0 ? (
<ul className="list-disc space-y-1 pl-5 text-muted-foreground">
{item.remainingWork.map((work, index) => (
<li key={`${work.description}:${index}`}>
{work.description}
{work.blocksCompletion ? " · blocks completion" : ""}
</li>
))}
</ul>
) : null}
{item.artifacts.length > 0 ? (
<div className="flex flex-wrap gap-2">
{item.artifacts.map((artifact) => (
<span
key={`${artifact.kind}:${artifact.ref}`}
className="rounded-sm border border-border px-2 py-1 font-mono text-xs"
>
{artifact.title ?? artifact.ref}
</span>
))}
</div>
) : null}
</div>
) : null}
</CardShell>
);
}
function TerminalCard({
item,
}: {
item: Extract<TaskChatProtocolItem, { surface: "run_terminal" }>;
}) {
return (
<CardShell
icon={TerminalSquare}
title="Run ended"
status={item.runState}
summary={[item.disposition.replaceAll("_", " "), item.stopReason]
.filter(Boolean)
.join(" · ")}
testId="task-chat-run-terminal"
/>
);
}
function ResourceCard({
item,
}: {
item: Extract<TaskChatProtocolItem, { surface: "resource" }>;
}) {
const { enabled: artifactsV1 } = useArtifactsV1Enabled();
if (artifactsV1) {
// Artifacts V1 (doc/plans/2026-10-22-artifacts-mvp-v1.md): one full-width row named after the file. A
// registered file keeps the shipped card's actions (gallery, text tab, open raw, download, CSV preview);
// any other deliverable is a link that opens in a new tab with its state — "Unhealthy" first — beside it.
const href = item.href ?? undefined;
const testId = `task-chat-resource-${item.resourceKind}`;
if (item.resourceKind === "deliverable" && item.workProduct) {
const workProduct = item.workProduct;
const statusBadge = workProductStateBadge(workProduct);
const file = fileRowSourceFromWorkProduct(workProduct);
if (file) {
return <ArtifactFileRow subject={subjectFromWorkProduct(workProduct)} source={file} statusBadge={statusBadge} updatedAtLabel={item.timestamp ?? ""} testId={testId} />;
}
return <ArtifactRow subject={subjectFromWorkProduct(workProduct)} href={href} external trailing={statusBadge} testId={testId} />;
}
if (item.resourceKind === "attachment" && item.attachment) {
return <ArtifactFileRow subject={subjectFromAttachment(item.attachment)} source={fileRowSourceFromAttachment(item.attachment)} updatedAtLabel={item.timestamp ?? ""} testId={testId} />;
}
const subject = item.document
? subjectFromDocumentSummary(item.document)
: { kind: item.resourceKind === "document" ? ("document" as const) : ("file" as const), title: item.title };
return <ArtifactRow subject={subject} href={href} external={item.resourceKind === "attachment"} testId={testId} />;
}
if (item.resourceKind === "deliverable" && item.workProduct) {
return <RichWorkProductCard workProduct={item.workProduct} href={item.href} />;
}
const Icon =
item.resourceKind === "document"
? FileText
: item.resourceKind === "attachment"
? FileCode2
: PackageCheck;
const body = (
<CardShell
icon={Icon}
title={item.title}
status={null}
summary={item.subtitle}
testId={`task-chat-resource-${item.resourceKind}`}
/>
);
return item.href ? (
<a
className="block rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
href={item.href}
>
{body}
</a>
) : (
body
);
}
export function TaskChatProtocolCard({
item,
onRuntimeRequestDecision,
presentation = "timeline",
draftKey,
imageUploadHandler,
mentions,
}: TaskChatProtocolCardProps) {
switch (item.surface) {
case "provider_activity":
return isStatusNotice(item)
? <TaskChatStatusNotice item={item} resolved={item.resolved} />
: <ProviderActivityCard item={item} />;
case "workspace_change":
return <WorkspaceChangeCard item={item} />;
case "workspace_file":
return <WorkspaceFileCard item={item} />;
case "runtime_request":
return (
<RuntimeRequestCard
item={item}
onDecision={onRuntimeRequestDecision}
presentation={presentation}
draftKey={draftKey}
imageUploadHandler={imageUploadHandler}
mentions={mentions}
/>
);
case "run_result":
return <ResultCard item={item} />;
case "run_terminal":
return <TerminalCard item={item} />;
case "resource":
return <ResourceCard item={item} />;
default: {
const unreachable: never = item;
return unreachable;
}
}
}