From 1a9746119fac423fa2111d3cc127aaef4e9d2dfc Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 16:43:58 +0000 Subject: [PATCH 1/4] fix(server): preview_open reports a tab it reveals as visible preview_open returned visible:false for a tab it had just asked a client to show, because the client opens the Browser panel and attaches its stream only after it receives the reveal. A revealed streamed tab without viewers now waits up to 1s (bounded by the request deadline) for one to attach, resolving as soon as it does. Desktop-rendered tabs never attach viewers and do not wait. The status `visible` fields now describe what they measure. Fixes pingdotgg/t3code#17015 Co-Authored-By: Claude Opus 5.5 --- apps/server/src/preview/ServerBrowser.ts | 26 +++++++++++++++++++++ packages/contracts/src/previewAutomation.ts | 6 +++-- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index a2485b30e9e36..0235bc5174b65 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -101,10 +101,24 @@ const RECORDING_SCREENCAST = { format: "jpeg", quality: 90, everyNthFrame: 1 } a const decodeViewportSetting = Schema.decodeUnknownSync(PreviewViewportSettingSchema); const AGENT_CURSOR_MOVE_MS = 160; const AGENT_CURSOR_CLICK_LEAD_MS = 40; +// A revealed tab's client opens the panel and attaches its stream a few round trips later, remote ones included. +const REVEAL_VIEWER_WAIT_MS = 1000; const sleepUntil = (deadline: number) => new Promise((resolve) => setTimeout(resolve, Math.max(0, deadline - Date.now()))); +const awaitViewer = async (tab: ServerTab, timeoutMs: number) => { + const attached = (tab.viewerAttached ??= Promise.withResolvers()); + let timer: ReturnType | undefined; + await Promise.race([ + attached.promise, + new Promise((resolve) => { + timer = setTimeout(resolve, Math.max(0, timeoutMs)); + }), + ]); + clearTimeout(timer); +}; + const EDITABLE_AT_POINT_SCRIPT = `(x, y) => { let element = document.elementFromPoint(x, y); while (element && element.shadowRoot) { @@ -283,6 +297,7 @@ interface ServerTab { readonly createdAt: number; usedAt: number; readonly viewers: Set; + viewerAttached: PromiseWithResolvers | null; readonly consoleEntries: Array; readonly networkEntries: Array; readonly actionTimeline: Array; @@ -750,6 +765,7 @@ const make = Effect.gen(function* () { createdAt: Date.now(), usedAt: Date.now(), viewers: new Set(), + viewerAttached: null, consoleEntries: [], networkEntries: [], actionTimeline: [], @@ -1806,6 +1822,7 @@ const make = Effect.gen(function* () { await tab.initialNavigation; } const reveal = open.open ?? open.show; + let shown: Promise | undefined; if (reveal !== false) { await runPromise( manager.requestReveal({ @@ -1814,12 +1831,19 @@ const make = Effect.gen(function* () { force: reveal === true, }), ); + // Desktop webviews never attach viewers, so only streamed tabs wait to be shown. + if (tab.viewers.size === 0 && tab.desktop === null) + shown = awaitViewer( + tab, + Math.min(REVEAL_VIEWER_WAIT_MS, deadlineMs - performance.now()), + ); } if (!existing && url) { await tab.page .waitForLoadState("load", { timeout: navigationTimeout }) .catch(constVoid); } + await shown; return statusWithTitle(tab, request.agentSessionId); }, deadlineMs, @@ -2316,6 +2340,8 @@ const make = Effect.gen(function* () { yield* Effect.acquireRelease( Effect.sync(() => { tab.viewers.add(viewer); + tab.viewerAttached?.resolve(); + tab.viewerAttached = null; reportLiveTabs(); }), () => diff --git a/packages/contracts/src/previewAutomation.ts b/packages/contracts/src/previewAutomation.ts index 5862b643e00ae..ff2f036985eff 100644 --- a/packages/contracts/src/previewAutomation.ts +++ b/packages/contracts/src/previewAutomation.ts @@ -71,9 +71,11 @@ export const PreviewAutomationProfile = Schema.Struct({ }); export type PreviewAutomationProfile = typeof PreviewAutomationProfile.Type; +const VISIBLE_DESCRIPTION = "True while a T3 client shows this tab's live view."; + export const PreviewAutomationStatus = Schema.Struct({ available: Schema.Boolean, - visible: Schema.Boolean, + visible: Schema.Boolean.annotate({ description: VISIBLE_DESCRIPTION }), tabId: Schema.NullOr(PreviewTabId), url: Schema.NullOr(Schema.String), title: Schema.NullOr(Schema.String), @@ -119,7 +121,7 @@ export const PreviewAutomationStatus = Schema.Struct({ openerTabId: Schema.optional(PreviewTabId), owner: Schema.optional(PreviewAutomationTabOwner), ownedByCaller: Schema.optional(Schema.Boolean), - visible: Schema.optional(Schema.Boolean), + visible: Schema.optional(Schema.Boolean.annotate({ description: VISIBLE_DESCRIPTION })), profileId: Schema.optional(BrowserProfileId), }), ), From dfd49a38357b5f735c9145ffdf48b764cadfc424 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 16:54:46 +0000 Subject: [PATCH 2/4] fix(server): wait only for explicit preview shows; describe visible as a streamed view Co-Authored-By: Claude Opus 5.5 --- apps/server/src/preview/ServerBrowser.ts | 4 ++-- packages/contracts/src/previewAutomation.ts | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index 0235bc5174b65..47ab731ec1dc6 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -1831,8 +1831,8 @@ const make = Effect.gen(function* () { force: reveal === true, }), ); - // Desktop webviews never attach viewers, so only streamed tabs wait to be shown. - if (tab.viewers.size === 0 && tab.desktop === null) + // Only an explicit show is sure to reach a client, and desktop webviews never attach viewers. + if (reveal === true && tab.viewers.size === 0 && tab.desktop === null) shown = awaitViewer( tab, Math.min(REVEAL_VIEWER_WAIT_MS, deadlineMs - performance.now()), diff --git a/packages/contracts/src/previewAutomation.ts b/packages/contracts/src/previewAutomation.ts index ff2f036985eff..25364591c7104 100644 --- a/packages/contracts/src/previewAutomation.ts +++ b/packages/contracts/src/previewAutomation.ts @@ -71,7 +71,8 @@ export const PreviewAutomationProfile = Schema.Struct({ }); export type PreviewAutomationProfile = typeof PreviewAutomationProfile.Type; -const VISIBLE_DESCRIPTION = "True while a T3 client shows this tab's live view."; +const VISIBLE_DESCRIPTION = + "True while a client streams this tab's live view. Tabs the desktop app renders itself read false."; export const PreviewAutomationStatus = Schema.Struct({ available: Schema.Boolean, From 0b70531b3187665650f89b71f1c15b666f13b09d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 17:05:28 +0000 Subject: [PATCH 3/4] test(server): preview_open sees a viewer that attaches after the reveal; reword visible The test fails on main (visible false) and passes with the bounded wait. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/preview/ServerBrowser.test.ts | 27 +++++++++++++++++++ packages/contracts/src/previewAutomation.ts | 2 +- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index b87bc8d9ca4d8..463ce4afd2dac 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -1201,6 +1201,33 @@ it.live("agents read a human's tab with no arguments and act on it only while no ).pipe(Effect.provide(layer)), ); +it.live("preview_open reports a tab it shows as visible once the client attaches", () => + Effect.scoped( + Effect.gen(function* () { + const browser = yield* ServerBrowser.ServerBrowser; + const broker = yield* Broker.PreviewAutomationBroker; + const manager = yield* Manager.PreviewManager; + const events = yield* manager.subscribeEvents; + yield* Effect.yieldNow; + // Like a real client, the stream attaches asynchronously after the reveal request arrives. + yield* Effect.gen(function* () { + let event; + do event = yield* PubSub.take(events); + while (event.type !== "navigated" || !event.snapshot.revealRequest); + yield* browser.attachViewer(viewerInput(event.tabId, false)).pipe(Effect.forkScoped); + }).pipe(Effect.forkScoped); + + const opened = yield* broker.invoke({ + scope, + operation: "open", + input: { reuseExistingTab: false, open: true }, + }); + + expect(opened.visible).toBe(true); + }), + ).pipe(Effect.provide(layer)), +); + it.live("a session's own tab stays its default while other sessions' tabs are listed", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/contracts/src/previewAutomation.ts b/packages/contracts/src/previewAutomation.ts index 25364591c7104..c899ef721d3e8 100644 --- a/packages/contracts/src/previewAutomation.ts +++ b/packages/contracts/src/previewAutomation.ts @@ -72,7 +72,7 @@ export const PreviewAutomationProfile = Schema.Struct({ export type PreviewAutomationProfile = typeof PreviewAutomationProfile.Type; const VISIBLE_DESCRIPTION = - "True while a client streams this tab's live view. Tabs the desktop app renders itself read false."; + "True while any client streams this tab's live view. The desktop app's own webview does not count."; export const PreviewAutomationStatus = Schema.Struct({ available: Schema.Boolean, From c637daeb53a6eea5d4fc053eab35eb5f60c5b939 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 00:56:48 +0000 Subject: [PATCH 4/4] fix(server): count desktop-shown preview tabs as visible; end the reveal wait when the tab closes Main now reports a tab the desktop panel shows as visible, so the visible description no longer excludes it. Closing a tab while preview_open waits for its viewer now ends the wait at once instead of after a second. AI-assisted; verified with the tests and tools listed above. --- apps/server/src/preview/ServerBrowser.ts | 3 ++- packages/contracts/src/previewAutomation.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index 903afbaf571f5..aeecf6acc528e 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -762,6 +762,7 @@ const make = Effect.gen(function* () { if (tabs.get(key) !== tab) return; tabs.delete(key); tab.closing = true; + tab.viewerAttached?.resolve(); clearAbortedNavigation(tab); // A desktop page outlives the connection unless its session closed with it. const end = tab.desktop && !closeSession && sessionOpen(tab) ? "reconnect" : "gone"; @@ -2110,7 +2111,7 @@ const make = Effect.gen(function* () { force: reveal === true, }), ); - // Only an explicit show is sure to reach a client, and desktop webviews never attach viewers. + // Only an explicit show is sure to reach a client; desktop tabs are visible without a viewer. if (reveal === true && tab.viewers.size === 0 && tab.desktop === null) shown = awaitViewer( tab, diff --git a/packages/contracts/src/previewAutomation.ts b/packages/contracts/src/previewAutomation.ts index f9443273ad213..ed8ce51be6b8e 100644 --- a/packages/contracts/src/previewAutomation.ts +++ b/packages/contracts/src/previewAutomation.ts @@ -72,7 +72,7 @@ export const PreviewAutomationProfile = Schema.Struct({ export type PreviewAutomationProfile = typeof PreviewAutomationProfile.Type; const VISIBLE_DESCRIPTION = - "True while any client streams this tab's live view. The desktop app's own webview does not count."; + "True while a client shows this tab: a streamed live view, or the desktop app's own browser panel."; export const PreviewAutomationStatus = Schema.Struct({ available: Schema.Boolean,