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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7VSRHHzyt2KwoCkUkZ2Gc
This commit is contained in:
Claude 2026-10-03 19:12:51 +00:00
parent 065b1322bb
commit cb3d65be2a
No known key found for this signature in database
5 changed files with 62 additions and 23 deletions

View File

@ -69,9 +69,10 @@ test("takeover, routine, plugins, and export are reachable", async ({ page }, te
}) })
.toBe("waiting_takeover"); .toBe("waiting_takeover");
await captureScreenshot(page, testInfo, "08-protected-input-request"); 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"); 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([ const [mainBox, panelBox] = await Promise.all([
page.locator("main").boundingBox(), page.locator("main").boundingBox(),
sidePanel.boundingBox(), sidePanel.boundingBox(),

View File

@ -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" }); const groupMarkdown = page.getByRole("button", { name: "Preview group-preview.md" });
await expect(groupMarkdown).toBeVisible({ timeout: 30_000 }); await expect(groupMarkdown).toBeVisible({ timeout: 30_000 });
await groupMarkdown.click(); await groupMarkdown.click();
const markdownDialog = page.getByRole("dialog", { name: "group-preview.md" }); // Wide windows open the file beside the group conversation.
await expect(markdownDialog.getByRole("heading", { name: "Group artifact" })).toBeVisible(); const markdownPane = page.getByTestId("artifact-pane");
await markdownDialog.getByRole("button", { name: "Close preview" }).click(); 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 page.setViewportSize({ width: 390, height: 844 });
await expect(page.getByRole("button", { name: "Open navigation" })).toBeVisible(); await expect(page.getByRole("button", { name: "Open navigation" })).toBeVisible();

View File

@ -3370,7 +3370,9 @@ export function ShellPage() {
} }
const embeddedScreenUrl = embeddableScreenUrl(screenUrl); 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 hasControl = userHoldsComputerControl(computer, active?.id);
const hideScreenLoadError = computerErrorFromScreen && Boolean(embeddedScreenUrl); const hideScreenLoadError = computerErrorFromScreen && Boolean(embeddedScreenUrl);
const computerScreenError = const computerScreenError =

View File

@ -28,6 +28,7 @@ function snapshot(overrides: Partial<WorkspaceFocusSnapshot> = {}): WorkspaceFoc
const report = { const report = {
id: "msg-report", id: "msg-report",
role: "bot",
runId: "run-1", runId: "run-1",
blocks: [ blocks: [
{ {
@ -73,7 +74,7 @@ describe("deriveWorkspaceFocus", () => {
for (const computer of [ for (const computer of [
{ ...runningComputer, kind: "desktop" }, { ...runningComputer, kind: "desktop" },
{ ...runningComputer, kind: "none" }, { ...runningComputer, kind: "none" },
{ ...runningComputer, screenAvailable: false }, null,
]) { ]) {
expect( expect(
deriveWorkspaceFocus(snapshot({ computer }), { screenRunId: "run-1" }).pane, 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", () => { it("shows the screen for a takeover request even when the user turned auto off", () => {
const focus = deriveWorkspaceFocus( const focus = deriveWorkspaceFocus(
snapshot({ snapshot({
@ -104,7 +124,7 @@ describe("deriveWorkspaceFocus", () => {
).toBeNull(); ).toBeNull();
}); });
it("shows a file the latest run presented", () => { it("shows the file the bot presented as its latest result", () => {
const focus = deriveWorkspaceFocus( const focus = deriveWorkspaceFocus(
snapshot({ run: { id: "run-1", status: "completed" }, messages: [report] }), snapshot({ run: { id: "run-1", status: "completed" }, messages: [report] }),
); );
@ -118,17 +138,20 @@ describe("deriveWorkspaceFocus", () => {
expect(focus.key).toBe("artifact:art-1|ask:none"); 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 = { const attached = {
...report, ...report,
blocks: [{ ...report.blocks[0]!, present: undefined }], blocks: [{ ...report.blocks[0]!, present: undefined }],
}; };
expect(deriveWorkspaceFocus(snapshot({ messages: [attached] })).pane).toBeNull(); 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( expect(
deriveWorkspaceFocus( deriveWorkspaceFocus(
snapshot({ run: { id: "run-2", status: "running" }, messages: [report] }), snapshot({ run: { id: "run-9", status: "running" }, messages: [report] }),
).pane, ).pane,
).toBeNull(); ).toBe("artifact");
const pdf = { const pdf = {
...report, ...report,
blocks: [{ ...report.blocks[0]!, mimeType: "application/pdf", name: "report.pdf" }], 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", () => { it("keeps a presented file open while the user follows up", () => {
const followUp = deriveWorkspaceFocus( 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(followUp.pane).toBeNull();
expect(applyWorkspaceFocus(artifact, followUp, { open: "artifact", auto: true })).toBeNull(); expect(applyWorkspaceFocus(artifact, followUp, { open: "artifact", auto: true })).toBeNull();

View File

@ -37,7 +37,12 @@ type FocusBlock = {
type FocusRun = { id: string; status: string }; type FocusRun = { id: string; status: string };
export type WorkspaceFocusSnapshot = { 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; run?: FocusRun | null;
activeRuns?: readonly FocusRun[]; activeRuns?: readonly FocusRun[];
computer?: { computer?: {
@ -89,12 +94,12 @@ function workingRunIds(snapshot: WorkspaceFocusSnapshot): Set<string> {
return new Set(runs.filter((run) => WORKING_RUN_STATUSES.has(run.status)).map((run) => run.id)); 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 { 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) { for (let index = snapshot.messages.length - 1; index >= 0; index -= 1) {
const message = snapshot.messages[index]; 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) { for (let blockIndex = message.blocks.length - 1; blockIndex >= 0; blockIndex -= 1) {
const block = message.blocks[blockIndex]; const block = message.blocks[blockIndex];
if ( 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 * 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 * 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( export function deriveWorkspaceFocus(
snapshot: WorkspaceFocusSnapshot | null, snapshot: WorkspaceFocusSnapshot | null,
@ -134,16 +139,19 @@ export function deriveWorkspaceFocus(
activeRuns: snapshot.activeRuns, activeRuns: snapshot.activeRuns,
}); });
const computer = snapshot.computer; 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 working = workingRunIds(snapshot);
const mode = live.screenMode ?? "auto"; 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 = const screenWork =
screenViewable && embeddable &&
mode !== "never" && 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 artifact = presentedArtifact(snapshot);
const pane: WorkspacePane | null = const pane: WorkspacePane | null =
takeover || screenWork ? "screen" : artifact ? "artifact" : null; takeover || screenWork ? "screen" : artifact ? "artifact" : null;