diff --git a/packages/paperclip-runner/src/drivers/acpx/environment.test.ts b/packages/paperclip-runner/src/drivers/acpx/environment.test.ts index 641cd1eb5cb..7b0d756b4c1 100644 --- a/packages/paperclip-runner/src/drivers/acpx/environment.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/environment.test.ts @@ -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", diff --git a/packages/paperclip-runner/src/drivers/acpx/environment.ts b/packages/paperclip-runner/src/drivers/acpx/environment.ts index 8b5a9a0d68a..e0489522c4e 100644 --- a/packages/paperclip-runner/src/drivers/acpx/environment.ts +++ b/packages/paperclip-runner/src/drivers/acpx/environment.ts @@ -17,7 +17,9 @@ export const ACPX_CREDENTIAL_NAMES: Readonly `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); } diff --git a/packages/paperclip-runner/src/drivers/codex/codex-security-config.test.ts b/packages/paperclip-runner/src/drivers/codex/codex-security-config.test.ts index 2d66ad81c6e..b1b9f42cb3a 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-security-config.test.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-security-config.test.ts @@ -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); diff --git a/packages/paperclip-runner/src/drivers/codex/codex-security-config.ts b/packages/paperclip-runner/src/drivers/codex/codex-security-config.ts index 78d7d4d2f5d..0121dce2e86 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-security-config.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-security-config.ts @@ -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 diff --git a/ui/src/components/TaskChatThread.test.tsx b/ui/src/components/TaskChatThread.test.tsx index 4ca0da7760d..2add6c0bdbc 100644 --- a/ui/src/components/TaskChatThread.test.tsx +++ b/ui/src/components/TaskChatThread.test.tsx @@ -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, diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx index 7d2451275af..9cc995370b4 100644 --- a/ui/src/components/TaskChatThread.tsx +++ b/ui/src/components/TaskChatThread.tsx @@ -3440,7 +3440,12 @@ export function TaskChatThread(props: TaskChatThreadProps) { {composerAccessory} {dismissError ?

{dismissError}

