mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 01:57:31 +01:00
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:
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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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'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>
|
||||
),
|
||||
};
|
||||
Reference in new issue
Block a user