Skip to content

Commit 06510f5

Browse files
fix(desktop): background agent tabs move into the panel once T3 is shown
A tab hosted in a never-shown window while T3 was hidden now moves into a panel webview when T3's window takes focus again, so Pick element, Capture, DevTools, Find, picture-in-picture and direct input come back. Registering that webview closes the background window. Co-Authored-By: Claude Opus 5.5 <[email protected]>
1 parent ad8c5a4 commit 06510f5

6 files changed

Lines changed: 97 additions & 17 deletions

File tree

‎apps/desktop/src/preview/Manager.test.ts‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -722,7 +722,7 @@ describe("PreviewManager", () => {
722722
);
723723

724724
effectIt.effect(
725-
"hosts an agent tab in a never-shown window that paints and closes with the tab",
725+
"hosts an agent tab in a never-shown window until a panel webview takes it over",
726726
() =>
727727
withManager((manager) =>
728728
Effect.gen(function* () {
@@ -767,6 +767,15 @@ describe("PreviewManager", () => {
767767
});
768768
expect(state.webContentsId).toBe(55);
769769
expect(loadURL).toHaveBeenCalledWith("https://example.com/agent");
770+
// Back on screen, the renderer moves the tab into a panel webview.
771+
fromId.mockReturnValue(
772+
makeTestPreviewWebContents(async () => ({
773+
toJPEG: () => Buffer.from(""),
774+
getSize: () => ({ width: 1, height: 1 }),
775+
})),
776+
);
777+
yield* manager.registerWebview("tab_agent", 42);
778+
expect(window.destroy).toHaveBeenCalledOnce();
770779
yield* manager.closeTab("tab_agent");
771780
expect(window.destroy).toHaveBeenCalledOnce();
772781
}),

‎apps/desktop/src/preview/Manager.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2084,7 +2084,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
20842084
const expectedGeneration = tabLifecycleGenerations.get(tabId);
20852085
return yield* withTabLifecycleLock(
20862086
tabId,
2087-
registerWebviewUnlocked(tabId, webContentsId, expectedGeneration),
2087+
registerWebviewUnlocked(tabId, webContentsId, expectedGeneration).pipe(
2088+
// The renderer moved a background tab into the panel once T3 was on screen again.
2089+
Effect.tap(() => Effect.sync(() => closeBackgroundWindow(tabId))),
2090+
),
20882091
);
20892092
});
20902093

‎apps/web/src/browser/ElectronBrowserHost.browser.test.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,9 @@ describe("ElectronBrowserHost", () => {
218218
url: `https://example.com/${tabId}`,
219219
});
220220
expect(screen.container.querySelectorAll("webview")).toHaveLength(0);
221+
// Back on screen, the tab moves into the panel, where every desktop tool works.
222+
await act(() => window.dispatchEvent(new Event("focus")));
223+
expect(screen.container.querySelectorAll("webview")).toHaveLength(1);
221224
}
222225
},
223226
);

‎apps/web/src/browser/ElectronBrowserHost.tsx‎

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,20 @@ import { useBrowserPointerStore } from "./browserPointerStore";
3131
import { HostedBrowserWebview } from "./HostedBrowserWebview";
3232
import { openUrlInPreview } from "./openFileInPreview";
3333
import { acquireDesktopTab } from "./desktopTabLifetime";
34-
import { hostsServerTabInBackground, rendersServerTabNatively } from "./previewRuntime";
34+
import {
35+
type BackgroundHosting,
36+
hostsServerTabInBackground,
37+
readBackgroundHosting,
38+
rendersServerTabNatively,
39+
useBackgroundHosting,
40+
} from "./previewRuntime";
3541
import { previewRuntimeTabId } from "./previewRuntimeTabId";
3642

