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>
This commit is contained in:
DottaandClaude Opus 5.5 authored and GitHub committed 2026-11-05 09:09:39 -06:00
1 parent cb5d7e440c
commit 8227259d86
14 files changed
+863 -227

No files matched your search

@@ -3,12 +3,21 @@ import { afterEach, describe, expect, it, vi } from "vitest";
afterEach(() => vi.unstubAllEnvs());
import { ACPX_CREDENTIAL_BINDING_ENV, createAcpxCredentialBinding, createAcpxSidecarHostEnvironment, createSanitizedAcpxSpawnInput } from "./environment.js";
import { ACPX_CREDENTIAL_BINDING_ENV, CLAUDE_TODO_TOOLS_ENV, createAcpxCredentialBinding, createAcpxSidecarHostEnvironment, createSanitizedAcpxSpawnInput } from "./environment.js";
const claudeLauncherEnv = { [CLAUDE_TODO_TOOLS_ENV]: "1" };
describe("ACPX launch environment", () => {
it("enables Claude's task-list tools for the native plan and only for Claude", () => {
const host = { PATH: "/bin", [CLAUDE_TODO_TOOLS_ENV]: "0" };
expect(createSanitizedAcpxSpawnInput(host, "claude").env[CLAUDE_TODO_TOOLS_ENV]).toBe("1");
expect(createSanitizedAcpxSpawnInput({ PATH: "/bin" }, "claude").env[CLAUDE_TODO_TOOLS_ENV]).toBe("1");
expect(createSanitizedAcpxSpawnInput(host, "codex").env[CLAUDE_TODO_TOOLS_ENV]).toBeUndefined();
});
it("rejects task shell selectors without a controller-owned helper projection", () => {
const selectors = { BASH_ENV: "/workspace/hook.sh", ZDOTDIR: "/workspace", GH_CONFIG_DIR: "/workspace/gh" };
expect(createSanitizedAcpxSpawnInput(selectors, "claude").env).toEqual({});
expect(createSanitizedAcpxSpawnInput(selectors, "claude").env).toEqual(claudeLauncherEnv);
for (const [key, value] of Object.entries(selectors)) {
expect(configuredEnvironmentProjection({[key]: value})).not.toHaveProperty(key);
expect(() => createSanitizedAcpxSpawnInput({[key]:value, [CONFIGURED_ENVIRONMENT_KEYS]:JSON.stringify([key])}, "claude")).toThrow("configured environment");
@@ -21,7 +30,7 @@ describe("ACPX launch environment", () => {
PAPERCLIP_GITHUB_BRIDGE_TOKEN: "bridge-capability" };
const source = { ...helpers, GH_TOKEN: "host-secret", GITHUB_TOKEN: "host-secret",
PAPERCLIP_GIT_TOKEN: "host-secret", NODE_OPTIONS: "--require=untrusted" };
expect(createSanitizedAcpxSpawnInput(source, "claude").env).toEqual(helpers);
expect(createSanitizedAcpxSpawnInput(source, "claude").env).toEqual({ ...helpers, ...claudeLauncherEnv });
for (const [key, value] of Object.entries(helpers)) vi.stubEnv(key, value);
const ambient = createSanitizedAcpxSpawnInput(undefined, "claude").env;
const sibling = createSanitizedAcpxSpawnInput(source, "cursor").env;
@@ -30,7 +39,7 @@ describe("ACPX launch environment", () => {
expect(sibling).not.toHaveProperty(key);
}
const { PAPERCLIP_GITHUB_LAUNCHER_DIR: _launcher, ...withoutLauncher } = source;
expect(createSanitizedAcpxSpawnInput(withoutLauncher, "claude").env).toEqual({ PATH: helpers.PATH });
expect(createSanitizedAcpxSpawnInput(withoutLauncher, "claude").env).toEqual({ PATH: helpers.PATH, ...claudeLauncherEnv });
for (const [key, value] of Object.entries(helpers).filter(([key]) => key.startsWith("PAPERCLIP_GITHUB_"))) {
expect(configuredEnvironmentProjection({ [key]: value })).not.toHaveProperty(key);
expect(() => createSanitizedAcpxSpawnInput({ [key]: value, [CONFIGURED_ENVIRONMENT_KEYS]: JSON.stringify([key]) }, "claude")).toThrow("configured environment");
@@ -94,6 +103,7 @@ describe("ACPX launch environment", () => {
ANTHROPIC_BASE_URL: source.ANTHROPIC_BASE_URL,
ANTHROPIC_AUTH_TOKEN: "selected-key", CLAUDE_CODE_USE_BEDROCK: "1",
AWS_REGION: "us-east-1", AWS_BEARER_TOKEN_BEDROCK: "bedrock-key",
...claudeLauncherEnv,
});
expect(createSanitizedAcpxSpawnInput(source, "codex").env).toEqual({ PAPERCLIP_AI_PROVIDER_KEY: "codex-key" });
expect(createSanitizedAcpxSpawnInput(source, "cursor").env).toEqual({});
@@ -126,6 +136,7 @@ describe("ACPX launch environment", () => {
LC_ALL: "C.UTF-8",
HTTPS_PROXY: "https://proxy.example",
ANTHROPIC_API_KEY: "anthropic-secret",
...claudeLauncherEnv,
});
expect(createSanitizedAcpxSpawnInput(source, "pi").env).toEqual({
PATH: "/bin",
@@ -17,7 +17,9 @@ export const ACPX_CREDENTIAL_NAMES: Readonly<Record<QualifiedAcpxAgent, readonly
claude: ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN", "AWS_BEARER_TOKEN_BEDROCK"],
codex: ["OPENAI_API_KEY", "CODEX_API_KEY", "PAPERCLIP_AI_PROVIDER_KEY"],
};
export const CLAUDE_ROUTING_ENV_KEYS = ["ANTHROPIC_BASE_URL", "ANTHROPIC_CUSTOM_HEADERS", "CLAUDE_CODE_USE_BEDROCK", "AWS_REGION", "AWS_DEFAULT_REGION", "AWS_EC2_METADATA_DISABLED", "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "ANTHROPIC_MODEL", "ANTHROPIC_DEFAULT_OPUS_MODEL", "ANTHROPIC_DEFAULT_SONNET_MODEL", "ANTHROPIC_DEFAULT_HAIKU_MODEL", "CLAUDE_CODE_SUBAGENT_MODEL"] as const;
/** Enables Claude Code's TaskCreate/TaskUpdate tools, which feed the native plan. */
export const CLAUDE_TODO_TOOLS_ENV = "CLAUDE_CODE_ENABLE_TODO_TOOLS";
export const CLAUDE_ROUTING_ENV_KEYS =["ANTHROPIC_BASE_URL", "ANTHROPIC_CUSTOM_HEADERS", "CLAUDE_CODE_USE_BEDROCK", "AWS_REGION", "AWS_DEFAULT_REGION", "AWS_EC2_METADATA_DISABLED", "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "ANTHROPIC_MODEL", "ANTHROPIC_DEFAULT_OPUS_MODEL", "ANTHROPIC_DEFAULT_SONNET_MODEL", "ANTHROPIC_DEFAULT_HAIKU_MODEL", "CLAUDE_CODE_SUBAGENT_MODEL"] as const;
/** Keep the task projection consistent when excluding general IAM from Pi. */
function removePiIamCredentials(environment: NodeJS.ProcessEnv): void {
@@ -182,6 +184,10 @@ export function createSanitizedAcpxSpawnInput(
// The controller-to-sidecar document is staged under a lifetime fence. The
// native child receives the private token file, never its launch document.
if (agent === "antigravity") delete result.PAPERCLIP_ACPX_ANTIGRAVITY_AUTH_JSON_SECRET;
// Headless Claude Code withholds its task-list tools from current models.
// Paperclip renders that list as the turn plan (ACP `plan` -> `plan.updated`),
// so the launcher owns this value; a host setting cannot switch it off.
if (agent === "claude") result[CLAUDE_TODO_TOOLS_ENV] = "1";
if (agent === "claude" && isOpenRouterUrl(result.ANTHROPIC_BASE_URL)) {
result.ANTHROPIC_CUSTOM_HEADERS = openRouterClaudeHeaders(result.ANTHROPIC_CUSTOM_HEADERS);
}
@@ -6,6 +6,7 @@ import { evalProviderTransportOptions } from "../../cli/eval-provider-runtime.js
import { describe, expect, it } from "vitest";
import {
CODEX_UPDATE_PLAN_TOOL_CONFIG,
codexExecutableReadOnlyRoots,
codexNetworkReadOnlyRoots,
createIsolatedCodexAppServerArgs,
@@ -14,6 +15,17 @@ import {
} from "./codex-security-config.js";
describe("Codex security configuration", () => {
it("enables the update_plan checklist tool before policy overrides and the app-server subcommand", () => {
for (const disableWebSearch of [false, true]) {
const args = createIsolatedCodexAppServerArgs({ HOME: "/host/home" }, [], undefined, [], disableWebSearch);
const index = args.indexOf(CODEX_UPDATE_PLAN_TOOL_CONFIG);
expect(args[index - 1]).toBe("-c");
expect(index).toBeLessThan(args.indexOf("app-server"));
expect(args.at(-1)).toBe("app-server");
if (disableWebSearch) expect(args.slice(-3, -1)).toEqual(["-c", 'web_search="disabled"']);
}
});
it("uses the same actual pinned executable for native defaults and direct evals without a global Codex PATH", () => {
const command = resolvePinnedCodexCommand();
expect(evalProviderTransportOptions("codex").codexCommand).toBe(command);
@@ -248,6 +248,8 @@ function tomlString(value: string): string {
return JSON.stringify(value);
}
export const CODEX_UPDATE_PLAN_TOOL_CONFIG = "tools.update_plan.enabled=true";
export function createIsolatedCodexAppServerArgs(
source: NodeJS.ProcessEnv | undefined = undefined,
readOnlyRoots: string[] = [],
@@ -352,6 +354,10 @@ export function createIsolatedCodexAppServerArgs(
...(commandEnv.length > 0
? ["-c", `shell_environment_policy.set={${commandEnv}}`]
: []),
// Codex ships its update_plan checklist tool disabled. Paperclip renders it
// as the turn plan (`turn/plan/updated` -> `plan.updated`).
"-c",
CODEX_UPDATE_PLAN_TOOL_CONFIG,
"--disable",
"image_generation",
...(externalRunnerSandbox
+41 -2
View File
@@ -20,7 +20,7 @@ import type {
} from "@paperclipai/shared";
import { heartbeatsApi } from "@/api/heartbeats";
import { nativeRunEventsToTranscript } from "./transcript/native-run-events";
import { pendingConnectionIntentInteraction } from "@/fixtures/issueThreadInteractionFixtures";
import { genericPendingRequestConfirmationInteraction, pendingConnectionIntentInteraction } from "@/fixtures/issueThreadInteractionFixtures";
import { TaskChatScrollNavigation, useTaskChatScrollNavigation } from "./task-chat/scroll-navigation";
import { CoordinatorPresentation } from "./coordinator/CoordinatorPresentation";
import type { HeartbeatRunEvent } from "@paperclipai/shared";
@@ -4683,6 +4683,39 @@ describe("TaskChatThread mobile composer dock", () => {
});
describe("TaskChatThread live transcript", () => {
it("collapses the turn plan to its header while a saved interaction opens in the composer", () => {
nativeTranscriptState.transcriptByRun.set("run-plan-approval", [{
kind: "provider_activity",
ts: "2026-08-24T12:00:01.000Z",
family: "plan",
eventType: "plan.updated",
status: "running",
title: "Plan",
payload: {
planId: "turn-1",
steps: [
{ stepId: "one", body: "Inspect", status: "completed" },
{ stepId: "two", body: "Build", status: "in_progress" },
],
},
}]);
renderThread({
comments: [],
onAdd: async () => {},
issueStatus: "in_progress",
activeRun: {
id: "run-plan-approval", status: "running" as const, invocationSource: "issue" as const, triggerDetail: null,
startedAt: "2026-08-24T12:00:00.000Z", finishedAt: null, createdAt: "2026-08-24T12:00:00.000Z",
agentId: "agent-1", agentName: "Codex", adapterType: "paperclip_runner", runtimeMode: "native" as const,
},
interactions: [{ ...genericPendingRequestConfirmationInteraction, sourceRunId: "run-plan-approval" }],
});
const island = container.querySelector('[data-testid="task-chat-turn-status-island"]')!;
expect(island.querySelector('[data-testid="task-chat-turn-plan-headline"]')?.textContent).toBe("Build");
expect(island.querySelector('[aria-label="Within-turn checklist"]')).toBeNull();
expect(island.querySelector('[data-testid="task-chat-turn-plan-header"]')?.tagName).toBe("DIV");
});
it("places the turn status island below composer accessories and hides it when the turn is terminal", () => {
nativeTranscriptState.transcriptByRun.set("run-status", [
{
@@ -4756,7 +4789,13 @@ describe("TaskChatThread live transcript", () => {
const stack = container.querySelector(
'[data-testid="task-chat-composer-stack"]',
)!;
expect(island.textContent).toContain("Step 2 / 2");
expect(
island.querySelector('[data-testid="task-chat-turn-plan-headline"]')?.textContent,
).toBe("Build");
expect(
island.querySelector('[data-testid="task-chat-turn-plan-progress"]')?.textContent,
).toContain("1/2");
expect(island.querySelector('[aria-current="step"]')?.textContent).toContain("Build");
expect(island.textContent).toContain("1 file changed");
expect(accessory.compareDocumentPosition(island)).toBe(
Node.DOCUMENT_POSITION_FOLLOWING,
+6 -1
View File
@@ -3440,7 +3440,12 @@ export function TaskChatThread(props: TaskChatThreadProps) {
{composerAccessory}
{dismissError ? <p role="alert" className="text-sm text-destructive">{dismissError}</p> : null}
{tailTurnStatus ? (
<TaskChatTurnStatusIsland model={tailTurnStatus} />
<TaskChatTurnStatusIsland
key={tailRunId ?? "turn-status"}
model={tailTurnStatus}
defaultCollapsed={isMobile}
compact={composerTakeover != null || pendingRuntimeRequest != null}
/>
) : null}
<RunnerGoalWidget control={runnerGoal} />
<div
@@ -6,6 +6,8 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { TaskChatItem, TaskChatProviderActivityItem } from "./task-chat-model";
import {
TaskChatTurnStatusIsland,
taskChatTurnPlanRows,
taskChatTurnPlanSegment,
taskChatTurnStatusModel,
} from "./TaskChatTurnStatusIsland";
@@ -93,56 +95,122 @@ describe("TaskChatTurnStatusIsland", () => {
]);
});
it("renders a focusable plan capsule and opens the complete checklist", () => {
const model = taskChatTurnStatusModel([
function render(model: NonNullable<ReturnType<typeof taskChatTurnStatusModel>>, props: { defaultCollapsed?: boolean; compact?: boolean } = {}) {
act(() => root.render(<TaskChatTurnStatusIsland model={model} {...props} />));
return container.querySelector<HTMLElement>('[data-testid="task-chat-turn-status-island"]')!;
}
const checklist = () => container.querySelector('[aria-label="Within-turn checklist"]');
const header = () => container.querySelector<HTMLElement>('[data-testid="task-chat-turn-plan-header"]')!;
it("shows the checklist inline with the current step highlighted", () => {
const island = render(taskChatTurnStatusModel([
plan([
{ id: "one", label: "Inspect", status: "completed" },
{ id: "two", label: "Build the island", status: "in_progress" },
{ id: "three", label: "Verify", status: "pending" },
]),
workspace,
])!;
act(() => root.render(<TaskChatTurnStatusIsland model={model} />));
const island = container.querySelector('[data-testid="task-chat-turn-status-island"]') as HTMLButtonElement;
expect(island.tagName).toBe("BUTTON");
expect(island.textContent).toContain("Step 2 / 3");
expect(island.textContent).toContain("2 files changed");
expect(island.getAttribute("aria-label")).toContain("Build the island");
act(() => island.focus());
const checklist = document.body.querySelector('[aria-label="Within-turn checklist"]');
expect(checklist?.textContent).toContain("Inspect");
expect(checklist?.textContent).toContain("Build the island");
expect(checklist?.textContent).toContain("Verify");
expect(checklist?.querySelector('[aria-current="step"]')?.textContent).toContain("Build the island");
});
it("renders diff-only status without an empty interactive popover", () => {
const model = taskChatTurnStatusModel([workspace])!;
act(() => root.render(<TaskChatTurnStatusIsland model={model} />));
const island = container.querySelector('[data-testid="task-chat-turn-status-island"]') as HTMLElement;
expect(island.tagName).toBe("DIV");
expect(island.getAttribute("role")).toBe("status");
])!);
expect(island.getAttribute("data-plan-state")).toBe("active");
expect(container.querySelector('[data-testid="task-chat-turn-plan-headline"]')?.textContent).toBe("Build the island");
expect(container.querySelector('[data-testid="task-chat-turn-plan-progress"]')?.textContent).toContain("1/3");
expect(island.textContent).toContain("+12");
expect(document.body.querySelector('[aria-label="Within-turn checklist"]')).toBeNull();
expect(checklist()?.textContent).toContain("Inspect");
expect(checklist()?.textContent).toContain("Verify");
expect(checklist()?.querySelector('[aria-current="step"]')?.textContent).toContain("Build the island");
expect(header().getAttribute("aria-expanded")).toBe("true");
});
it("toggles the checklist for touch/click input and closes it with Escape", () => {
const model = taskChatTurnStatusModel([plan([
it("collapses and reopens from the header", () => {
render(taskChatTurnStatusModel([plan([
{ id: "one", label: "Inspect", status: "completed" },
{ id: "two", label: "Build", status: "in_progress" },
])])!);
expect(document.getElementById(header().getAttribute("aria-controls")!)).toBe(checklist());
act(() => header().click());
expect(checklist()).toBeNull();
expect(header().getAttribute("aria-expanded")).toBe("false");
expect(header().hasAttribute("aria-controls")).toBe(false);
act(() => header().click());
expect(checklist()).not.toBeNull();
});
it("auto-collapses a completed plan but lets the viewer reopen it", () => {
render(taskChatTurnStatusModel([plan([
{ id: "one", label: "Inspect", status: "completed" },
{ id: "two", label: "Build", status: "completed" },
])])!);
expect(container.querySelector('[data-testid="task-chat-turn-plan-headline"]')?.textContent).toBe("All 2 steps done");
expect(checklist()).toBeNull();
act(() => header().click());
expect(checklist()?.querySelector('[aria-current="step"]')).toBeNull();
});
it("headlines blocked and not-yet-started plans", () => {
render(taskChatTurnStatusModel([plan([
{ id: "one", label: "Inspect", status: "completed" },
{ id: "two", label: "Run tests", status: "blocked" },
])])!);
expect(container.querySelector('[data-testid="task-chat-turn-plan-headline"]')?.textContent).toBe("Blocked: Run tests");
expect(container.querySelector('[data-plan-state]')?.getAttribute("data-plan-state")).toBe("blocked");
render(taskChatTurnStatusModel([plan([
{ id: "a", label: "Inspect", status: "pending" },
{ id: "b", label: "Build", status: "pending" },
], 2)])!);
expect(container.querySelector('[data-testid="task-chat-turn-plan-headline"]')?.textContent).toBe("Up next: Inspect");
});
it("renders header-only for single steps, compact mode, and collapsed-by-default", () => {
const twoSteps = taskChatTurnStatusModel([plan([
{ id: "one", label: "Inspect", status: "in_progress" },
{ id: "two", label: "Build", status: "pending" },
])])!;
act(() => root.render(<TaskChatTurnStatusIsland model={model} />));
const island = container.querySelector<HTMLButtonElement>('[data-testid="task-chat-turn-status-island"]')!;
render(taskChatTurnStatusModel([plan([{ id: "one", label: "Answer", status: "in_progress" }])])!);
expect(checklist()).toBeNull();
expect(header().tagName).toBe("DIV");
act(() => island.click());
expect(document.body.querySelector('[aria-label="Within-turn checklist"]')).not.toBeNull();
act(() => island.click());
expect(document.body.querySelector('[aria-label="Within-turn checklist"]')).toBeNull();
act(() => root.unmount());
root = createRoot(container);
render(twoSteps, { compact: true });
expect(checklist()).toBeNull();
expect(header().tagName).toBe("DIV");
act(() => island.click());
act(() => document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })));
expect(document.body.querySelector('[aria-label="Within-turn checklist"]')).toBeNull();
act(() => root.unmount());
root = createRoot(container);
render(twoSteps, { defaultCollapsed: true });
expect(checklist()).toBeNull();
expect(header().getAttribute("aria-expanded")).toBe("false");
});
it("windows long plans around the current step and expands on demand", () => {
const labels = Array.from({ length: 12 }, (_, index) => `Step ${index + 1}`);
const steps = labels.map((label, index) => ({
id: `s${index}`,
label,
status: index < 6 ? "completed" as const : index === 6 ? "in_progress" as const : "pending" as const,
}));
const segment = taskChatTurnPlanSegment(steps);
const rows = taskChatTurnPlanRows(segment, false);
expect(rows[0]).toEqual({ kind: "earlier", count: 5, allCompleted: true });
expect(rows.filter((row) => row.kind === "step")).toHaveLength(5);
expect(rows.at(-1)).toEqual({ kind: "more", count: 2 });
expect(taskChatTurnPlanRows(segment, true)).toHaveLength(12);
expect(taskChatTurnPlanRows(taskChatTurnPlanSegment(steps.slice(0, 7)), false)).toHaveLength(7);
render(taskChatTurnStatusModel([plan(steps)])!);
expect(checklist()?.querySelectorAll("[data-step-status]")).toHaveLength(5);
act(() => container.querySelector<HTMLButtonElement>('[data-testid="task-chat-turn-plan-more"]')!.click());
expect(checklist()?.querySelectorAll("[data-step-status]")).toHaveLength(12);
});
it("renders diff-only status as a pill without a checklist", () => {
const island = render(taskChatTurnStatusModel([workspace])!);
expect(island.tagName).toBe("DIV");
expect(island.getAttribute("role")).toBe("status");
expect(island.textContent).toContain("2 files changed");
expect(island.textContent).toContain("+12");
expect(checklist()).toBeNull();
});
it("hides empty snapshots", () => {
@@ -1,15 +1,11 @@
import { useEffect, useRef, useState } from "react";
import { useId, useState } from "react";
import {
CheckCircle2,
ChevronDown,
Circle,
CircleAlert,
LoaderCircle,
} from "lucide-react";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import type {
TaskChatItem,
@@ -18,13 +14,36 @@ import type {
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 =
| {
kind: "plan";
steps: TaskChatProtocolStep[];
currentStepIndex: number;
complete: boolean;
}
| TaskChatTurnPlanSegment
| {
kind: "workspace_diff";
files: number;
@@ -36,6 +55,8 @@ 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;
@@ -68,17 +89,19 @@ function currentStepIndex(steps: readonly TaskChatProtocolStep[]): number {
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({
kind: "plan",
steps: plan.steps,
currentStepIndex: currentStepIndex(plan.steps),
complete: plan.steps.every((step) => step.status === "completed"),
});
}
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) {
@@ -92,13 +115,60 @@ export function taskChatTurnStatusModel(items: readonly TaskChatItem[]): TaskCha
return segments.length > 0 ? { segments } : null;
}
function planIcon(plan: Extract<TaskChatTurnStatusSegment, { kind: "plan" }>) {
const status = plan.steps[plan.currentStepIndex]?.status;
if (plan.complete) return <CheckCircle2 className="size-4 text-(--status-task-icon-done)" aria-hidden />;
if (status === "blocked" || status === "failed") {
return <CircleAlert className="size-4 text-(--status-task-icon-blocked)" aria-hidden />;
/** 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"),
});
}
return <LoaderCircle className="size-4 text-(--status-agent-running) motion-safe:animate-spin" aria-hidden />;
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) {
@@ -108,173 +178,227 @@ function checklistIcon(step: TaskChatProtocolStep) {
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 (step.status === "blocked" || step.status === "failed") {
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 IslandBody({ model }: { model: TaskChatTurnStatusModel }) {
const plan = model.segments.find(
(segment): segment is Extract<TaskChatTurnStatusSegment, { kind: "plan" }> => segment.kind === "plan",
);
const workspace = model.segments.find(
(segment): segment is Extract<TaskChatTurnStatusSegment, { kind: "workspace_diff" }> => segment.kind === "workspace_diff",
);
function WorkspaceStats({ workspace, compact }: { workspace: WorkspaceSegment; compact?: boolean }) {
return (
<span className="flex min-w-0 items-center gap-2">
{plan ? (
<span className="flex shrink-0 items-center gap-2">
{planIcon(plan)}
<span className="font-mono text-sm tabular-nums" aria-live="polite" aria-atomic="true">
Step {plan.currentStepIndex + 1} / {plan.steps.length}
<span className="sr-only">: {plan.steps[plan.currentStepIndex]?.label}</span>
</span>
<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}
{plan && workspace ? <span className="text-muted-foreground" aria-hidden>·</span> : null}
{workspace ? (
<span className="flex min-w-0 items-center gap-2">
<span className="min-w-0 truncate text-sm text-muted-foreground">
{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}
{workspace.deletions != null ? (
<span className="shrink-0 font-mono text-sm tabular-nums text-(--status-task-icon-blocked)">
−{workspace.deletions}
</span>
) : null}
</span>
);
}
function islandLabel(model: TaskChatTurnStatusModel): string {
const parts: string[] = [];
for (const segment of model.segments) {
if (segment.kind === "plan") {
const step = segment.steps[segment.currentStepIndex];
parts.push(`Step ${segment.currentStepIndex + 1} of ${segment.steps.length}${step ? `: ${step.label}` : ""}`);
} else {
parts.push(fileCountLabel(segment.files));
if (segment.additions != null) parts.push(`${segment.additions} additions`);
if (segment.deletions != null) parts.push(`${segment.deletions} deletions`);
}
}
return parts.join(", ");
}
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 HOVER_CLOSE_GRACE_MS = 100;
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";
export function TaskChatTurnStatusIsland({ model }: { model: TaskChatTurnStatusModel }) {
const plan = model.segments.find(
(segment): segment is Extract<TaskChatTurnStatusSegment, { kind: "plan" }> => segment.kind === "plan",
);
const [open, setOpen] = useState(false);
const [pinned, setPinned] = useState(false);
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => () => {
if (closeTimer.current) clearTimeout(closeTimer.current);
}, []);
const cancelClose = () => {
if (closeTimer.current) clearTimeout(closeTimer.current);
closeTimer.current = null;
};
const scheduleClose = () => {
cancelClose();
if (pinned) return;
closeTimer.current = setTimeout(() => setOpen(false), HOVER_CLOSE_GRACE_MS);
};
if (!plan) {
function PlanRow({ row, onShowAll }: { row: TaskChatTurnPlanRow; onShowAll: () => void }) {
if (row.kind === "earlier") {
return (
<div className={ISLAND_CLASS_NAME} role="status" data-testid="task-chat-turn-status-island">
<IslandBody model={model} />
</div>
<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>
);
}
return (
<Popover
open={open}
onOpenChange={(nextOpen) => {
setOpen(nextOpen);
if (!nextOpen) setPinned(false);
}}
>
<PopoverTrigger asChild>
<button
type="button"
aria-label={islandLabel(model)}
className={cn(
ISLAND_CLASS_NAME,
"cursor-pointer transition-colors hover:bg-muted/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background motion-reduce:transition-none",
)}
data-testid="task-chat-turn-status-island"
onPointerEnter={(event) => {
if (event.pointerType !== "mouse") return;
cancelClose();
setOpen(true);
}}
onPointerLeave={(event) => {
if (event.pointerType === "mouse") scheduleClose();
}}
onFocus={() => setOpen(true)}
onClick={(event) => {
event.preventDefault();
const nextPinned = !pinned;
setPinned(nextPinned);
setOpen(nextPinned);
}}
>
<IslandBody model={model} />
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>
</PopoverTrigger>
<PopoverContent
side="top"
align="center"
sideOffset={8}
className="w-(--sz-turn-status-popover) p-2"
aria-label="Turn plan"
onPointerEnter={(event) => {
if (event.pointerType === "mouse") cancelClose();
}}
onPointerLeave={(event) => {
if (event.pointerType === "mouse") scheduleClose();
}}
onEscapeKeyDown={() => setPinned(false)}
onInteractOutside={() => setPinned(false)}
</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"
>
<ol className="flex flex-col gap-1" aria-label="Within-turn checklist">
{plan.steps.map((step, index) => (
<li
key={step.id}
className={cn(
"flex items-start gap-2 rounded-md px-2 py-1.5 text-sm leading-5",
index === plan.currentStepIndex && "bg-muted",
step.status === "completed" && "text-muted-foreground",
)}
aria-current={index === plan.currentStepIndex ? "step" : undefined}
>
{checklistIcon(step)}
<span className="min-w-0 break-words">{step.label}</span>
{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>
</PopoverContent>
</Popover>
) : 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>
);
}
@@ -404,7 +404,8 @@ function providerActivityItem(
const status: TaskChatProtocolStep["status"] =
rawStatus === "in_progress" ||
rawStatus === "completed" ||
rawStatus === "blocked"
rawStatus === "blocked" ||
rawStatus === "failed"
? rawStatus
: "pending";
return {
@@ -87,21 +87,11 @@ describe("canonical native event UI boundary corpus", () => {
expect(transcript).toContainEqual(
expect.objectContaining({
kind: "tool_call",
toolUseId: `plan:${fixture.expected.planId}`,
name: "plan",
input: {
eventType: "plan.updated",
summary: "Keep every boundary lossless.",
},
}),
);
expect(transcript).toContainEqual(
expect.objectContaining({
kind: "tool_result",
toolUseId: `plan:${fixture.expected.planId}`,
content: "Keep every boundary lossless.",
isError: false,
kind: "provider_activity",
family: "plan",
eventType: "plan.updated",
summary: "Keep every boundary lossless.",
payload: expect.objectContaining({ planId: fixture.expected.planId }),
}),
);
@@ -118,6 +118,57 @@ describe("provider notice presentation", () => {
});
});
describe("native plan presentation", () => {
it("keeps plan snapshots as plan activity so the turn plan card can read their steps", () => {
const plan = (seq: number, statuses: string[]) => event(seq, "plan.updated", {
schema: "paperclip.plan.updated.v1",
planId: "turn-1",
revision: 1,
explanation: null,
steps: statuses.map((status, index) => ({ stepId: `step-${index + 1}`, body: `Step ${index + 1}`, status })),
complete: statuses.every((status) => status === "completed"),
syncStatus: "not_applicable",
documentRevision: null,
});
const transcript = nativeRunEventsToTranscript([
plan(1, ["in_progress", "pending"]),
plan(2, ["completed", "completed"]),
]);
expect(transcript).toEqual([
expect.objectContaining({ kind: "provider_activity", family: "plan", status: "running", title: "Plan" }),
expect.objectContaining({ kind: "provider_activity", family: "plan", status: "completed" }),
]);
const items = transcriptToTaskChatItems(transcript, { runId: RUN_ID, agentName: "Claude", running: true });
const plans = items.filter((item) => item.kind === "protocol" && item.surface === "provider_activity" && item.family === "plan");
expect(plans).toHaveLength(1);
expect(plans[0]).toMatchObject({ steps: [
{ label: "Step 1", status: "completed" },
{ label: "Step 2", status: "completed" },
] });
});
it("keeps a blocked plan blocked in settled history instead of marking it interrupted", () => {
const transcript = nativeRunEventsToTranscript([event(1, "plan.updated", {
schema: "paperclip.plan.updated.v1",
planId: "turn-1",
revision: 1,
explanation: null,
steps: [
{ stepId: "step-1", body: "Inspect", status: "completed" },
{ stepId: "step-2", body: "Run tests", status: "blocked" },
],
complete: false,
syncStatus: "not_applicable",
documentRevision: null,
})]);
expect(transcript[0]).toMatchObject({ kind: "provider_activity", family: "plan", status: "failed" });
const settled = transcriptToTaskChatItems(transcript, { runId: RUN_ID, agentName: "Claude", running: false });
const plan = settled.find((item) => item.kind === "protocol" && item.surface === "provider_activity" && item.family === "plan");
expect(plan).toMatchObject({ status: "failed" });
expect(JSON.stringify(plan)).not.toContain("Interrupted before completion");
});
});
describe("nativeRunEventsToTranscript", () => {
describe("accepted response-wake authority", () => {
function fixture() {
@@ -841,6 +841,29 @@ export function nativeRunEventsToTranscript(events: readonly HeartbeatRunEvent[]
continue;
}
// The plan carries its steps to the turn plan card, so keep the full
// snapshot instead of flattening it into a generic tool row.
if (event.eventType === "plan.updated" && payload.schema === "paperclip.plan.updated.v1" && text(payload.planId)) {
entries.push({
kind: "provider_activity",
ts,
family: "plan",
eventType: event.eventType,
status: payload.complete === true
? "completed"
: Array.isArray(payload.steps) && payload.steps.some((step) => {
const status = record(step)?.status;
return status === "blocked" || status === "failed";
})
? "failed"
: "running",
title: "Plan",
summary: text(payload.explanation)?.trim() || "Plan",
payload,
});
continue;
}
const providerActivity = providerActivityPresentation(event, payload);
if (providerActivity) {
if (!startedToolIds.has(providerActivity.id)) {
+2 -2
View File
@@ -580,9 +580,9 @@
--tc-shell-max-w: 60rem;
--sz-66px: 66px;
/* Active-turn status island and its checklist overlay. */
/* Active-turn status island and its docked plan checklist. */
--sz-turn-status-island: min(100%, 42rem);
--sz-turn-status-popover: min(32rem, calc(100vw - 2rem));
--sz-turn-plan-list-max-h: min(40dvh, 22rem);
/* Mobile pinned-composer bottom offset (PAP-495). The visible-nav offset
leaves 6px below the dock; its pb-1 adds 4px for a 10px card-to-nav gap.
@@ -0,0 +1,300 @@
import { useEffect, useState, type ReactNode } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { IssueThreadInteraction, IssueWorkMode } from "@paperclipai/shared";
import { TaskChatComposer } from "@/components/task-chat/TaskChatComposer";
import { TaskChatComposerDock } from "@/components/task-chat/TaskChatComposerDock";
import { TaskChatInteractionCard } from "@/components/task-chat/TaskChatInteractionCard";
import {
issueThreadInteractionFixtureMeta,
pendingAskUserQuestionsInteraction,
} from "@/fixtures/issueThreadInteractionFixtures";
import {
TaskChatTurnStatusIsland,
taskChatTurnPlanSegment,
type TaskChatTurnStatusModel,
} from "@/components/task-chat/TaskChatTurnStatusIsland";
import type { TaskChatProtocolStep } from "@/components/task-chat/task-chat-model";
import { composerAgentAppearance, composerAgents } from "../../prototypes/composer-model-picker/fixtures";
import { storybookAgentMap } from "../../fixtures/paperclipData";
type Status = TaskChatProtocolStep["status"];
const FIX_STEPS = [
"Reproduce the duplicate webhook delivery",
"Trace the retry path in the delivery worker",
"Make delivery idempotent on the event id",
"Add a regression test for retried deliveries",
"Run the webhook test suite",
];
function steps(labels: readonly string[], statuses: readonly Status[]): TaskChatProtocolStep[] {
return labels.map((label, index) => ({ id: `step-${index + 1}`, label, status: statuses[index] ?? "pending" }));
}
function planModel(planSteps: TaskChatProtocolStep[], diff?: { files: number; additions: number; deletions: number }): TaskChatTurnStatusModel {
return {
segments: [
taskChatTurnPlanSegment(planSteps),
...(diff ? [{ kind: "workspace_diff" as const, ...diff }] : []),
],
};
}
const LONG_STEPS = [
"Read the migration guide for the new SDK",
"Inventory every call site of the legacy client",
"Update the auth handshake",
"Port the streaming reader",
"Port the batch uploader",
"Replace retry helpers with SDK retries",
"Update fixtures for the new response shape",
"Fix type errors across the server package",
"Run unit tests",
"Run the end-to-end smoke suite",
"Remove the legacy client dependency",
"Write the upgrade note",
];
const STATES = {
planned: planModel(steps(FIX_STEPS, ["pending", "pending", "pending", "pending", "pending"])),
inProgress: planModel(steps(FIX_STEPS, ["completed", "in_progress", "pending", "pending", "pending"])),
withDiff: planModel(
steps(FIX_STEPS, ["completed", "completed", "completed", "in_progress", "pending"]),
{ files: 3, additions: 48, deletions: 12 },
),
blocked: planModel(steps(FIX_STEPS, ["completed", "completed", "completed", "completed", "blocked"]), {
files: 3,
additions: 61,
deletions: 12,
}),
complete: planModel(steps(FIX_STEPS, ["completed", "completed", "completed", "completed", "completed"]), {
files: 3,
additions: 61,
deletions: 12,
}),
long: planModel(steps(LONG_STEPS, LONG_STEPS.map((_, index) => (index < 6 ? "completed" : index === 6 ? "in_progress" : "pending")))),
singleStep: planModel(steps(["Answer the question about the billing export"], ["in_progress"])),
diffOnly: { segments: [{ kind: "workspace_diff" as const, files: 2, additions: 14, deletions: 3 }] },
} satisfies Record<string, TaskChatTurnStatusModel>;
const agentMap = new Map(composerAgents.map((agent) => [agent.id, {
id: agent.id,
name: agent.name,
appearance: composerAgentAppearance(agent.id),
}]));
const assignees = composerAgents.map((agent) => ({ id: `agent:${agent.id}`, label: agent.name }));
const environmentQuestion = {
...pendingAskUserQuestionsInteraction,
id: "interaction-plan-environment",
title: "Question",
payload: {
...pendingAskUserQuestionsInteraction.payload,
title: "Question",
questions: [{
...pendingAskUserQuestionsInteraction.payload.questions[0],
id: "smoke-environment",
prompt: "Which environment should I run the webhook suite against?",
helpText: undefined,
options: [
{ id: "staging", label: "Staging" },
{ id: "local", label: "Local" },
],
}],
},
} satisfies IssueThreadInteraction;
/**
* The production composer dock with the production composer, mirroring the
* order in TaskChatThread: turn status island, then the composer stack. A
* pending question uses the composer's own takeover, as in the live thread.
*/
function Dock({ children, question = false, mobile = false }: { children: ReactNode; question?: boolean; mobile?: boolean }) {
const [workMode, setWorkMode] = useState<IssueWorkMode>("standard");
const [questionOpen, setQuestionOpen] = useState(question);
const takeoverContent = (
<TaskChatInteractionCard
item={{ id: `interaction:${environmentQuestion.id}`, kind: "interaction", interaction: environmentQuestion }}
presentation="takeover"
agentMap={storybookAgentMap}
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
onSubmitInteractionAnswers={async () => setQuestionOpen(false)}
/>
);
return (
<TaskChatComposerDock mobile={mobile} streamlined={false}>
{children}
<TaskChatComposer
onAdd={async () => {}}
onStop={async () => {}}
workMode={workMode}
onWorkModeChange={setWorkMode}
enableReassign
currentAssigneeValue="agent:codex"
reassignOptions={assignees}
agentMap={agentMap}
issueStatus="in_progress"
mobile={mobile}
takeover={questionOpen ? {
id: environmentQuestion.id,
label: environmentQuestion.title ?? "Question",
pendingCount: 1,
content: takeoverContent,
onDismiss: () => setQuestionOpen(false),
onSkip: () => setQuestionOpen(false),
inlineSkip: true,
} : null}
/>
</TaskChatComposerDock>
);
}
function StateCard({ title, rule, children }: { title: string; rule: string; children: ReactNode }) {
return (
<section className="flex flex-col gap-3">
<div>
<h3 className="text-sm font-semibold">{title}</h3>
<p className="text-xs text-muted-foreground">{rule}</p>
</div>
{children}
</section>
);
}
function Page({ children, narrow }: { children: ReactNode; narrow?: boolean }) {
return (
<div className="min-h-screen bg-background p-6 text-foreground">
<div className={narrow ? "mx-auto w-full max-w-sm" : "mx-auto w-full max-w-2xl"}>{children}</div>
</div>
);
}
function AllStates() {
return (
<Page>
<div className="flex flex-col gap-10">
<header>
<h2 className="text-lg font-semibold">Agent plan</h2>
<p className="text-sm text-muted-foreground">
The agent&apos;s own checklist for the current turn, docked above the composer. It comes from the
provider-neutral <code className="font-mono text-xs">plan.updated</code> event, so Claude task tools, Codex
plan updates, Cursor todos and any ACP <code className="font-mono text-xs">plan</code> update look the same.
</p>
</header>
<StateCard title="Just planned" rule="Nothing started yet. The headline shows the first step as “Up next”.">
<Dock><TaskChatTurnStatusIsland model={STATES.planned} /></Dock>
</StateCard>
<StateCard title="Working" rule="Done steps are checked and muted, the current step is highlighted with a spinner, upcoming steps are open circles.">
<Dock><TaskChatTurnStatusIsland model={STATES.inProgress} /></Dock>
</StateCard>
<StateCard title="Working, with file changes" rule="File-change totals join the header instead of a second pill.">
<Dock><TaskChatTurnStatusIsland model={STATES.withDiff} /></Dock>
</StateCard>
<StateCard title="Blocked" rule="A blocked or failed step becomes the headline and the progress bar turns red.">
<Dock><TaskChatTurnStatusIsland model={STATES.blocked} /></Dock>
</StateCard>
<StateCard title="All done, final answer still streaming" rule="Collapses on its own once every step is done. Click the header to reopen it. The card disappears when the run ends.">
<Dock><TaskChatTurnStatusIsland model={STATES.complete} /></Dock>
</StateCard>
<StateCard title="Long plan" rule="Plans over 7 steps show a window around the current step, with rows for earlier and remaining steps. Click either to show all.">
<Dock><TaskChatTurnStatusIsland model={STATES.long} /></Dock>
</StateCard>
<StateCard title="Single step" rule="A one-step list adds nothing, so only the header shows.">
<Dock><TaskChatTurnStatusIsland model={STATES.singleStep} /></Dock>
</StateCard>
<StateCard title="Waiting on the viewer" rule="While a question or approval is open, the plan shrinks to its header so the decision stays in focus.">
<Dock question>
<TaskChatTurnStatusIsland model={STATES.inProgress} compact />
</Dock>
</StateCard>
<StateCard title="No plan, file changes only" rule="Without a plan, the existing file-change pill still shows.">
<Dock><TaskChatTurnStatusIsland model={STATES.diffOnly} /></Dock>
</StateCard>
<StateCard title="Hidden" rule="Nothing renders when the turn has no plan steps and no file changes, or once the run has finished, failed or been cancelled.">
<Dock>{null}</Dock>
</StateCard>
</div>
</Page>
);
}
/** Advances through the plan the way a live run would. */
function LiveRun() {
const [tick, setTick] = useState(0);
const total = FIX_STEPS.length;
useEffect(() => {
const timer = setInterval(() => setTick((value) => (value + 1) % (total * 2 + 3)), 1400);
return () => clearInterval(timer);
}, [total]);
const completed = Math.min(total, Math.floor(tick / 2));
const statuses: Status[] = FIX_STEPS.map((_, index) =>
index < completed ? "completed" : index === completed && tick % 2 === 1 ? "in_progress" : "pending",
);
const model = planModel(steps(FIX_STEPS, statuses), completed > 1 ? { files: completed, additions: completed * 13, deletions: completed * 2 } : undefined);
return (
<Page>
<p className="mb-4 text-xs text-muted-foreground">Simulated live run: plan updates arrive every 1.4 s and the loop restarts after the plan finishes.</p>
<Dock><TaskChatTurnStatusIsland model={model} /></Dock>
</Page>
);
}
const meta = {
id: "product-task-chat-agent-plan",
title: "Tasks and conversations/Task creation and lifecycle/Product/Task chat/Agent plan",
component: TaskChatTurnStatusIsland,
args: { model: STATES.inProgress },
parameters: {
layout: "fullscreen",
docs: {
description: {
component:
"The agent's checklist for the current turn, docked above the composer. It is built from canonical `plan.updated` provider events, so every harness that reports a native plan renders the same way.",
},
},
paperclip: {
"capabilities": [
"tasks/discussion",
"runs-adapters/runs"
],
"implementation": "production",
"boundary": "fixtures",
"notes": "Renders the shipped turn-status island inside the production composer dock, composer, and question takeover with fixture plan snapshots. Live provider event delivery is not exercised."
},
},
render: (args) => (
<Page>
<Dock><TaskChatTurnStatusIsland {...args} /></Dock>
</Page>
),
} satisfies Meta<typeof TaskChatTurnStatusIsland>;
export default meta;
type Story = StoryObj<typeof meta>;
export const AllStatesGallery: Story = { render: () => <AllStates /> };
export const LiveRunSimulation: Story = { render: () => <LiveRun /> };
export const JustPlanned: Story = { args: { model: STATES.planned } };
export const Working: Story = { args: { model: STATES.inProgress } };
export const WorkingWithFileChanges: Story = { args: { model: STATES.withDiff } };
export const Blocked: Story = { args: { model: STATES.blocked } };
export const AllDoneCollapsed: Story = { args: { model: STATES.complete } };
export const LongPlanWindowed: Story = { args: { model: STATES.long } };
export const SingleStepHeaderOnly: Story = { args: { model: STATES.singleStep } };
export const WaitingOnViewer: Story = {
args: { model: STATES.inProgress, compact: true },
render: (args) => (
<Page>
<Dock question><TaskChatTurnStatusIsland {...args} /></Dock>
</Page>
),
};
export const MobileStartsCollapsed: Story = {
args: { model: STATES.withDiff, defaultCollapsed: true },
render: (args) => (
<Page narrow>
<Dock mobile><TaskChatTurnStatusIsland {...args} /></Dock>
</Page>
),
};