Files
PaperClipAI/ui/src/components/task-chat/TaskChatTurnStatusIsland.tsx
T
DottaandClaude Opus 5.5 8227259d86 feat(task-chat): show the agent's native plan as a task list above the composer (#19252)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Agents run through the native runner. The runner turns each
harness's native plan into a canonical `plan.updated` event.
> - The task chat did not show these plans. A small "Step 2 / 5" pill
showed the checklist only on hover, and in live runs it received no plan
data.
> - Live tests found three breaks. The UI flattened `plan.updated` into
tool rows. Headless Claude Code hides its task tools from current
models. Codex ships its `update_plan` tool disabled.
> - This pull request fixes the three breaks and replaces the pill with
a docked plan card above the composer.
> - The benefit is that users see what the agent finished, what it does
now, and what comes next. The view is the same for Claude, Codex, Grok
and Copilot.

## Linked Issues or Issue Description

**Subsystem affected**
Task chat UI (turn status island), native runner ACPX launch environment
for Claude, and native runner Codex app-server launch arguments.

**Problem or motivation**
Harnesses report native plans, and the runner records them as
`plan.updated` events. Users do not see them. The live transcript
builder converts `plan.updated` into generic tool rows, so the plan
steps are lost. Claude Code in headless mode does not offer
TaskCreate/TaskUpdate for current models, so Claude never makes a plan.
Codex 0.160 has `update_plan` disabled by default, so Codex writes its
checklist into a Paperclip document instead.

**Proposed solution**
Keep the full plan snapshot in the live transcript. Enable the Claude
task tools with `CLAUDE_CODE_ENABLE_TODO_TOOLS=1` in the ACPX launch
environment. Enable the Codex plan tool with
`tools.update_plan.enabled=true`. Show the plan as a docked card with
clear rules for when it shows, collapses and hides.

**Alternatives considered**
Show the plan only in the hover popover (the old behavior). Users did
not find it. Write the plan into the issue Plan document. That mixes a
per-turn checklist with the reviewed planning document.

**Roadmap alignment**
This makes existing native runner plan events visible. It adds no new
protocol or data model.

**Additional context**
Grok and Copilot needed no harness change. Cursor could not start in a
local worktree instance (an unrelated startup admission problem), so
this PR does not verify Cursor.

## What Changed

- `ui/src/components/transcript/native-run-events.ts`: keep
`plan.updated` as `provider_activity` with family `plan` and the full
payload, not as a tool row.
- `ui/src/components/task-chat/TaskChatTurnStatusIsland.tsx`: replace
the hover pill with a docked plan card. It shows done, current, upcoming
and blocked steps, a progress bar and file-change totals.
- Visibility rules: hidden with no steps and after the run ends;
collapses when all steps are done; header only for one-step plans and
while a question or approval is pending; window around the current step
for plans over 7 steps; collapsed by default on mobile.
- `ui/src/components/TaskChatThread.tsx`: pass `defaultCollapsed`
(mobile) and `compact` (pending runtime request), and reset the card per
run.
- `ui/src/components/task-chat/transcript-adapter.ts`: keep the `failed`
step status.
- `packages/paperclip-runner/src/drivers/acpx/environment.ts`: set
`CLAUDE_CODE_ENABLE_TODO_TOOLS=1` for Claude launches. A host value
cannot turn it off.
-
`packages/paperclip-runner/src/drivers/codex/codex-security-config.ts`:
add `-c tools.update_plan.enabled=true` before the policy overrides and
the `app-server` subcommand.
- `ui/storybook/stories/tasks/task-chat-agent-plan.stories.tsx`: new
story for each state. It uses the production composer dock, composer and
question takeover.
- Tests for all of the above. `ui/src/index.css` replaces the unused
popover size token with a list max-height token.

## Verification

