From cb3d65be2a0ed79d1c64f14e503a34bb41d5196e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 19:12:51 +0000 Subject: [PATCH] fix: open the screen beside the chat even before the computer status catches up A bot that boots its computer inside a run emits no computer.status event, so the client still saw a stopped computer and never opened the screen for screen work or a takeover. Pointer activity and takeover requests now count as proof of a running screen; desktop hosts and bots without a computer still never get one. A presented file is now the newest one since the user last wrote, so another member's run in a group no longer hides it. Only panes the work opened get the wide width, so switching from the computer to routines or settings no longer animates the width. E2E specs follow the new behaviour: the takeover screen opens on its own and group Markdown previews open beside the chat. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01C7VSRHHzyt2KwoCkUkZ2Gc --- apps/web/e2e/golden.spec.ts | 5 +-- apps/web/e2e/group-chats.spec.ts | 8 +++-- apps/web/src/pages/Shell.tsx | 4 ++- packages/core/src/workspace-focus.test.ts | 38 +++++++++++++++++++---- packages/core/src/workspace-focus.ts | 30 +++++++++++------- 5 files changed, 62 insertions(+), 23 deletions(-) diff --git a/apps/web/e2e/golden.spec.ts b/apps/web/e2e/golden.spec.ts index 367349b..17ace91 100644 --- a/apps/web/e2e/golden.spec.ts +++ b/apps/web/e2e/golden.spec.ts @@ -69,9 +69,10 @@ test("takeover, routine, plugins, and export are reachable", async ({ page }, te }) .toBe("waiting_takeover"); await captureScreenshot(page, testInfo, "08-protected-input-request"); - await page.getByTitle("Agent computer").click(); + // A takeover request brings the bot's screen beside the chat on its own. const sidePanel = page.getByTestId("side-panel"); - await expect(sidePanel).toHaveCSS("width", "384px"); + await expect(sidePanel).toHaveAttribute("data-panel", "computer"); + await expect(sidePanel).toHaveAttribute("data-auto", ""); const [mainBox, panelBox] = await Promise.all([ page.locator("main").boundingBox(), sidePanel.boundingBox(), diff --git a/apps/web/e2e/group-chats.spec.ts b/apps/web/e2e/group-chats.spec.ts index e5d449c..762432a 100644 --- a/apps/web/e2e/group-chats.spec.ts +++ b/apps/web/e2e/group-chats.spec.ts @@ -303,9 +303,11 @@ test("create group from + and see two bots in one transcript", async ({ page }, const groupMarkdown = page.getByRole("button", { name: "Preview group-preview.md" }); await expect(groupMarkdown).toBeVisible({ timeout: 30_000 }); await groupMarkdown.click(); - const markdownDialog = page.getByRole("dialog", { name: "group-preview.md" }); - await expect(markdownDialog.getByRole("heading", { name: "Group artifact" })).toBeVisible(); - await markdownDialog.getByRole("button", { name: "Close preview" }).click(); + // Wide windows open the file beside the group conversation. + const markdownPane = page.getByTestId("artifact-pane"); + await expect(markdownPane.getByRole("heading", { name: "Group artifact" })).toBeVisible(); + await markdownPane.getByRole("button", { name: "Close panel" }).click(); + await expect(markdownPane).toHaveCount(0); await page.setViewportSize({ width: 390, height: 844 }); await expect(page.getByRole("button", { name: "Open navigation" })).toBeVisible(); diff --git a/apps/web/src/pages/Shell.tsx b/apps/web/src/pages/Shell.tsx index e52e20f..5437789 100644 --- a/apps/web/src/pages/Shell.tsx +++ b/apps/web/src/pages/Shell.tsx @@ -3370,7 +3370,9 @@ export function ShellPage() { } const embeddedScreenUrl = embeddableScreenUrl(screenUrl); - const workspacePaneOpen = panel === "computer" || panel === "artifact"; + // Only what the work opened gets the wide pane; settings, routines and a hand-opened screen keep + // one width so switching between them never jumps. + const workspacePaneOpen = panel === "artifact" || (panel === "computer" && autoPanel === panel); const hasControl = userHoldsComputerControl(computer, active?.id); const hideScreenLoadError = computerErrorFromScreen && Boolean(embeddedScreenUrl); const computerScreenError = diff --git a/packages/core/src/workspace-focus.test.ts b/packages/core/src/workspace-focus.test.ts index 933019d..9210428 100644 --- a/packages/core/src/workspace-focus.test.ts +++ b/packages/core/src/workspace-focus.test.ts @@ -28,6 +28,7 @@ function snapshot(overrides: Partial = {}): WorkspaceFoc const report = { id: "msg-report", + role: "bot", runId: "run-1", blocks: [ { @@ -73,7 +74,7 @@ describe("deriveWorkspaceFocus", () => { for (const computer of [ { ...runningComputer, kind: "desktop" }, { ...runningComputer, kind: "none" }, - { ...runningComputer, screenAvailable: false }, + null, ]) { expect( deriveWorkspaceFocus(snapshot({ computer }), { screenRunId: "run-1" }).pane, @@ -81,6 +82,25 @@ describe("deriveWorkspaceFocus", () => { } }); + it("trusts screen work over a computer status that has not caught up with the boot", () => { + const stale = { ...runningComputer, state: "stopped", screenAvailable: false }; + expect(deriveWorkspaceFocus(snapshot({ computer: stale }), { screenRunId: "run-1" }).pane).toBe( + "screen", + ); + expect( + deriveWorkspaceFocus( + snapshot({ + run: { id: "run-1", status: "waiting_takeover" }, + computer: { ...stale, takeoverRequested: true }, + }), + ).pane, + ).toBe("screen"); + // Without evidence of screen work, a learned default never boots a stopped computer. + expect( + deriveWorkspaceFocus(snapshot({ computer: stale }), { screenMode: "always" }).pane, + ).toBeNull(); + }); + it("shows the screen for a takeover request even when the user turned auto off", () => { const focus = deriveWorkspaceFocus( snapshot({ @@ -104,7 +124,7 @@ describe("deriveWorkspaceFocus", () => { ).toBeNull(); }); - it("shows a file the latest run presented", () => { + it("shows the file the bot presented as its latest result", () => { const focus = deriveWorkspaceFocus( snapshot({ run: { id: "run-1", status: "completed" }, messages: [report] }), ); @@ -118,17 +138,20 @@ describe("deriveWorkspaceFocus", () => { expect(focus.key).toBe("artifact:art-1|ask:none"); }); - it("ignores files that were only attached, presented by older runs, or not previewable", () => { + it("ignores files that were only attached, presented before the user wrote again, or not previewable", () => { const attached = { ...report, blocks: [{ ...report.blocks[0]!, present: undefined }], }; expect(deriveWorkspaceFocus(snapshot({ messages: [attached] })).pane).toBeNull(); + const followUp = { id: "msg-user", role: "user", runId: "run-2", blocks: [{ kind: "text" }] }; + expect(deriveWorkspaceFocus(snapshot({ messages: [report, followUp] })).pane).toBeNull(); + // Another member's newer run in a group does not hide the result. expect( deriveWorkspaceFocus( - snapshot({ run: { id: "run-2", status: "running" }, messages: [report] }), + snapshot({ run: { id: "run-9", status: "running" }, messages: [report] }), ).pane, - ).toBeNull(); + ).toBe("artifact"); const pdf = { ...report, blocks: [{ ...report.blocks[0]!, mimeType: "application/pdf", name: "report.pdf" }], @@ -181,7 +204,10 @@ describe("applyWorkspaceFocus", () => { it("keeps a presented file open while the user follows up", () => { const followUp = deriveWorkspaceFocus( - snapshot({ run: { id: "run-2", status: "running" }, messages: [report] }), + snapshot({ + run: { id: "run-2", status: "running" }, + messages: [report, { id: "msg-user", role: "user", blocks: [{ kind: "text" }] }], + }), ); expect(followUp.pane).toBeNull(); expect(applyWorkspaceFocus(artifact, followUp, { open: "artifact", auto: true })).toBeNull(); diff --git a/packages/core/src/workspace-focus.ts b/packages/core/src/workspace-focus.ts index 2777fd8..08dd634 100644 --- a/packages/core/src/workspace-focus.ts +++ b/packages/core/src/workspace-focus.ts @@ -37,7 +37,12 @@ type FocusBlock = { type FocusRun = { id: string; status: string }; export type WorkspaceFocusSnapshot = { - messages: readonly { id: string; runId?: string | null; blocks: readonly FocusBlock[] }[]; + messages: readonly { + id: string; + role?: string; + runId?: string | null; + blocks: readonly FocusBlock[]; + }[]; run?: FocusRun | null; activeRuns?: readonly FocusRun[]; computer?: { @@ -89,12 +94,12 @@ function workingRunIds(snapshot: WorkspaceFocusSnapshot): Set { return new Set(runs.filter((run) => WORKING_RUN_STATUSES.has(run.status)).map((run) => run.id)); } +/** The newest file a bot presented since the user last wrote; a new request starts fresh. */ function presentedArtifact(snapshot: WorkspaceFocusSnapshot): WorkspaceArtifact | null { - const latestRunId = snapshot.run?.id; - if (!latestRunId) return null; for (let index = snapshot.messages.length - 1; index >= 0; index -= 1) { const message = snapshot.messages[index]; - if (!message || message.runId !== latestRunId) continue; + if (!message) continue; + if (message.role === "user") return null; for (let blockIndex = message.blocks.length - 1; blockIndex >= 0; blockIndex -= 1) { const block = message.blocks[blockIndex]; if ( @@ -119,7 +124,7 @@ function presentedArtifact(snapshot: WorkspaceFocusSnapshot): WorkspaceArtifact /** * Derives the pane a conversation should show from what the bot is actually doing: a * takeover request or live work on its screen shows the screen, a file the bot presented - * as its result shows that file, and everything else stays a plain conversation. + * as its latest result shows that file, and everything else stays a plain conversation. */ export function deriveWorkspaceFocus( snapshot: WorkspaceFocusSnapshot | null, @@ -134,16 +139,19 @@ export function deriveWorkspaceFocus( activeRuns: snapshot.activeRuns, }); const computer = snapshot.computer; - const screenViewable = - Boolean(computer?.screenAvailable) && computer?.kind !== "desktop" && computer?.kind !== "none"; - const takeover = Boolean(computer?.takeoverRequested) && screenViewable; const working = workingRunIds(snapshot); const mode = live.screenMode ?? "auto"; - const pointerWork = live.screenRunId != null && working.has(live.screenRunId); + const pointerWork = + Boolean(live.screenActive) || (live.screenRunId != null && working.has(live.screenRunId)); + // A desktop host or a bot without a computer has no screen to embed. + const embeddable = Boolean(computer) && computer?.kind !== "desktop" && computer?.kind !== "none"; + // A bot that points at its screen or asks for a takeover has a running screen, even when the + // client's computer status has not caught up with a boot that happened inside the run. + const takeover = embeddable && Boolean(computer?.takeoverRequested); const screenWork = - screenViewable && + embeddable && mode !== "never" && - ((working.size > 0 && (mode === "always" || pointerWork)) || Boolean(live.screenActive)); + (pointerWork || (mode === "always" && working.size > 0 && Boolean(computer?.screenAvailable))); const artifact = presentedArtifact(snapshot); const pane: WorkspacePane | null = takeover || screenWork ? "screen" : artifact ? "artifact" : null;