diff --git a/cli/src/commands/onboard.ts b/cli/src/commands/onboard.ts index 714741b2581..845eeb79223 100644 --- a/cli/src/commands/onboard.ts +++ b/cli/src/commands/onboard.ts @@ -477,7 +477,7 @@ export async function onboard(opts: OnboardOptions): Promise { } if (shouldRunNow && !opts.invokedByRun) { - process.env.PAPERCLIP_OPEN_ON_LISTEN = "true"; + process.env.PAPERCLIP_OPEN_ON_LISTEN ??= "true"; const { runCommand } = await import("./run.js"); await runCommand({ config: configPath, repair: true, yes: true }); return; @@ -746,7 +746,7 @@ export async function onboard(opts: OnboardOptions): Promise { } if (shouldRunNow && !opts.invokedByRun) { - process.env.PAPERCLIP_OPEN_ON_LISTEN = "true"; + process.env.PAPERCLIP_OPEN_ON_LISTEN ??= "true"; const { runCommand } = await import("./run.js"); await runCommand({ config: configPath, repair: true, yes: true }); return; diff --git a/doc/plans/2026-08-26-runner-full-stack-e2e.md b/doc/plans/2026-08-26-runner-full-stack-e2e.md index f7ca62e938f..36307592ea7 100644 --- a/doc/plans/2026-08-26-runner-full-stack-e2e.md +++ b/doc/plans/2026-08-26-runner-full-stack-e2e.md @@ -27,6 +27,12 @@ Every attempt allocates a new OS temporary root with a Paperclip home, embedded database, workspace, private artifacts, logs, port, instance ID, and signing secrets. +Local runs default to one worker and are intended for a narrow `--id` smoke +test. The high-parallelism full matrix is a GitHub Actions responsibility; an +operator must explicitly raise `--max-parallel` to overlap local stacks. The +harness disables Paperclip's normal post-onboarding desktop browser launch so +Playwright is the only browser owner. + The launcher deletes ambient database URLs. `playwright.config.ts` separately constructs the Paperclip child environment and strips OpenAI, Anthropic, OpenRouter, and Daytona variables. It starts diff --git a/tests/runner-e2e/README.md b/tests/runner-e2e/README.md index 34a6ecfddae..7335962e3dd 100644 --- a/tests/runner-e2e/README.md +++ b/tests/runner-e2e/README.md @@ -61,7 +61,7 @@ pnpm test:e2e:runner -- --group native --environment local pnpm test:e2e:runner -- --profile runner-codex --case message-marker pnpm test:e2e:runner -- --case plan-revise-accept --group local pnpm test:e2e:runner -- --case ask-question --group native -pnpm test:e2e:runner -- --all --max-parallel 8 +pnpm test:e2e:runner -- --all ``` The catalog contains three cases for every profile/environment pair: @@ -81,11 +81,16 @@ AND semantics. `--id` is exclusive with dimension selectors and `--all`. `--headed`, `--ui`, and `--debug` are forwarded to Playwright. An unknown selector, an empty selection, or a run with no explicit selector exits before Paperclip starts. `--max-parallel ` controls the number of isolated -profile/environment/case harnesses that can overlap (default 4, also configurable +profile/environment/case harnesses that can overlap (default 1, also configurable with `PAPERCLIP_E2E_MAX_PARALLEL`). Headed/UI/debug runs are forced to one worker. The Plan case is still sequential internally because its turns share one task; it runs in parallel with unrelated scenarios. +Use a single `--id` smoke test for routine local verification. Full-matrix +parallelism is intended for GitHub Actions; raising local parallelism starts +multiple Paperclip/Postgres/Chromium stacks and can consume substantial CPU and +memory. + Credential-free checks are: ```bash diff --git a/tests/runner-e2e/launch.ts b/tests/runner-e2e/launch.ts index 2950129584b..b569de390cf 100644 --- a/tests/runner-e2e/launch.ts +++ b/tests/runner-e2e/launch.ts @@ -48,6 +48,7 @@ const repositoryRoot = path.resolve(import.meta.dirname, "../.."); const localEnvPath = path.join(repositoryRoot, ".env.runner-e2e.local"); const resultsRoot = path.join(repositoryRoot, "tests/runner-e2e/results"); const activeProcessGroups = new Set(); +const activeProcessCleanup = new Map>(); let cancelled = false; function cleanId(value: string) { @@ -115,8 +116,13 @@ async function terminateProcessGroup(pid: number) { for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"] as const) { process.on(signal, () => { cancelled = true; - for (const pid of activeProcessGroups) - stopProcessGroup(pid, signal === "SIGINT" ? "SIGTERM" : signal); + for (const pid of activeProcessGroups) { + if (activeProcessCleanup.has(pid)) { + stopProcessGroup(pid, "SIGKILL"); + continue; + } + activeProcessCleanup.set(pid, terminateProcessGroup(pid)); + } }); } @@ -253,9 +259,13 @@ async function runProcess( }); if (timer) clearTimeout(timer); const processCleanupError = child.pid - ? await terminateProcessGroup(child.pid) + ? await (activeProcessCleanup.get(child.pid) ?? + terminateProcessGroup(child.pid)) : null; - if (child.pid) activeProcessGroups.delete(child.pid); + if (child.pid) { + activeProcessGroups.delete(child.pid); + activeProcessCleanup.delete(child.pid); + } await new Promise((resolve) => log.end(resolve)); return { exitCode, @@ -305,6 +315,7 @@ async function readResult(resultPath: string, fallback: RunnerE2EResult) { async function copySharedEvidence(privateRoot: string, casePrivateDir: string) { for (const relative of [ "server.log", + "playwright.log", "junit.xml", "html-report", "blob-report", diff --git a/tests/runner-e2e/selectors.ts b/tests/runner-e2e/selectors.ts index 6c5169b54db..59a342983d5 100644 --- a/tests/runner-e2e/selectors.ts +++ b/tests/runner-e2e/selectors.ts @@ -41,7 +41,7 @@ export function parseRunnerSelectors( headed: false, ui: false, debug: false, - maxParallel: Number(process.env.PAPERCLIP_E2E_MAX_PARALLEL ?? "4"), + maxParallel: Number(process.env.PAPERCLIP_E2E_MAX_PARALLEL ?? "1"), }; for (let index = 0; index < args.length; index += 1) { const flag = args[index]; diff --git a/tests/runner-e2e/server.ts b/tests/runner-e2e/server.ts index c6a57adc292..388a9236d2e 100644 --- a/tests/runner-e2e/server.ts +++ b/tests/runner-e2e/server.ts @@ -43,6 +43,9 @@ const serverEnvironment = buildPaperclipServerEnvironment(process.env, { PAPERCLIP_SECRETS_STRICT_MODE: "true", PAPERCLIP_DB_BACKUP_ENABLED: "false", PAPERCLIP_DB_BACKUP_DIR: path.join(temporaryRoot, "backups"), + // Onboarding normally opens the app after listen. Browser ownership belongs + // to Playwright in this harness, so never create a developer desktop tab. + PAPERCLIP_OPEN_ON_LISTEN: "false", }); assertIsolatedServerEnvironment(serverEnvironment, { temporaryRoot, diff --git a/ui/src/components/IssueProperties.test.tsx b/ui/src/components/IssueProperties.test.tsx index 702154a54cf..bacdee29f3a 100644 --- a/ui/src/components/IssueProperties.test.tsx +++ b/ui/src/components/IssueProperties.test.tsx @@ -505,18 +505,11 @@ describe("IssueProperties", () => { document.body.innerHTML = ""; }); - it("keeps the Plan tab visible for a planning-mode issue without a plan document", async () => { + it("does not show a Plan tab for a planning-mode issue without a plan document", async () => { mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableTaskWatchdogs: false, enableClassicTaskInterface: false, }); - mockIssuesApi.listInteractions.mockResolvedValue([ - { - kind: "request_confirmation", - status: "pending", - payload: { target: { type: "issue_document", key: "plan" } }, - }, - ]); const root = renderProperties(container, { issue: createIssue({ workMode: "planning" }), childIssues: [], @@ -525,18 +518,8 @@ describe("IssueProperties", () => { }); await waitForAssertion(() => { - expect(Array.from(container.querySelectorAll("button")).some((button) => button.textContent === "Plan")).toBe(true); - }); - - const planTab = Array.from(container.querySelectorAll("button")).find((button) => button.textContent === "Plan"); - await act(async () => { - // Radix Tabs triggers select on mousedown (button 0), not on click. - planTab!.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 0 })); - }); - - await waitForAssertion(() => { - expect(container.textContent).toContain("This task is in plan mode but no plan document has been written yet."); - expect(container.textContent).toContain("A plan confirmation is pending, but the plan document it should confirm is missing."); + expect(Array.from(container.querySelectorAll("button")).some((button) => button.textContent === "Plan")).toBe(false); + expect(container.textContent).not.toContain("This task is in plan mode but no plan document has been written yet."); }); act(() => root.unmount()); diff --git a/ui/src/components/TaskChatThread.test.tsx b/ui/src/components/TaskChatThread.test.tsx index 4277707e154..89b44171269 100644 --- a/ui/src/components/TaskChatThread.test.tsx +++ b/ui/src/components/TaskChatThread.test.tsx @@ -1726,6 +1726,50 @@ describe("TaskChatThread composer alignment (PAP-498)", () => { }); }); +describe("TaskChatThread no-live-execution-path recovery", () => { + const noLivePathComment = { + id: "comment-no-live-path", + companyId: "company-1", + issueId: "issue-1", + authorType: "system" as const, + authorAgentId: null, + authorUserId: null, + body: "Paperclip retried continuation, but it still has no live execution path.", + presentation: { + kind: "system_notice" as const, + tone: "danger" as const, + title: "No live execution path", + detailsDefaultOpen: false, + }, + metadata: null, + createdAt: new Date("2026-08-26T12:00:00.000Z"), + updatedAt: new Date("2026-08-26T12:00:00.000Z"), + }; + + it("offers Try again only while the task is blocked", async () => { + const onTryAgain = vi.fn(); + const props = { + comments: [noLivePathComment], + onAdd: async () => {}, + onTryAgainNoLiveExecutionPath: onTryAgain, + showComposer: false, + }; + + render(); + const tryAgain = container.querySelector( + '[data-testid="task-chat-no-live-path-try-again"]', + ); + expect(tryAgain).not.toBeNull(); + + flushSync(() => tryAgain!.click()); + await Promise.resolve(); + expect(onTryAgain).toHaveBeenCalledTimes(1); + + render(); + expect(container.querySelector('[data-testid="task-chat-no-live-path-try-again"]')).toBeNull(); + }); +}); + describe("TaskChatThread blocker links", () => { it("shows the direct and server-selected terminal blocker at the top and bottom", () => { const terminalBlocker = { diff --git a/ui/src/components/issue-properties/IssueProperties.tsx b/ui/src/components/issue-properties/IssueProperties.tsx index e7d1e1bbbf2..6a93ae64b62 100644 --- a/ui/src/components/issue-properties/IssueProperties.tsx +++ b/ui/src/components/issue-properties/IssueProperties.tsx @@ -210,9 +210,7 @@ export function IssueProperties({ } setPaneHeaderSlot(document.getElementById(PROPERTIES_PANE_HEADER_SLOT_ID)); }, [taskChatShellEnabled, inline]); - // Plan earns a tab as soon as an issue is in planning mode, even before the - // plan document arrives. This keeps an expected plan surface visible and - // lets its diagnostic empty state explain what is missing. + // A Plan tab represents materialized plan content, not merely planning mode. // Same query keys as the tab bodies, so these share their cached fetches. const { data: paneTabPlanDocument } = useIssuePlanDocument( taskChatShellEnabled ? issue.id : null, @@ -241,8 +239,7 @@ export function IssueProperties({ const hasPlanTab = Boolean(paneTabPlanDocument) || (paneTabAcceptedPlans?.length ?? 0) > 0 - || paneTabStandaloneDocuments.length > 0 - || issue.workMode === "planning"; + || paneTabStandaloneDocuments.length > 0; // Artifacts covers the same three sources the tab body composes: work // products, documents (redundant with the Plan tab, intentionally), and // agent-created attachments. User comment uploads stay thread-only and diff --git a/ui/src/components/task-chat/TaskChatActivityPhase.tsx b/ui/src/components/task-chat/TaskChatActivityPhase.tsx index c0a89bbac06..837cef26389 100644 --- a/ui/src/components/task-chat/TaskChatActivityPhase.tsx +++ b/ui/src/components/task-chat/TaskChatActivityPhase.tsx @@ -27,6 +27,7 @@ export function TaskChatActivityPhase({ renderChild, renderChildren, autoOpen = true, + defaultOpen = false, childrenClassName, showChildRail = false, appearance = "classic", @@ -35,6 +36,8 @@ export function TaskChatActivityPhase({ renderChild: (child: TaskChatActivityPhaseItem["items"][number]) => ReactNode; renderChildren?: (children: TaskChatActivityPhaseItem["items"]) => ReactNode; autoOpen?: boolean; + /** Keep useful historical content visible in an in-flight transcript. */ + defaultOpen?: boolean; childrenClassName?: string; /** Draws a nested-activity rail aligned beneath the summary disclosure. */ showChildRail?: boolean; @@ -42,8 +45,9 @@ export function TaskChatActivityPhase({ appearance?: "classic" | "runner"; }) { const shouldAutoOpen = - autoOpen && - (item.active || + defaultOpen || + (autoOpen && + (item.active || item.items.some( (child) => (child.kind === "thinking" && child.streaming) || @@ -57,7 +61,7 @@ export function TaskChatActivityPhase({ (child.kind === "protocol" && child.surface === "runtime_request" && child.status === "pending"), - )); + ))); const [open, setOpen] = useState(shouldAutoOpen); useEffect(() => { if (shouldAutoOpen) setOpen(true); diff --git a/ui/src/components/task-chat/TaskChatLiveTail.test.tsx b/ui/src/components/task-chat/TaskChatLiveTail.test.tsx index 90a6f009935..b38466f953c 100644 --- a/ui/src/components/task-chat/TaskChatLiveTail.test.tsx +++ b/ui/src/components/task-chat/TaskChatLiveTail.test.tsx @@ -135,6 +135,7 @@ describe("TaskChatLiveTail", () => { { kind: "thinking", ts: TS, text: "Provider reasoning summary" }, { kind: "assistant", ts: TS, text: "Visible answer." }, ]); + expect(items[0]).toMatchObject({ kind: "thinking", streaming: false }); render(items); expect(container.textContent).toContain("Visible answer."); @@ -142,20 +143,15 @@ describe("TaskChatLiveTail", () => { '[data-testid="task-chat-phase-summary"]', ); expect(reasoningPhase?.textContent).toContain("Reasoning"); - expect(reasoningPhase?.getAttribute("aria-expanded")).toBe("false"); - flushSync(() => reasoningPhase?.click()); + expect(reasoningPhase?.getAttribute("aria-expanded")).toBe("true"); expect(container.textContent).toContain("Provider reasoning summary"); const thinking = container.querySelector('[data-testid="task-chat-thinking"]'); - const disclosure = thinking?.querySelector("button"); - const preview = thinking?.querySelector( - '[data-testid="task-chat-thinking-preview"]', - ); - expect(disclosure?.getAttribute("aria-expanded")).toBe("false"); - expect(preview?.textContent).toBe("Provider reasoning summary"); - expect(preview?.classList.contains("task-chat-collapsed-line-fade")).toBe( - true, - ); + expect(thinking?.getAttribute("data-state")).toBe("settled"); + expect( + thinking?.querySelector('[data-testid="task-chat-thinking-text"]') + ?.textContent, + ).toContain("Provider reasoning summary"); const phaseChildren = thinking?.closest( '[data-testid="task-chat-phase-children"]', ); @@ -174,19 +170,9 @@ describe("TaskChatLiveTail", () => { expect( thinking ?.querySelector('[data-testid="task-chat-thinking-icon"]') - ?.parentElement?.classList.contains("justify-center"), + ?.classList.contains("text-muted-foreground/50"), ).toBe(true); expect(thinking?.querySelector(".task-chat-reasoning-markdown")).toBeNull(); - - flushSync(() => disclosure?.click()); - - expect(disclosure?.getAttribute("aria-expanded")).toBe("true"); - expect( - thinking?.querySelector(".task-chat-expanded-line-wrap"), - ).not.toBeNull(); - expect( - thinking?.querySelector(".task-chat-reasoning-markdown")?.textContent, - ).toContain("Provider reasoning summary"); }); it("bounds long tool targets and wraps the full value only when expanded", () => { diff --git a/ui/src/components/task-chat/TaskChatLiveTail.tsx b/ui/src/components/task-chat/TaskChatLiveTail.tsx index 0ad807d59d9..2a84e119f6f 100644 --- a/ui/src/components/task-chat/TaskChatLiveTail.tsx +++ b/ui/src/components/task-chat/TaskChatLiveTail.tsx @@ -105,6 +105,11 @@ function renderTailRow( + child.kind === "thinking" && + child.lines.some((line) => line.trim().length > 0), + )} childrenClassName="relative ml-2.5 pl-6" showChildRail renderChild={(child) => child.kind === "tool" diff --git a/ui/src/components/task-chat/TaskChatRunnerTurn.test.tsx b/ui/src/components/task-chat/TaskChatRunnerTurn.test.tsx index ac78f71b0cd..11ae820f29f 100644 --- a/ui/src/components/task-chat/TaskChatRunnerTurn.test.tsx +++ b/ui/src/components/task-chat/TaskChatRunnerTurn.test.tsx @@ -439,18 +439,71 @@ describe("TaskChatRunnerTurn", () => { ?.parentElement?.classList.contains("justify-center"), ).toBe(true); expect( - thinkingRows?.[0]?.querySelector("button")?.classList.contains( + thinkingRows?.[0]?.querySelector( + '[data-testid="task-chat-thinking-text"]', + )?.textContent, + ).toContain("Inspect the current card."); + expect( + thinkingRows?.[0]?.querySelector(".task-chat-reasoning-markdown"), + ).toBeNull(); + expect( + thinkingRows?.[1]?.querySelector("button")?.classList.contains( "font-normal", ), ).toBe(true); + }); + + it("renders only the current reasoning block as active", () => { + render([ + { + id: "old-reasoning", + kind: "thinking", + lines: ["Inspect the current card."], + streaming: true, + transcriptIndex: 1, + }, + { + id: "read", + kind: "tool", + name: "Read files", + rawName: "Read", + status: "completed", + }, + { + id: "current-reasoning", + kind: "thinking", + lines: ["Verify the updated state."], + streaming: true, + transcriptIndex: 3, + }, + ]); + act(() => - thinkingRows?.[0] - ?.querySelector("button") + container + .querySelector( + '[data-testid="task-chat-phase-summary"]', + ) ?.click(), ); + + const oldReasoning = container.querySelector( + '[data-activity-item-id="old-reasoning"]', + ); + expect(oldReasoning?.textContent).toBe("Inspect the current card."); + expect(oldReasoning?.querySelector(".shimmer-text")).toBeNull(); expect( - thinkingRows?.[0]?.querySelector(".task-chat-reasoning-markdown"), + oldReasoning?.querySelector('[data-testid="task-chat-thinking-text"]'), ).not.toBeNull(); + + const currentReasoning = container.querySelector( + '[data-activity-item-id="current-reasoning"]', + ); + expect(currentReasoning?.textContent).toContain("Reasoning…"); + expect( + currentReasoning + ?.querySelector('[data-testid="task-chat-thinking-icon"]') + ?.classList.contains("text-(--status-agent-running)"), + ).toBe(true); }); it("does not let a textless reasoning lifecycle remove sticky commentary", () => { diff --git a/ui/src/components/task-chat/TaskChatRunnerTurn.tsx b/ui/src/components/task-chat/TaskChatRunnerTurn.tsx index d4098123a5b..79f5b320d1a 100644 --- a/ui/src/components/task-chat/TaskChatRunnerTurn.tsx +++ b/ui/src/components/task-chat/TaskChatRunnerTurn.tsx @@ -106,7 +106,7 @@ function RunnerActivityTimeline({ items }: { items: readonly TaskChatItem[] }) { aria-label="Run activity" data-testid="task-chat-runner-activity-list" > - {items.map((item) => ( + {items.map((item, index) => (
  • {item.kind === "message" ? (
    diff --git a/ui/src/components/task-chat/TaskChatThinking.tsx b/ui/src/components/task-chat/TaskChatThinking.tsx index b66c2496d88..bc611355dcc 100644 --- a/ui/src/components/task-chat/TaskChatThinking.tsx +++ b/ui/src/components/task-chat/TaskChatThinking.tsx @@ -10,22 +10,48 @@ export function TaskChatThinking({ item, defaultOpen, rowClassName, + active = Boolean(item.streaming), }: { item: TaskChatThinkingItem; /** Activity timelines keep reasoning as one compact row until requested. */ defaultOpen?: boolean; /** Optional alignment override for a containing activity rail. */ rowClassName?: string; + /** Whether this reasoning block is the runner's current activity. */ + active?: boolean; }) { const [open, setOpen] = useState(defaultOpen ?? false); const body = item.lines.join("\n").trim(); const preview = flattenSelfTalk(body); const baseLabel = item.channel === "detail" ? "Reasoning detail" : "Reasoning"; - const active = item.streaming === true; const label = active ? body ? `${baseLabel}…` : "Thinking…" : item.summaryLabel ?? (body ? baseLabel : "Thought"); + if (body && !active) { + return ( +
    + + + +
    + {body} +
    +
    + ); + } + if (!body) { return (
    diff --git a/ui/src/components/task-chat/transcript-adapter.test.ts b/ui/src/components/task-chat/transcript-adapter.test.ts index 655fea8fc00..2a28132ea8f 100644 --- a/ui/src/components/task-chat/transcript-adapter.test.ts +++ b/ui/src/components/task-chat/transcript-adapter.test.ts @@ -1442,7 +1442,6 @@ describe("paperclip runner semantic channels", () => { ], { runId: "run-reasoning-activity", running: true }, ); - const thinking = parsed.filter( (item): item is Extract => item.kind === "thinking", @@ -1530,8 +1529,8 @@ describe("paperclip runner semantic channels", () => { interstitial: true, channel: "progress", }); - expect(parsed[1]).toMatchObject({ channel: "summary" }); - expect(parsed[2]).toMatchObject({ channel: "detail" }); + expect(parsed[1]).toMatchObject({ channel: "summary", streaming: false }); + expect(parsed[2]).toMatchObject({ channel: "detail", streaming: false }); expect(parsed[3]).toMatchObject({ interstitial: false, channel: "final" }); const history = settledRunChildren(parsed); expect(JSON.stringify(history)).not.toContain("Done."); diff --git a/ui/src/components/task-chat/transcript-adapter.ts b/ui/src/components/task-chat/transcript-adapter.ts index ba669129ce5..e2e4ed1dad3 100644 --- a/ui/src/components/task-chat/transcript-adapter.ts +++ b/ui/src/components/task-chat/transcript-adapter.ts @@ -641,6 +641,7 @@ export function transcriptToTaskChatItems( } case "assistant": { if (!entry.text) break; + finishThinking(); const channel = entry.channel; if (messageIndex >= 0 && messageChannel === channel) { const it = items[messageIndex]; diff --git a/ui/src/components/task-side-panel/TaskSidePanel.test.tsx b/ui/src/components/task-side-panel/TaskSidePanel.test.tsx index df1c10e0d3d..cb953257635 100644 --- a/ui/src/components/task-side-panel/TaskSidePanel.test.tsx +++ b/ui/src/components/task-side-panel/TaskSidePanel.test.tsx @@ -7,6 +7,7 @@ import type { Issue, IssueDocument } from "@paperclipai/shared"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { TooltipProvider } from "@/components/ui/tooltip"; import { + taskPanelDocumentTab, taskPanelPropertiesTab, writeTaskSidePanelState, } from "@/lib/task-side-panel-state"; @@ -24,7 +25,7 @@ class ResizeObserverStub { const fixture = vi.hoisted(() => ({ documents: [] as IssueDocument[] | undefined, - plan: null as IssueDocument | null, + plan: null as IssueDocument | null | undefined, })); const routeFixture = vi.hoisted(() => ({ @@ -157,16 +158,13 @@ describe("TaskSidePanel", () => { expect(container.textContent).toContain("Properties content"); }); - it("opens and selects Plan for a planning task", async () => { + it("does not create a Plan tab for planning mode before a plan exists", async () => { await render(panel({ issue: issue({ workMode: "planning" }) })); - expect(container.querySelector('[role="tab"][aria-selected="true"]')?.textContent).toContain("Plan"); - expect(container.textContent).toContain("Plan content"); - const viewport = container.querySelector('[role="tabpanel"][data-side-panel-content-viewport="true"]')!; - const prose = viewport.querySelector('[data-side-panel-prose-content="true"]')!; - expect(viewport.className).toContain("overflow-auto"); - expect(viewport.className).toContain("scrollbar-while-scrolling"); - expect(viewport.className).not.toContain("max-w-4xl"); - expect(prose.className).toContain("max-w-4xl"); + expect(container.querySelector('[role="tab"][aria-selected="true"]')?.textContent).toContain("Properties"); + expect(container.querySelector('[data-side-panel-tab-target="document:plan"]')).toBeNull(); + + await act(async () => container.querySelector('button[aria-label="Open a new tab"]')?.click()); + expect(Array.from(container.querySelectorAll('[role="option"]')).some((item) => item.textContent?.includes("Plan"))).toBe(false); }); it("opens a newly materialized plan until the user has interacted", async () => { @@ -179,6 +177,7 @@ describe("TaskSidePanel", () => { it("opens a plan deep link without looping while document metadata loads", async () => { fixture.documents = undefined; + fixture.plan = undefined; await render(panel({ issue: issue({ workMode: "planning" }), @@ -211,6 +210,24 @@ describe("TaskSidePanel", () => { expect(container.getElementsByTagName("input")[0]?.getAttribute("aria-label")).toBe("Search tabs and resources…"); }); + it("removes a Plan tab persisted before a plan document existed", async () => { + writeTaskSidePanelState("user-1", "company-1", "task-1", { + state: { + tabs: [taskPanelPropertiesTab(), taskPanelDocumentTab("plan", "Plan")], + activeTabId: "document:plan", + }, + launcherOpen: false, + userInteracted: false, + autoPlanHandled: false, + updatedAt: 1, + }); + + await render(panel({ issue: issue({ workMode: "planning" }) })); + + expect(container.querySelector('[data-side-panel-tab-target="document:plan"]')).toBeNull(); + expect(container.querySelector('[role="tab"][aria-selected="true"]')?.textContent).toContain("Properties"); + }); + it("opens an ordinary document deep link in a deduplicated document tab", async () => { fixture.documents = [issueDocument("brief", "Implementation brief")]; await render(panel({ documentDeepLink: { requestId: 1, documentKey: "brief" } })); diff --git a/ui/src/components/task-side-panel/TaskSidePanel.tsx b/ui/src/components/task-side-panel/TaskSidePanel.tsx index 6672ff5faea..4a7ae76ed0c 100644 --- a/ui/src/components/task-side-panel/TaskSidePanel.tsx +++ b/ui/src/components/task-side-panel/TaskSidePanel.tsx @@ -218,15 +218,10 @@ export function TaskSidePanel({ const scrollPositionsRef = useRef(new Map()); const userInteractedRef = useRef(restoredRef.current?.userInteracted ?? false); const autoPlanHandledRef = useRef(restoredRef.current?.autoPlanHandled ?? false); - const initialState = useMemo(() => { - if (restoredRef.current) return restoredRef.current.state; - const tabs = [taskPanelPropertiesTab()]; - if (issue.workMode === "planning") tabs.push(taskPanelDocumentTab("plan", "Plan")); - return { - tabs, - activeTabId: issue.workMode === "planning" ? "document:plan" : "properties", - }; - }, [issue.workMode]); + const initialState = useMemo(() => restoredRef.current?.state ?? ({ + tabs: [taskPanelPropertiesTab()], + activeTabId: "properties", + }), []); const persist = useCallback((state: { tabs: SidePanelTabRecord[]; activeTabId: string | null }) => { writeTaskSidePanelState(accountScope, issue.companyId, issue.id, { @@ -248,6 +243,16 @@ export function TaskSidePanel({ setPaneHeaderSlot(document.getElementById(PROPERTIES_PANE_HEADER_SLOT_ID)); }, [inline]); + // Older clients persisted an empty Plan tab merely because the task was in + // planning mode. Remove that stale tab once the plan lookup confirms there + // is no document; a real plan will be opened by the materialization effect + // below when it arrives. + useEffect(() => { + if (planDocument !== null || !controller.tabs.some((tab) => tab.id === "document:plan")) return; + autoPlanHandledRef.current = false; + controller.closeTab("document:plan"); + }, [controller.closeTab, controller.tabs, planDocument]); + // Surface a newly materialized plan exactly once until the user takes manual // control of the tab set. Persisted empty/custom states therefore stay put. useEffect(() => { @@ -258,10 +263,14 @@ export function TaskSidePanel({ useEffect(() => { if (!documentDeepLink) return; + if ( + documentDeepLink.documentKey === "plan" && + planDocument === null + ) return; const document = documents.find((candidate) => candidate.key === documentDeepLink.documentKey); const label = document ? documentDisplayTitle(document) : documentDeepLink.documentKey === "plan" ? "Plan" : documentDeepLink.documentKey; controller.openTab(taskPanelDocumentTab(documentDeepLink.documentKey, label)); - }, [controller.openTab, documentDeepLink, documents]); + }, [controller.openTab, documentDeepLink, documents, planDocument]); // Existing URL-backed workspace links remain the external integration API. useEffect(() => { @@ -399,13 +408,13 @@ export function TaskSidePanel({ primary.push({ id: "files", label: "Files", icon: , shortcut: "G F", alreadyOpen: controller.tabs.some((tab) => tab.id === "files") }); } const documentItems: SidePanelLauncherItem[] = [ - { + ...(planDocument ? [{ id: "document:plan", - label: planDocument ? documentDisplayTitle(planDocument) : "Plan", - description: planDocument ? `Revision ${planDocument.latestRevisionNumber ?? 1}` : "No plan document yet", + label: documentDisplayTitle(planDocument), + description: `Revision ${planDocument.latestRevisionNumber ?? 1}`, icon: , alreadyOpen: controller.tabs.some((tab) => tab.id === "document:plan"), - }, + }] : []), ...documents .filter((document) => document.key !== "plan" && !isArtifactReviewDocumentKey(document.key)) .sort((left, right) => new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime()) @@ -419,8 +428,10 @@ export function TaskSidePanel({ ]; const sections: SidePanelLauncherSection[] = [ { id: "open", label: "Open", items: primary }, - { id: "documents", label: "Task documents", items: documentItems }, ]; + if (documentItems.length > 0) { + sections.push({ id: "documents", label: "Task documents", items: documentItems }); + } if (fileTabsEnabled) { const recentItems = recentFilesQuery.data?.state === "available" ? recentFilesQuery.data.items.filter((item) => item.kind === "file").slice(0, 5).map((item) => ({ diff --git a/ui/src/pages/IssueDetail.test.tsx b/ui/src/pages/IssueDetail.test.tsx index e620e55ec87..9cfff0b9091 100644 --- a/ui/src/pages/IssueDetail.test.tsx +++ b/ui/src/pages/IssueDetail.test.tsx @@ -50,6 +50,7 @@ const mockIssuesApi = vi.hoisted(() => ({ discardQueuedComment: vi.fn(), markRead: vi.fn(), update: vi.fn(), + resolveRecoveryAction: vi.fn(), previewTreeControl: vi.fn(), getTreeControlState: vi.fn(), listTreeHolds: vi.fn(), @@ -359,6 +360,7 @@ vi.mock("../components/TaskChatThread", () => ({ threadHeader?: ReactNode; onStopRun?: (runId: string) => Promise; stopRunLabel?: string; + onTryAgainNoLiveExecutionPath?: () => Promise | void; runFinalizationActions?: readonly { id: string; label: string; @@ -379,6 +381,15 @@ vi.mock("../components/TaskChatThread", () => ({ {props.stopRunLabel ?? "Stop run"} ) : null} + {props.onTryAgainNoLiveExecutionPath ? ( + + ) : null} {props.runFinalizationActions?.map((action) => ( {taskChatShellEnabled || - (panelVisible && !suppressPanelForFirstTask) ? null : ( + (panelVisible && !suppressPanelUntilPlan) ? null : (