- `pnpm --filter @paperclipai/ui exec vitest run
src/components/transcript src/components/task-chat
src/components/TaskChatThread.test.tsx src/adapters/paperclip-runner`
(1345 tests pass).
- `pnpm --filter @paperclipai/paperclip-runner exec vitest run
src/drivers/acpx/environment.test.ts src/drivers/codex` (pass).
- UI and runner typecheck are clean. `pnpm check:token-gates` is clean.
- Storybook: open "Tasks and conversations / … / Task chat / Agent
plan". The "All States Gallery" story shows every state. "Live Run
Simulation" animates a run.
- Live test on a local worktree instance with real API keys: a
multi-step task for each harness. The card showed and updated during the
run for Claude (Opus 5.5), Codex (gpt-5.6-sol), Grok (grok-4.7) and
Copilot (gpt-5.6-luna). For Claude the card collapsed when all steps
were done and hid after the run ended.
- Codex probe: `codex exec` with the pinned 0.160 binary reports no
`update_plan` tool by default and reports the tool with `-c
tools.update_plan.enabled=true`.

## Risks

- Claude and Codex now get their plan tools. Agents can spend a few
tokens on plan updates. The tools are standard harness features.
- The Codex flag goes before the web-search and project-instruction
policy overrides, so the existing policy tail check still holds.
- Runs that finish fast show the card only briefly. This is the intended
hide rule.
- Cursor plan display is not verified.

> 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

- Claude Opus 5.5 (`claude-opus-5-5`) in Claude Code, with tool use
(shell, file edit, browser automation) 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
- [x] 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 09:09:39 -06:00