: null} {tailTurnStatus ? ( - + ) : null}
{ ]); }); - it("renders a focusable plan capsule and opens the complete checklist", () => { - const model = taskChatTurnStatusModel([ + function render(model: NonNullable>, props: { defaultCollapsed?: boolean; compact?: boolean } = {}) { + act(() => root.render()); + return container.querySelector('[data-testid="task-chat-turn-status-island"]')!; + } + const checklist = () => container.querySelector('[aria-label="Within-turn checklist"]'); + const header = () => container.querySelector('[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()); - 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()); - 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()); - const island = container.querySelector('[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('[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", () => { diff --git a/ui/src/components/task-chat/TaskChatTurnStatusIsland.tsx b/ui/src/components/task-chat/TaskChatTurnStatusIsland.tsx index 5a25bba2bb7..5a82f7008f8 100644 --- a/ui/src/components/task-chat/TaskChatTurnStatusIsland.tsx +++ b/ui/src/components/task-chat/TaskChatTurnStatusIsland.tsx @@ -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; + 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) { - const status = plan.steps[plan.currentStepIndex]?.status; - if (plan.complete) return ; - if (status === "blocked" || status === "failed") { - return ; +/** 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 ; + 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 ; + if (status === "blocked" || status === "failed") { + return ; + } + if (status === "pending") return ; + return ; } function checklistIcon(step: TaskChatProtocolStep) { @@ -108,173 +178,227 @@ function checklistIcon(step: TaskChatProtocolStep) { if (step.status === "in_progress") { return ; } - if (step.status === "blocked" || step.status === "failed") { + if (isProblem(step)) { return ; } return ; } +const STEP_STATUS_LABEL: Record = { + 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 => segment.kind === "plan", - ); - const workspace = model.segments.find( - (segment): segment is Extract => segment.kind === "workspace_diff", - ); +function WorkspaceStats({ workspace, compact }: { workspace: WorkspaceSegment; compact?: boolean }) { return ( - {plan ? ( - - {planIcon(plan)} - - Step {plan.currentStepIndex + 1} / {plan.steps.length} - : {plan.steps[plan.currentStepIndex]?.label} - + + {fileCountLabel(workspace.files)} + + {workspace.additions != null ? ( + + +{workspace.additions} ) : null} - {plan && workspace ? · : null} - {workspace ? ( - - - {fileCountLabel(workspace.files)} - - {workspace.additions != null ? ( - - +{workspace.additions} - - ) : null} - {workspace.deletions != null ? ( - - −{workspace.deletions} - - ) : null} + {workspace.deletions != null ? ( + + −{workspace.deletions} ) : null} ); } -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 => segment.kind === "plan", - ); - const [open, setOpen] = useState(false); - const [pinned, setPinned] = useState(false); - const closeTimer = useRef | 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 ( -
- -
+
  • + +
  • ); } - - return ( - { - setOpen(nextOpen); - if (!nextOpen) setPinned(false); - }} - > - - - - { - if (event.pointerType === "mouse") cancelClose(); - }} - onPointerLeave={(event) => { - if (event.pointerType === "mouse") scheduleClose(); - }} - onEscapeKeyDown={() => setPinned(false)} - onInteractOutside={() => setPinned(false)} + + ); + } + const { step, current } = row; + return ( +
  • + {checklistIcon(step)} + + {step.label} + ({STEP_STATUS_LABEL[step.status]}) + +
  • + ); +} + +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(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)} + -
      - {plan.steps.map((step, index) => ( -
    1. - {checklistIcon(step)} - {step.label} + {headline} + + {workspace ? : null} + + {completed}/{total} + {completed} of {total} steps done + + + ); + + return ( +
      + {listable && !compact ? ( + + ) : ( +
      + {headerContent} +
      + )} +
      +
      0 ? (completed / total) * 100 : 0}%` }} + /> +
      + {expanded ? ( +
        + {rows.map((row) => ( + setShowAll(true)} + /> + ))} + {showAll && total > PLAN_FULL_LIST_LIMIT ? ( +
      1. +
      2. - ))} + ) : null}
      - - + ) : null} +
      + ); +} + +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 ; + } + if (!workspace) return null; + return ( +
      + +
      ); } diff --git a/ui/src/components/task-chat/transcript-adapter.ts b/ui/src/components/task-chat/transcript-adapter.ts index 31827f091bb..3757d5d5bda 100644 --- a/ui/src/components/task-chat/transcript-adapter.ts +++ b/ui/src/components/task-chat/transcript-adapter.ts @@ -404,7 +404,8 @@ function providerActivityItem( const status: TaskChatProtocolStep["status"] = rawStatus === "in_progress" || rawStatus === "completed" || - rawStatus === "blocked" + rawStatus === "blocked" || + rawStatus === "failed" ? rawStatus : "pending"; return { diff --git a/ui/src/components/transcript/native-run-events-boundary-golden.test.ts b/ui/src/components/transcript/native-run-events-boundary-golden.test.ts index b51f5bebfc5..ed75daf8683 100644 --- a/ui/src/components/transcript/native-run-events-boundary-golden.test.ts +++ b/ui/src/components/transcript/native-run-events-boundary-golden.test.ts @@ -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 }), }), ); diff --git a/ui/src/components/transcript/native-run-events.test.ts b/ui/src/components/transcript/native-run-events.test.ts index 5b04d1a0275..dfdcd17722f 100644 --- a/ui/src/components/transcript/native-run-events.test.ts +++ b/ui/src/components/transcript/native-run-events.test.ts @@ -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() { diff --git a/ui/src/components/transcript/native-run-events.ts b/ui/src/components/transcript/native-run-events.ts index e00d18dd803..c1b7b757f0c 100644 --- a/ui/src/components/transcript/native-run-events.ts +++ b/ui/src/components/transcript/native-run-events.ts @@ -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)) { diff --git a/ui/src/index.css b/ui/src/index.css index 9308a7a27d2..cbe069580ce 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -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. diff --git a/ui/storybook/stories/tasks/task-chat-agent-plan.stories.tsx b/ui/storybook/stories/tasks/task-chat-agent-plan.stories.tsx new file mode 100644 index 00000000000..272717f4d6a --- /dev/null +++ b/ui/storybook/stories/tasks/task-chat-agent-plan.stories.tsx @@ -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; + +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("standard"); + const [questionOpen, setQuestionOpen] = useState(question); + const takeoverContent = ( + setQuestionOpen(false)} + /> + ); + return ( + + {children} + {}} + 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} + /> + + ); +} + +function StateCard({ title, rule, children }: { title: string; rule: string; children: ReactNode }) { + return ( +
      +
      +

      {title}

      +

      {rule}

      +
      + {children} +
      + ); +} + +function Page({ children, narrow }: { children: ReactNode; narrow?: boolean }) { + return ( +
      +
      {children}
      +
      + ); +} + +function AllStates() { + return ( + +
      +
      +

      Agent plan

      +

      + The agent's own checklist for the current turn, docked above the composer. It comes from the + provider-neutral plan.updated event, so Claude task tools, Codex + plan updates, Cursor todos and any ACP plan update look the same. +

      +
      + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {null} + +
      +
      + ); +} + +/** 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 ( + +

      Simulated live run: plan updates arrive every 1.4 s and the loop restarts after the plan finishes.

      + +
      + ); +} + +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) => ( + + + + ), +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const AllStatesGallery: Story = { render: () => }; +export const LiveRunSimulation: Story = { render: () => }; +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) => ( + + + + ), +}; +export const MobileStartsCollapsed: Story = { + args: { model: STATES.withDiff, defaultCollapsed: true }, + render: (args) => ( + + + + ), +};