# Files changed · PR \#21494 · pingdotgg/t3code

[View on GitCafe](https://git.cafe/pingdotgg/t3code/pull/21494/diffs)

Repository: [pingdotgg/t3code](https://git.cafe/pingdotgg/t3code)

Visibility: public

[fix\(server\): text-only browser snapshots no longer hang on hidden tabs](https://git.cafe/pingdotgg/t3code/pull/21494.md)

Version: 7

Head commit: 69f60e53038fe2d46c6357b157d7bd56bc4d437b

Base commit: 62245d8e3ad5858abe6c5c0e2e8fba42ed245e9f

Comparison base: 62245d8e3ad5858abe6c5c0e2e8fba42ed245e9f

Showing up to 20 entries from this revision. If the pull request changes, reopen the first page before continuing.

## apps/server/src/mcp/McpHttpServer.test.ts

Status: modified; +20; −8

```diff
@@ -220,10 +220,15 @@
 );
 
 it.effect.each([
-  { mode: "default", input: {}, images: false },
+  { mode: "default", input: {}, images: false, hostInput: { screenshot: false } },
+  { mode: "explicit image", input: { includeImage: true }, images: true, hostInput: {} },
+  {
-  { mode: "explicit image", input: { includeImage: true }, images: true },
-  { mode: "text only", input: { includeImage: false }, images: false },
-])("returns fresh $mode snapshots on repeated MCP calls", ({ input, images }) =>
+    mode: "text only",
+    input: { includeImage: false },
+    images: false,
+    hostInput: { screenshot: false },
+  },
+])("returns fresh $mode snapshots on repeated MCP calls", ({ input, images, hostInput }) =>
   Effect.scoped(
     Effect.gen(function* () {
       const server = yield* McpServer.McpServer;
@@ -254,6 +259,7 @@
       };
       const screenshot = { mimeType: "image/png", width: 1, height: 1 };
       let requests = 0;
+      const hostInputs: Array<unknown> = [];
       const events = yield* broker.connect({ clientId: "mcp-image-option-client", environmentId });
       yield* Stream.runForEach(events, (event) => {
         if (event.type === "connected") return Deferred.succeed(connected, undefined);
@@ -263,16 +269,19 @@
           tabId: alternateTabId,
           threadId,
         });
-        expect(event.request.input).toEqual({});
+        hostInputs.push(event.request.input);
         return broker.respond({
           clientId: "mcp-image-option-client",
           connectionId: event.connectionId,
           requestId: event.request.requestId,
           ok: true,
+          // Like the server browser, the host captures only when it is asked to.
           result: {
             ...page,
             title: `Snapshot ${requests}`,
+            ...((event.request.input as { screenshot?: boolean }).screenshot === false
+              ? {}
+              : { screenshot: { ...screenshot, data: png } }),
-            screenshot: { ...screenshot, data: png },
           },
         });
       }).pipe(Effect.forkScoped);
@@ -288,7 +297,7 @@
             Effect.provideService(McpInvocationContext.McpInvocationContext, invocation),
             Effect.provideService(McpSchema.McpServerClient, client),
           );
-        const metadata = { ...page, title: `Snapshot ${call}`, screenshot };
+        const metadata = { ...page, title: `Snapshot ${call}`, ...(images ? { screenshot } : {}) };
         const { accessibilityTree: _tree, ...boundedMetadata } = metadata;
         expect(snapshot.isError).toBe(false);
         expect(snapshot.structuredContent).toEqual({
@@ -327,9 +336,12 @@
           Effect.provideService(McpSchema.McpServerClient, client),
         );
       expect(nextDefault.content.map((content) => content.type)).toEqual(["text", "text", "text"]);
+      expect(nextDefault.structuredContent).toMatchObject({ title: "Snapshot 7" });
+      expect(nextDefault.structuredContent).not.toHaveProperty("screenshot");
-      expect(nextDefault.structuredContent).toMatchObject({ title: "Snapshot 7", screenshot });
       expect(nextDefault.structuredContent).not.toHaveProperty("accessibilityTree");
       expect(requests).toBe(7);
+      // Only a call that shows or saves the image asks the host to capture one.
+      expect(hostInputs).toEqual([...Array(6).fill(hostInput), { screenshot: false }]);
     }),
   ).pipe(Effect.provide(layerTest)),
 );
```

## apps/server/src/mcp/McpHttpServer.ts

Status: modified; +16; −10

```diff
@@ -478,7 +478,7 @@
             Effect.gen(function* () {
               const snapshot = encodedResult as SnapshotMetadata & {
                 readonly url: string;
-                readonly screenshot: {
+                readonly screenshot?: {
                   readonly mimeType: "image/png";
                   readonly data: string;
                   readonly width: number;
@@ -486,9 +486,11 @@
                 };
               };
               const { screenshot, ...page } = snapshot;
-              const png = new Uint8Array(Buffer.from(screenshot.data, "base64"));
+              const png = screenshot && new Uint8Array(Buffer.from(screenshot.data, "base64"));
               const screenshotPath =
+                png && payload?.save === true
+                  ? yield* saveScreenshot(snapshot.url, png)
+                  : undefined;
-                payload?.save === true ? yield* saveScreenshot(snapshot.url, png) : undefined;
               // Providers replay images on later requests, and some reject them; include images only when requested.
               const includeImage = payload?.includeImage === true;
               if (screenshotPath !== undefined && !includeImage) {
@@ -504,11 +506,15 @@
               }
               const metadata = {
                 ...page,
-                screenshot: {
-                  mimeType: screenshot.mimeType,
-                  width: screenshot.width,
-                  height: screenshot.height,
-                },
+                ...(screenshot === undefined
+                  ? {}
+                  : {
+                      screenshot: {
+                        mimeType: screenshot.mimeType,
+                        width: screenshot.width,
+                        height: screenshot.height,
+                      },
+                    }),
                 ...(screenshotPath === undefined ? {} : { screenshotPath }),
               };
               const bounded = boundSnapshotMetadata(metadata);
@@ -534,8 +540,8 @@
                           text: `Snapshot text was bounded. Omitted: ${bounded.omitted.join("; ")}.`,
                         },
                       ]),
-                  ...(includeImage
+                  ...(includeImage && png
+                    ? [{ type: "image" as const, data: png, mimeType: "image/png" }]
-                    ? [{ type: "image" as const, data: png, mimeType: screenshot.mimeType }]
                     : []),
                 ],
               });
```

## apps/server/src/mcp/toolkits/preview/handlers.ts

Status: modified; +8; −2

```diff
@@ -202,8 +202,14 @@
     invokeTargeted<PreviewAutomationSetColorSchemeResult>("setColorScheme", input),
   ),
   preview_snapshot: McpToolAccess.readsAsCaller((input) => {
+    const { includeImage, save, ...operationInput } = input ?? {};
+    // A capture makes a hidden desktop tab paint until it answers; skip one nobody reads.
+    return invokeTargeted<PreviewAutomationSnapshot>(
-    const { includeImage: _includeImage, save: _save, ...operationInput } = input ?? {};
-    return invokeTargeted<PreviewAutomationSnapshot>("snapshot", operationInput);
+      "snapshot",
+      includeImage === true || save === true
+        ? operationInput
+        : { ...operationInput, screenshot: false },
+    );
   }),
   preview_click: McpToolAccess.actsAsCaller((input) =>
     invokeTargeted<object>("click", input, input.timeoutMs),
```

## apps/server/src/mcp/toolkits/preview/tools.ts

Status: modified; +1; −1

```diff
@@ -137,7 +137,7 @@
 export const PreviewSnapshotTool = safeBrowserTool(
   Tool.make("preview_snapshot", {
     description:
-      "Use it only when the current request asks for browser work, including checking an edit in the browser. Never use it unasked to verify your own edit, or to repeat a snapshot from an earlier turn. Works on a tab the user is driving. Returns page state, elements, diagnostics, and screenshot size. Target one element with `locator='aria-ref=<ref>'` from `accessibilityTree`, even in frames. Refs die on navigation, a new snapshot, or handoff. Text caps near 20 KB; on your own or an unclaimed tab, `preview_evaluate` reads more. To show a screenshot: `save=true`, then embed ![alt](screenshotPath).",
+      "Use it only when the current request asks for browser work, including checking an edit in the browser. Never use it unasked to verify your own edit, or to repeat a snapshot from an earlier turn. Works on a tab the user is driving. Returns page state, elements, and diagnostics, plus screenshot size with `includeImage`. Target one element with `locator='aria-ref=<ref>'` from `accessibilityTree`, even in frames. Refs die on navigation, a new snapshot, or handoff. Text caps near 20 KB; on your own or an unclaimed tab, `preview_evaluate` reads more. To show a screenshot: `save=true`, then embed ![alt](screenshotPath).",
     parameters: Schema.Struct({
       ...PreviewAutomationTabTargetInput.fields,
       includeImage: Schema.optional(
```

## apps/server/src/preview/ServerBrowser.ts

Status: modified; +5; −3

```diff
@@ -1958,12 +1958,14 @@
         return { tabId: tab.tabId, colorScheme };
       }
       case "snapshot": {
-        return withScreencastsPaused(tab, () =>
+        const screenshot = (input as { readonly screenshot?: boolean }).screenshot !== false;
+        const take = () =>
           ServerBrowserPage.snapshot({
             ...tab,
             renderScale: tab.desktop ? "display" : RENDER_SCALE,
-          }),
-        );
+            screenshot,
+          });
+        return screenshot ? withScreencastsPaused(tab, take) : take();
       }
       case "click": {
         const clickInput = input as PreviewAutomationClickInput;
```

## apps/server/src/preview/ServerBrowserPage.test.ts

Status: modified; +26; −0

```diff
@@ -212,6 +212,32 @@
     expect(tree).not.toContain("PAYLOAD");
   });
 
+  it("reads a page that draws no frames when no screenshot is wanted", async () => {
+    await page.setContent("<button>continue</button>");
+    const methods: Array<string> = [];
+    // Stands in for a hidden tab that never paints, so Chromium never answers its screenshot.
+    const drawsNothing = {
+      send: (...[method, params]: Parameters<CDPSession["send"]>) => {
+        methods.push(method);
+        return method === "Page.captureScreenshot"
+          ? new Promise<never>(() => {})
+          : cdp.send(method, params);
+      },
+    } as CDPSession;
+    const result = await ServerBrowserPage.snapshot({
+      page,
+      cdp: drawsNothing,
+      renderScale: 1,
+      screenshot: false,
+      consoleEntries: [],
+      networkEntries: [],
+      actionTimeline: [],
+    });
+    buttonLocator(result.accessibilityTree, "continue");
+    expect(result.screenshot).toBeUndefined();
+    expect(methods).not.toContain("Page.captureScreenshot");
+  });
+
   it("rejects refs from another tab", async () => {
     await page.setContent("<button>same label</button>");
     const locator = buttonLocator((await takeSnapshot()).accessibilityTree, "same label");
```

## apps/server/src/preview/ServerBrowserPage.ts

Status: modified; +8; −6

```diff
@@ -492,6 +492,8 @@
   readonly cdp: CDPSession;
   /** Headless pages render at the scale the server set; a desktop page at its display's. */
   readonly renderScale: number | "display";
+  /** A screenshot waits for the page's next frame, which a hidden desktop tab must paint. */
+  readonly screenshot?: boolean;
   readonly consoleEntries: ReadonlyArray<PreviewAutomationConsoleEntry>;
   readonly networkEntries: ReadonlyArray<PreviewAutomationNetworkEntry>;
   readonly actionTimeline: PreviewAutomationSnapshot["actionTimeline"];
@@ -536,7 +538,9 @@
         "url" | "title" | "loading" | "visibleText" | "interactiveElements"
       >
     >,
+    input.screenshot === false
+      ? undefined
-    captureViewport(input.page, input.cdp, { format: "png", scale, viewport, offset }),
+      : captureViewport(input.page, input.cdp, { format: "png", scale, viewport, offset }),
   ]);
   void pageAndScreenshot.catch(constVoid);
   // Leaves time for the second read; a frame that never loads holds the tree until then.
@@ -581,11 +585,9 @@
     consoleEntries: [...input.consoleEntries],
     networkEntries: [...input.networkEntries],
     actionTimeline: [...input.actionTimeline],
-    screenshot: {
-      mimeType: "image/png",
+    ...(data === undefined
+      ? {}
+      : { screenshot: { mimeType: "image/png", data, ...pngSize(data) } }),
-      data,
-      ...pngSize(data),
-    },
   };
 };
 
```

## packages/contracts/src/previewAutomation.ts

Status: modified; +9; −6

```diff
@@ -500,12 +500,15 @@
   consoleEntries: Schema.Array(PreviewAutomationConsoleEntry),
   networkEntries: Schema.Array(PreviewAutomationNetworkEntry),
   actionTimeline: Schema.Array(PreviewAutomationActionEvent),
+  /** Absent when the caller asked for none. */
+  screenshot: Schema.optional(
+    Schema.Struct({
+      mimeType: Schema.Literal("image/png"),
+      data: Schema.String,
+      width: Schema.Int,
+      height: Schema.Int,
+    }),
-  screenshot: Schema.Struct({
-    mimeType: Schema.Literal("image/png"),
-    data: Schema.String,
+  ),
-    width: Schema.Int,
-    height: Schema.Int,
-  }),
 });
 export type PreviewAutomationSnapshot = typeof PreviewAutomationSnapshot.Type;
 
```