405 lines
16 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 } from "react";
import {
CheckCircle2,
ChevronDown,
Circle,
CircleAlert,
LoaderCircle,
} from "lucide-react";
import { cn } from "@/lib/utils";
import type {
TaskChatItem,
TaskChatProtocolStep,
TaskChatProviderActivityItem,
TaskChatWorkspaceChangeItem,
} from "./task-chat-model";
/**
* Active-turn status docked above the composer.
*
* The plan is the provider-neutral `plan.updated` checklist (Claude task
* tools, Codex `turn/plan/updated`, Cursor todos, any ACP `plan` update).
* Visibility rules live here so every harness gets the same behavior:
*
* - Hidden when no plan steps and no workspace changes exist, and hidden once
* the run is terminal (the caller passes `null`; the turn transcript keeps
* the plan record).
* - Single-step plans render as a header only: a one-item list adds no
* information beyond the header.
* - Expanded by default while work remains; auto-collapses once every step is
* done unless the viewer explicitly reopened it.
* - Forced to a header (`compact`) while a question or approval needs the
* viewer, so the decision stays the focus.
* - Long plans show a window around the current step with "earlier" and
* "more" rows that reveal every step. The list always scrolls within a
* bounded height so it never covers the conversation.
*/
export type TaskChatTurnPlanSegment = {
kind: "plan";
steps: TaskChatProtocolStep[];
currentStepIndex: number;
complete: boolean;
};
export type TaskChatTurnStatusSegment =
| TaskChatTurnPlanSegment
| {
kind: "workspace_diff";
files: number;
additions: number | null;
deletions: number | null;
};
export interface TaskChatTurnStatusModel {
segments: TaskChatTurnStatusSegment[];
}
type WorkspaceSegment = Extract<TaskChatTurnStatusSegment, { kind: "workspace_diff" }>;
function latestPlan(items: readonly TaskChatItem[]): TaskChatProviderActivityItem | null {
let latest: TaskChatProviderActivityItem | null = null;
let latestOrder = -1;
for (const [index, item] of items.entries()) {
if (item.kind !== "protocol" || item.surface !== "provider_activity" || item.family !== "plan") continue;
const order = item.transcriptIndex ?? index;
if (order >= latestOrder) {
latest = item;
latestOrder = order;
}
}
return latest;
}
function latestWorkspaceDiff(items: readonly TaskChatItem[]): TaskChatWorkspaceChangeItem | null {
let latest: TaskChatWorkspaceChangeItem | null = null;
for (const item of items) {
if (item.kind === "protocol" && item.surface === "workspace_change") latest = item;
}
return latest;
}
function currentStepIndex(steps: readonly TaskChatProtocolStep[]): number {
const inProgress = steps.findIndex((step) => step.status === "in_progress");
if (inProgress >= 0) return inProgress;
const blocked = steps.findIndex((step) => step.status === "blocked" || step.status === "failed");
if (blocked >= 0) return blocked;
const pending = steps.findIndex((step) => step.status === "pending");
if (pending >= 0) return pending;
return Math.max(0, steps.length - 1);
}
export function taskChatTurnPlanSegment(steps: TaskChatProtocolStep[]): TaskChatTurnPlanSegment {
return {
kind: "plan",
steps,
currentStepIndex: currentStepIndex(steps),
complete: steps.length > 0 && steps.every((step) => step.status === "completed"),
};
}
export function taskChatTurnStatusModel(items: readonly TaskChatItem[]): TaskChatTurnStatusModel | null {
const segments: TaskChatTurnStatusSegment[] = [];
const plan = latestPlan(items);
if (plan && plan.steps.length > 0) segments.push(taskChatTurnPlanSegment(plan.steps));
const workspace = latestWorkspaceDiff(items);
const files = workspace ? workspace.totals.files || workspace.files.length : 0;
if (workspace && files > 0) {
segments.push({
kind: "workspace_diff",
files,
additions: workspace.totals.additions,
deletions: workspace.totals.deletions,
});
}
return segments.length > 0 ? { segments } : null;
}
/** Plans up to this length always list every step. */
export const PLAN_FULL_LIST_LIMIT = 7;
const PLAN_WINDOW_SIZE = 5;
export type TaskChatTurnPlanRow =
| { kind: "step"; step: TaskChatProtocolStep; index: number; current: boolean }
| { kind: "earlier"; count: number; allCompleted: boolean }
| { kind: "more"; count: number };
/** Rows to render: every step for short plans, else a window around the current step. */
export function taskChatTurnPlanRows(plan: TaskChatTurnPlanSegment, showAll: boolean): TaskChatTurnPlanRow[] {
const toRow = (step: TaskChatProtocolStep, index: number): TaskChatTurnPlanRow => ({
kind: "step",
step,
index,
current: !plan.complete && index === plan.currentStepIndex,
});
if (showAll || plan.steps.length <= PLAN_FULL_LIST_LIMIT) return plan.steps.map(toRow);
const start = Math.max(0, Math.min(plan.currentStepIndex - 1, plan.steps.length - PLAN_WINDOW_SIZE));
const end = Math.min(plan.steps.length, start + PLAN_WINDOW_SIZE);
const rows: TaskChatTurnPlanRow[] = [];
if (start > 0) {
rows.push({
kind: "earlier",
count: start,
allCompleted: plan.steps.slice(0, start).every((step) => step.status === "completed"),
});
}
for (let index = start; index < end; index += 1) rows.push(toRow(plan.steps[index]!, index));
if (end < plan.steps.length) rows.push({ kind: "more", count: plan.steps.length - end });
return rows;
}
function isProblem(step: TaskChatProtocolStep | undefined): boolean {
return step?.status === "blocked" || step?.status === "failed";
}
export function taskChatTurnPlanHeadline(plan: TaskChatTurnPlanSegment): string {
const step = plan.steps[plan.currentStepIndex];
if (plan.complete) return plan.steps.length === 1 ? "Step done" : `All ${plan.steps.length} steps done`;
if (!step) return "Plan";
if (isProblem(step)) return `Blocked: ${step.label}`;
if (step.status === "pending") return `Up next: ${step.label}`;
return step.label;
}
function planHeaderIcon(plan: TaskChatTurnPlanSegment) {
const status = plan.steps[plan.currentStepIndex]?.status;
if (plan.complete) return <CheckCircle2 className="size-4 shrink-0 text-(--status-task-icon-done)" aria-hidden />;
if (status === "blocked" || status === "failed") {
return <CircleAlert className="size-4 shrink-0 text-(--status-task-icon-blocked)" aria-hidden />;
}
if (status === "pending") return <Circle className="size-4 shrink-0 text-muted-foreground" aria-hidden />;
return <LoaderCircle className="size-4 shrink-0 text-(--status-agent-running) motion-safe:animate-spin" aria-hidden />;
}
function checklistIcon(step: TaskChatProtocolStep) {
if (step.status === "completed") {
return <CheckCircle2 className="mt-0.5 size-4 shrink-0 text-(--status-task-icon-done)" aria-hidden />;
}
if (step.status === "in_progress") {
return <LoaderCircle className="mt-0.5 size-4 shrink-0 text-(--status-agent-running) motion-safe:animate-spin" aria-hidden />;
}
if (isProblem(step)) {
return <CircleAlert className="mt-0.5 size-4 shrink-0 text-(--status-task-icon-blocked)" aria-hidden />;
}
return <Circle className="mt-0.5 size-4 shrink-0 text-muted-foreground" aria-hidden />;
}
const STEP_STATUS_LABEL: Record<TaskChatProtocolStep["status"], string> = {
completed: "done",
in_progress: "in progress",
pending: "not started",
blocked: "blocked",
failed: "failed",
};
function fileCountLabel(files: number): string {
return `${files} ${files === 1 ? "file" : "files"} changed`;
}
function WorkspaceStats({ workspace, compact }: { workspace: WorkspaceSegment; compact?: boolean }) {
return (
<span className="flex min-w-0 items-center gap-2">
<span className={cn("min-w-0 truncate text-sm text-muted-foreground", compact && "sr-only")}>
{fileCountLabel(workspace.files)}
</span>
{workspace.additions != null ? (
<span className="shrink-0 font-mono text-sm tabular-nums text-(--status-task-icon-done)">
+{workspace.additions}
</span>
) : null}
{workspace.deletions != null ? (
<span className="shrink-0 font-mono text-sm tabular-nums text-(--status-task-icon-blocked)">
−{workspace.deletions}
</span>
) : null}
</span>
);
}
const ISLAND_CLASS_NAME = "mx-auto flex min-h-10 max-w-(--sz-turn-status-island) items-center overflow-hidden rounded-full border border-border bg-popover/95 px-4 py-2 text-foreground shadow-sm backdrop-blur";
const ROW_BUTTON_CLASS_NAME = "flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-muted-foreground hover:bg-accent/50 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring";
function PlanRow({ row, onShowAll }: { row: TaskChatTurnPlanRow; onShowAll: () => void }) {
if (row.kind === "earlier") {
return (
<li>
<button type="button" className={ROW_BUTTON_CLASS_NAME} onClick={onShowAll} data-testid="task-chat-turn-plan-earlier">
{row.allCompleted
? <CheckCircle2 className="size-4 shrink-0 text-(--status-task-icon-done)" aria-hidden />
: <Circle className="size-4 shrink-0" aria-hidden />}
<span>{row.count} earlier {row.count === 1 ? "step" : "steps"}{row.allCompleted ? " done" : ""}</span>
</button>
</li>
);
}
if (row.kind === "more") {
return (
<li>
<button type="button" className={ROW_BUTTON_CLASS_NAME} onClick={onShowAll} data-testid="task-chat-turn-plan-more">
<Circle className="size-4 shrink-0 opacity-0" aria-hidden />
<span>+{row.count} more {row.count === 1 ? "step" : "steps"}</span>
</button>
</li>
);
}
const { step, current } = row;
return (
<li
className={cn(
"flex items-start gap-2 rounded-md px-2 py-1.5 text-sm leading-5",
current && "bg-muted font-medium text-foreground",
step.status === "completed" && "text-muted-foreground",
step.status === "pending" && !current && "text-foreground",
isProblem(step) && "text-foreground",
)}
aria-current={current ? "step" : undefined}
data-step-status={step.status}
>
{checklistIcon(step)}
<span className="min-w-0 break-words">
{step.label}
<span className="sr-only"> ({STEP_STATUS_LABEL[step.status]})</span>
</span>
</li>
);
}
export interface TaskChatTurnPlanProps {
plan: TaskChatTurnPlanSegment;
workspace?: WorkspaceSegment | null;
/** Start collapsed (used on mobile, where dock height is scarce). */
defaultCollapsed?: boolean;
/** Force a header-only presentation while the viewer must answer something. */
compact?: boolean;
}
export function TaskChatTurnPlan({ plan, workspace = null, defaultCollapsed = false, compact = false }: TaskChatTurnPlanProps) {
const listId = useId();
const [expandedOverride, setExpandedOverride] = useState<boolean | null>(null);
const [showAll, setShowAll] = useState(false);
const completed = plan.steps.filter((step) => step.status === "completed").length;
const total = plan.steps.length;
const listable = total > 1;
const automaticExpanded = !defaultCollapsed && !plan.complete;
const expanded = listable && !compact && (expandedOverride ?? automaticExpanded);
const headline = taskChatTurnPlanHeadline(plan);
const rows = taskChatTurnPlanRows(plan, showAll);
const problem = isProblem(plan.steps[plan.currentStepIndex]);
const headerContent = (
<>
{planHeaderIcon(plan)}
<span
className={cn("min-w-0 flex-1 truncate text-left text-sm", !plan.complete && "font-medium")}
aria-live="polite"
aria-atomic="true"
data-testid="task-chat-turn-plan-headline"
>
{headline}
</span>
{workspace ? <WorkspaceStats workspace={workspace} compact /> : null}
<span
className={cn("shrink-0 font-mono text-xs tabular-nums", problem ? "text-(--status-task-icon-blocked)" : "text-muted-foreground")}
data-testid="task-chat-turn-plan-progress"
>
<span aria-hidden>{completed}/{total}</span>
<span className="sr-only">{completed} of {total} steps done</span>
</span>
</>
);
return (
<section
aria-label="Agent plan"
className="mx-auto w-full max-w-(--sz-turn-status-island) overflow-hidden rounded-xl border border-border bg-popover/95 text-foreground shadow-sm backdrop-blur"
data-testid="task-chat-turn-status-island"
data-plan-state={plan.complete ? "complete" : problem ? "blocked" : "active"}
data-expanded={expanded ? "true" : "false"}
>
{listable && !compact ? (
<button
type="button"
className="flex min-h-10 w-full items-center gap-2 px-3 py-2 transition-colors hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring motion-reduce:transition-none"
aria-expanded={expanded}
aria-controls={expanded ? listId : undefined}
onClick={() => setExpandedOverride(!expanded)}
data-testid="task-chat-turn-plan-header"
>
{headerContent}
<ChevronDown
className={cn(
"size-4 shrink-0 text-muted-foreground transition-transform motion-reduce:transition-none",
expanded && "rotate-180",
)}
aria-hidden
/>
</button>
) : (
<div className="flex min-h-10 w-full items-center gap-2 px-3 py-2" role="status" data-testid="task-chat-turn-plan-header">
{headerContent}
</div>
)}
<div
className="h-0.5 w-full bg-muted"
role="progressbar"
aria-label="Plan progress"
aria-valuemin={0}
aria-valuemax={total}
aria-valuenow={completed}
>
<div
className={cn(
"h-full transition-[width] motion-reduce:transition-none",
problem ? "bg-(--status-task-icon-blocked)" : "bg-(--status-task-icon-done)",
)}
style={{ width: `${total > 0 ? (completed / total) * 100 : 0}%` }}
/>
</div>
{expanded ? (
<ol
id={listId}
aria-label="Within-turn checklist"
className="flex max-h-(--sz-turn-plan-list-max-h) flex-col gap-0.5 overflow-y-auto p-1.5"
>
{rows.map((row) => (
<PlanRow
key={row.kind === "step" ? row.step.id : row.kind}
row={row}
onShowAll={() => setShowAll(true)}
/>
))}
{showAll && total > PLAN_FULL_LIST_LIMIT ? (
<li>
<button type="button" className={ROW_BUTTON_CLASS_NAME} onClick={() => setShowAll(false)}>
<Circle className="size-4 shrink-0 opacity-0" aria-hidden />
<span>Show fewer</span>
</button>
</li>
) : null}
</ol>
) : null}
</section>
);
}
export interface TaskChatTurnStatusIslandProps {
model: TaskChatTurnStatusModel;
defaultCollapsed?: boolean;
compact?: boolean;
}
export function TaskChatTurnStatusIsland({ model, defaultCollapsed, compact }: TaskChatTurnStatusIslandProps) {
const plan = model.segments.find((segment): segment is TaskChatTurnPlanSegment => segment.kind === "plan");
const workspace = model.segments.find((segment): segment is WorkspaceSegment => segment.kind === "workspace_diff") ?? null;
if (plan) {
return <TaskChatTurnPlan plan={plan} workspace={workspace} defaultCollapsed={defaultCollapsed} compact={compact} />;
}
if (!workspace) return null;
return (
<div className={ISLAND_CLASS_NAME} role="status" data-testid="task-chat-turn-status-island">
<WorkspaceStats workspace={workspace} />
</div>
);
}