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;