3743
/** Every session this window renders as a hosted webview, in a fixed (non-display) order. */
3844
function hostedBrowserSessions(
3945
previewByThreadKey: Record<string, ThreadPreviewState>,
4046
primaryEnvironmentId: EnvironmentId | null,
47+
hosting: BackgroundHosting,
4148
) {
4249
const activeSessions = Object.entries(previewByThreadKey).flatMap(([threadKey, previewState]) => {
4350
const threadRef = parseScopedThreadKey(threadKey);
@@ -46,8 +53,18 @@ function hostedBrowserSessions(
4653
.filter(
4754
(snapshot) =>
4855
snapshot.runtime !== "server" ||
49-
rendersServerTabNatively(threadRef.environmentId, primaryEnvironmentId, snapshot) ||
50-
hostsServerTabInBackground(threadRef.environmentId, primaryEnvironmentId, snapshot),
56+
rendersServerTabNatively(
57+
threadRef.environmentId,
58+
primaryEnvironmentId,
59+
snapshot,
60+
hosting,
61+
) ||
62+
hostsServerTabInBackground(
63+
threadRef.environmentId,
64+
primaryEnvironmentId,
65+
snapshot,
66+
hosting,
67+
),
5168
)
5269
.map((snapshot) => ({
5370
threadRef,
@@ -56,6 +73,7 @@ function hostedBrowserSessions(
5673
threadRef.environmentId,
5774
primaryEnvironmentId,
5875
snapshot,
76+
hosting,
5977
),
6078
runtimeTabId: previewRuntimeTabId(threadRef, previewState.serverEpoch, snapshot.tabId),
6179
pictureInPicture:
@@ -106,6 +124,7 @@ function openLinkInPreview({ tabId, url, background }: DesktopPreviewOpenLinkEve
106124
const source = hostedBrowserSessions(
107125
readActivePreviewSessions(),
108126
appAtomRegistry.get(primaryEnvironmentIdAtom),
127+
readBackgroundHosting(),
109128
).find((session) => session.runtimeTabId === tabId);
110129
if (!source) return;
111130
void openUrlInPreview({
@@ -137,7 +156,11 @@ export function ElectronBrowserHost() {
137156
const previewByThreadKey = useActivePreviewSessions();
138157
const primaryEnvironmentId = useAtomValue(primaryEnvironmentIdAtom);
139158
useDesktopHostedPreviewSessions(primaryEnvironmentId);
140-
const sessions = hostedBrowserSessions(previewByThreadKey, primaryEnvironmentId);
159+
const sessions = hostedBrowserSessions(
160+
previewByThreadKey,
161+
primaryEnvironmentId,
162+
useBackgroundHosting(),
163+
);
141164

142165
if (!isElectron) return null;
143166
return (

‎apps/web/src/browser/previewRuntime.ts‎

Lines changed: 45 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { useAtomValue } from "@effect/atom-react";
2+
import { useSyncExternalStore } from "react";
23
import type { EnvironmentId, PreviewRuntime, PreviewSessionSnapshot } from "@t3tools/contracts";
34

45
import { isElectron } from "~/env";
@@ -61,17 +62,47 @@ function desktopHostsServerTab(
6162
);
6263
}
6364

64-
// Fixed when a tab first appears: moving a page between hosts would replace it.
65-
const backgroundHostedTabs = new Map<string, boolean>();
65+
// Fixed when a tab first appears, until T3 is on screen again: moving a page between hosts replaces it.
66+
let backgroundHostedTabs = new Map<string, boolean>();
67+
const hostingListeners = new Set<() => void>();
68+
69+
// Its window takes focus when T3 is shown again. Background tabs then move to panel webviews, where
70+
// every desktop tool works, and a new map makes memoized callers look again.
71+
const returnBackgroundTabsToPanel = () => {
72+
if (![...backgroundHostedTabs.values()].some(Boolean)) return;
73+
backgroundHostedTabs = new Map([...backgroundHostedTabs.keys()].map((key) => [key, false]));
74+
for (const listener of hostingListeners) listener();
75+
};
76+
77+
const subscribeBackgroundHosting = (listener: () => void) => {
78+
hostingListeners.add(listener);
79+
if (hostingListeners.size === 1) window.addEventListener("focus", returnBackgroundTabsToPanel);
80+
return () => {
81+
hostingListeners.delete(listener);
82+
if (hostingListeners.size === 0) {
83+
window.removeEventListener("focus", returnBackgroundTabsToPanel);
84+
}
85+
};
86+
};
87+
88+
export type BackgroundHosting = ReadonlyMap<string, boolean>;
89+
90+
export const readBackgroundHosting = (): BackgroundHosting => backgroundHostedTabs;
91+
92+
export function useBackgroundHosting(): BackgroundHosting {
93+
return useSyncExternalStore(subscribeBackgroundHosting, readBackgroundHosting);
94+
}
6695

6796
/**
6897
* A panel `<webview>` created while the app window is hidden or minimized never paints, so an
6998
* agent tab that appears then runs in a never-shown desktop window and the panel streams it.
99+
* `hosting` is the map from `useBackgroundHosting`, passed so memoized callers recompute.
70100
*/
71101
export function hostsServerTabInBackground(
72102
environmentId: EnvironmentId,
73103
primaryEnvironmentId: EnvironmentId | null,
74104
snapshot: RuntimeSnapshot | null | undefined,
105+
hosting: BackgroundHosting,
75106
): boolean {
76107
if (
77108
!snapshot ||
@@ -81,28 +112,33 @@ export function hostsServerTabInBackground(
81112
return false;
82113
}
83114
const key = `${environmentId}\u0000${snapshot.threadId}\u0000${snapshot.tabId}`;
84-
let background = backgroundHostedTabs.get(key);
85-
if (background === undefined) {
86-
background = window.desktopBridge?.getWindowShown?.() === false;
87-
backgroundHostedTabs.set(key, background);
88-
}
115+
const decided = backgroundHostedTabs.get(key) ?? hosting.get(key);
116+
if (decided !== undefined) return decided;
117+
const background = window.desktopBridge?.getWindowShown?.() === false;
118+
backgroundHostedTabs.set(key, background);
89119
return background;
90120
}
91121

92122
export function rendersServerTabNatively(
93123
environmentId: EnvironmentId,
94124
primaryEnvironmentId: EnvironmentId | null,
95125
snapshot: RuntimeSnapshot | null | undefined,
126+
hosting: BackgroundHosting,
96127
): boolean {
97128
return (
98129
desktopHostsServerTab(environmentId, primaryEnvironmentId, snapshot) &&
99-
!hostsServerTabInBackground(environmentId, primaryEnvironmentId, snapshot)
130+
!hostsServerTabInBackground(environmentId, primaryEnvironmentId, snapshot, hosting)
100131
);
101132
}
102133

103134
export function useRendersServerTabNatively(
104135
environmentId: EnvironmentId,
105136
snapshot: RuntimeSnapshot | null | undefined,
106137
): boolean {
107-
return rendersServerTabNatively(environmentId, useAtomValue(primaryEnvironmentIdAtom), snapshot);
138+
return rendersServerTabNatively(
139+
environmentId,
140+
useAtomValue(primaryEnvironmentIdAtom),
141+
snapshot,
142+
useBackgroundHosting(),
143+
);
108144
}

‎apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import {
2424
import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore";
2525
import type { BrowserViewportResizeDirection } from "~/browser/browserViewportLayout";
2626
import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId";
27-
import { rendersServerTabNatively } from "~/browser/previewRuntime";
27+
import { rendersServerTabNatively, useBackgroundHosting } from "~/browser/previewRuntime";
2828
import { primaryEnvironmentIdAtom } from "~/state/primaryEnvironment";
2929
import { type ServerBrowserHandle, ServerBrowserSurface } from "~/browser/ServerBrowserSurface";
3030
import {
@@ -138,10 +138,16 @@ function useBrowserMiniPlayerSource(threadRef: ScopedThreadRef | null, tabId: st
138138
runtimeTabId === null ? null : (state.byTabId[runtimeTabId]?.fittedSourceContent ?? null),
139139
);
140140
const primaryEnvironmentId = useAtomValue(primaryEnvironmentIdAtom);
141+
const backgroundHosting = useBackgroundHosting();
141142
const serverTab =
142143
threadRef !== null &&
143144
snapshot?.runtime === "server" &&
144-
!rendersServerTabNatively(threadRef.environmentId, primaryEnvironmentId, snapshot);
145+
!rendersServerTabNatively(
146+
threadRef.environmentId,
147+
primaryEnvironmentId,
148+
snapshot,
149+
backgroundHosting,
150+
);
145151
const streamViewport = useStreamedSourceSize((state) =>
146152
threadRef &&
147153
tabId !== null &&

0 commit comments

Comments
 (0)