From 166446cf68c2322e29ffde6de79ddad9a28abfeb Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Sun, 5 Apr 2026 02:45:39 -0500 Subject: [PATCH] Scope preview tabs by thread - Add IPC to activate a thread's preview set - Keep preview tabs and WebContentsView state isolated per thread - Pass thread IDs from the preview panel when opening tabs --- apps/desktop/src/main.ts | 14 +- apps/desktop/src/preload.ts | 2 + apps/desktop/src/previewController.ts | 251 +++++++++++++++++++---- apps/web/src/components/PreviewPanel.tsx | 16 +- packages/contracts/src/ipc.ts | 4 +- 5 files changed, 239 insertions(+), 48 deletions(-) diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 86d96efaa..88a114384 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -65,6 +65,7 @@ const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; const PREVIEW_CREATE_TAB_CHANNEL = "desktop:preview-create-tab"; const PREVIEW_CLOSE_TAB_CHANNEL = "desktop:preview-close-tab"; const PREVIEW_ACTIVATE_TAB_CHANNEL = "desktop:preview-activate-tab"; +const PREVIEW_ACTIVATE_THREAD_CHANNEL = "desktop:preview-activate-thread"; const PREVIEW_GO_BACK_CHANNEL = "desktop:preview-go-back"; const PREVIEW_GO_FORWARD_CHANNEL = "desktop:preview-go-forward"; const PREVIEW_RELOAD_CHANNEL = "desktop:preview-reload"; @@ -1275,7 +1276,7 @@ function registerIpcHandlers(): void { ipcMain.removeHandler(PREVIEW_CREATE_TAB_CHANNEL); ipcMain.handle( PREVIEW_CREATE_TAB_CHANNEL, - async (event, input: { url?: unknown; title?: unknown }) => { + async (event, input: { url?: unknown; title?: unknown; threadId?: unknown }) => { const window = resolvePreviewWindow(event.sender); if (!window) { return { tabId: "", state: createEmptyTabsState() }; @@ -1283,10 +1284,21 @@ function registerIpcHandlers(): void { return getPreviewController(window).createTab({ url: input?.url, title: input?.title, + threadId: input?.threadId, }); }, ); + ipcMain.removeHandler(PREVIEW_ACTIVATE_THREAD_CHANNEL); + ipcMain.handle( + PREVIEW_ACTIVATE_THREAD_CHANNEL, + async (event, input: { threadId?: string }) => { + const window = resolvePreviewWindow(event.sender); + if (!window || !input?.threadId) return createEmptyTabsState(); + return getPreviewController(window).activateThread(input.threadId); + }, + ); + ipcMain.removeHandler(PREVIEW_CLOSE_TAB_CHANNEL); ipcMain.handle(PREVIEW_CLOSE_TAB_CHANNEL, async (event, input: { tabId?: PreviewTabId }) => { const window = resolvePreviewWindow(event.sender); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 91477c600..44f1c3a51 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -15,6 +15,7 @@ const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; const PREVIEW_CREATE_TAB_CHANNEL = "desktop:preview-create-tab"; const PREVIEW_CLOSE_TAB_CHANNEL = "desktop:preview-close-tab"; const PREVIEW_ACTIVATE_TAB_CHANNEL = "desktop:preview-activate-tab"; +const PREVIEW_ACTIVATE_THREAD_CHANNEL = "desktop:preview-activate-thread"; const PREVIEW_GO_BACK_CHANNEL = "desktop:preview-go-back"; const PREVIEW_GO_FORWARD_CHANNEL = "desktop:preview-go-forward"; const PREVIEW_RELOAD_CHANNEL = "desktop:preview-reload"; @@ -63,6 +64,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { createTab: (input) => ipcRenderer.invoke(PREVIEW_CREATE_TAB_CHANNEL, input), closeTab: (input) => ipcRenderer.invoke(PREVIEW_CLOSE_TAB_CHANNEL, input), activateTab: (input) => ipcRenderer.invoke(PREVIEW_ACTIVATE_TAB_CHANNEL, input), + activateThread: (input) => ipcRenderer.invoke(PREVIEW_ACTIVATE_THREAD_CHANNEL, input), goBack: () => ipcRenderer.invoke(PREVIEW_GO_BACK_CHANNEL), goForward: () => ipcRenderer.invoke(PREVIEW_GO_FORWARD_CHANNEL), reload: () => ipcRenderer.invoke(PREVIEW_RELOAD_CHANNEL), diff --git a/apps/desktop/src/previewController.ts b/apps/desktop/src/previewController.ts index 9092d2a14..010e92dab 100644 --- a/apps/desktop/src/previewController.ts +++ b/apps/desktop/src/previewController.ts @@ -20,15 +20,32 @@ const PREVIEW_WEB_PREFERENCES = { const ZERO_BOUNDS = { x: 0, y: 0, width: 0, height: 0 } as const; +/** + * Maximum total WebContentsView instances across all threads. + * When this limit is exceeded, the least-recently-used thread's + * views are disposed to free resources. + */ +const MAX_TOTAL_VIEWS = 20; + interface TabEntry { id: PreviewTabId; view: WebContentsView; state: PreviewTabState; } +interface ThreadTabSet { + tabs: Map; + activeTabId: PreviewTabId | null; +} + export class DesktopPreviewController { - private tabs: Map = new Map(); - private activeTabId: PreviewTabId | null = null; + /** Per-thread tab storage. */ + private threadTabs: Map = new Map(); + /** The thread whose tabs are currently visible. */ + private activeThreadId: string | null = null; + /** LRU ordering of thread IDs (most recently used last). */ + private threadLru: string[] = []; + private bounds: DesktopPreviewBounds = { x: 0, y: 0, @@ -45,19 +62,64 @@ export class DesktopPreviewController { private readonly onStateChange: (state: PreviewTabsState) => void, ) {} + // ── Thread management ───────────────────────────────────────── + + /** + * Switch the active thread. Hides the current thread's tabs and + * shows the target thread's tabs (creating the set if needed). + */ + activateThread(threadId: string): PreviewTabsState { + if (this.activeThreadId === threadId) { + return this.buildTabsState(); + } + + // Hide all views from the old thread + this.hideActiveThreadViews(); + + this.activeThreadId = threadId; + this.touchThreadLru(threadId); + + // Ensure the thread set exists + if (!this.threadTabs.has(threadId)) { + this.threadTabs.set(threadId, { tabs: new Map(), activeTabId: null }); + } + + // Show the active tab of the new thread + this.applyActiveTabBounds(); + + return this.broadcastState(); + } + + // ── Public API (operates on the active thread) ──────────────── + getState(): PreviewTabsState { return this.buildTabsState(); } - async createTab(input: { url: unknown; title?: unknown }): Promise { + async createTab(input: { + url: unknown; + title?: unknown; + threadId?: unknown; + }): Promise { + // If a threadId is provided and differs from the active one, switch first + const requestedThread = + typeof input.threadId === "string" && input.threadId.length > 0 + ? input.threadId + : this.activeThreadId; + + if (requestedThread && requestedThread !== this.activeThreadId) { + this.activateThread(requestedThread); + } + const validatedUrl = validateDesktopPreviewUrl(input.url); if (!validatedUrl.ok) { - // Still create the tab but in error state const tabId = randomUUID(); - // Don't actually create a view for invalid URLs return { tabId, state: this.buildTabsState() }; } + // Enforce global view limit before creating a new one + this.enforceViewLimit(); + const tabId = randomUUID(); const nextTitle = typeof input.title === "string" && input.title.trim().length > 0 ? input.title : null; @@ -75,14 +137,15 @@ export class DesktopPreviewController { }; const entry: TabEntry = { id: tabId, view, state: tabState }; - this.tabs.set(tabId, entry); + const threadSet = this.getActiveThreadSet(); + threadSet.tabs.set(tabId, entry); // Switch to this tab this.activateTabInternal(tabId); // Load URL void view.webContents.loadURL(validatedUrl.url).catch((error: unknown) => { - const tab = this.tabs.get(tabId); + const tab = threadSet.tabs.get(tabId); if (!tab || tab.view !== view) return; tab.state = { ...tab.state, @@ -100,17 +163,18 @@ export class DesktopPreviewController { } closeTab(tabId: PreviewTabId): PreviewTabsState { - const entry = this.tabs.get(tabId); + const threadSet = this.getActiveThreadSet(); + const entry = threadSet.tabs.get(tabId); if (!entry) return this.buildTabsState(); this.disposeTabView(entry); - this.tabs.delete(tabId); + threadSet.tabs.delete(tabId); // If this was the active tab, activate an adjacent one - if (this.activeTabId === tabId) { - const remaining = Array.from(this.tabs.keys()); - this.activeTabId = remaining.length > 0 ? remaining[remaining.length - 1]! : null; - if (this.activeTabId) { + if (threadSet.activeTabId === tabId) { + const remaining = Array.from(threadSet.tabs.keys()); + threadSet.activeTabId = remaining.length > 0 ? remaining[remaining.length - 1]! : null; + if (threadSet.activeTabId) { this.applyActiveTabBounds(); } } @@ -119,7 +183,8 @@ export class DesktopPreviewController { } activateTab(tabId: PreviewTabId): PreviewTabsState { - if (!this.tabs.has(tabId)) return this.buildTabsState(); + const threadSet = this.getActiveThreadSet(); + if (!threadSet.tabs.has(tabId)) return this.buildTabsState(); this.activateTabInternal(tabId); return this.broadcastState(); } @@ -165,7 +230,8 @@ export class DesktopPreviewController { this.broadcastState(); void activeEntry.view.webContents.loadURL(validatedUrl.url).catch((error: unknown) => { - const tab = this.tabs.get(activeEntry.id); + const threadSet = this.getActiveThreadSet(); + const tab = threadSet.tabs.get(activeEntry.id); if (!tab || tab.view !== activeEntry.view) return; tab.state = { ...tab.state, @@ -187,12 +253,16 @@ export class DesktopPreviewController { view.webContents.toggleDevTools(); } + /** Close all tabs for the active thread only. */ closeAll(): void { - for (const entry of this.tabs.values()) { + const threadSet = this.threadTabs.get(this.activeThreadId ?? ""); + if (!threadSet) return; + + for (const entry of threadSet.tabs.values()) { this.disposeTabView(entry); } - this.tabs.clear(); - this.activeTabId = null; + threadSet.tabs.clear(); + threadSet.activeTabId = null; this.broadcastState(); } @@ -203,10 +273,87 @@ export class DesktopPreviewController { } destroy(): void { - this.closeAll(); + // Destroy all tabs across all threads + for (const threadSet of this.threadTabs.values()) { + for (const entry of threadSet.tabs.values()) { + this.disposeTabView(entry); + } + threadSet.tabs.clear(); + } + this.threadTabs.clear(); + this.activeThreadId = null; + this.threadLru = []; } - // --- Private helpers --- + // ── Private helpers ─────────────────────────────────────────── + + private getActiveThreadSet(): ThreadTabSet { + if (!this.activeThreadId) { + // Create a transient "default" thread set + const threadId = "__default__"; + this.activeThreadId = threadId; + if (!this.threadTabs.has(threadId)) { + this.threadTabs.set(threadId, { tabs: new Map(), activeTabId: null }); + } + } + return this.threadTabs.get(this.activeThreadId)!; + } + + private hideActiveThreadViews(): void { + if (!this.activeThreadId) return; + const threadSet = this.threadTabs.get(this.activeThreadId); + if (!threadSet) return; + + for (const entry of threadSet.tabs.values()) { + if (!entry.view.webContents.isDestroyed()) { + entry.view.setBounds(ZERO_BOUNDS); + } + } + } + + private touchThreadLru(threadId: string): void { + const idx = this.threadLru.indexOf(threadId); + if (idx !== -1) { + this.threadLru.splice(idx, 1); + } + this.threadLru.push(threadId); + } + + /** + * Enforce the global MAX_TOTAL_VIEWS limit by evicting the + * least-recently-used thread's views when the cap is exceeded. + */ + private enforceViewLimit(): void { + let totalViews = 0; + for (const threadSet of this.threadTabs.values()) { + totalViews += threadSet.tabs.size; + } + + // Evict from least-recently-used threads until under limit + while (totalViews >= MAX_TOTAL_VIEWS && this.threadLru.length > 0) { + const lruThreadId = this.threadLru[0]!; + // Never evict the active thread + if (lruThreadId === this.activeThreadId) { + if (this.threadLru.length <= 1) break; + // Move it to the end and try the next + this.threadLru.shift(); + this.threadLru.push(lruThreadId); + continue; + } + + const threadSet = this.threadTabs.get(lruThreadId); + if (threadSet) { + for (const entry of threadSet.tabs.values()) { + this.disposeTabView(entry); + totalViews--; + } + threadSet.tabs.clear(); + threadSet.activeTabId = null; + } + this.threadLru.shift(); + this.threadTabs.delete(lruThreadId); + } + } private createView(tabId: PreviewTabId): WebContentsView { const view = new WebContentsView({ @@ -223,8 +370,17 @@ export class DesktopPreviewController { private bindView(tabId: PreviewTabId, view: WebContentsView): void { const { webContents } = view; + // We need to look up the tab in whichever thread owns it. + const findTab = (): TabEntry | undefined => { + for (const threadSet of this.threadTabs.values()) { + const tab = threadSet.tabs.get(tabId); + if (tab) return tab; + } + return undefined; + }; + webContents.on("did-start-loading", () => { - const tab = this.tabs.get(tabId); + const tab = findTab(); if (!tab) return; tab.state = { ...tab.state, @@ -236,7 +392,7 @@ export class DesktopPreviewController { }); webContents.on("did-stop-loading", () => { - const tab = this.tabs.get(tabId); + const tab = findTab(); if (!tab || tab.state.status === "error") return; tab.state = { ...tab.state, @@ -250,7 +406,7 @@ export class DesktopPreviewController { }); webContents.on("did-navigate", (_event, url) => { - const tab = this.tabs.get(tabId); + const tab = findTab(); if (!tab) return; tab.state = { ...tab.state, @@ -262,7 +418,7 @@ export class DesktopPreviewController { }); webContents.on("did-navigate-in-page", (_event, url) => { - const tab = this.tabs.get(tabId); + const tab = findTab(); if (!tab) return; tab.state = { ...tab.state, @@ -275,7 +431,7 @@ export class DesktopPreviewController { webContents.on("page-title-updated", (event, title) => { event.preventDefault(); - const tab = this.tabs.get(tabId); + const tab = findTab(); if (!tab) return; tab.state = { ...tab.state, title: title || null }; this.broadcastState(); @@ -285,7 +441,7 @@ export class DesktopPreviewController { "did-fail-load", (_event, errorCode, errorDescription, validatedUrl, isMainFrame) => { if (!isMainFrame || errorCode === -3) return; // -3 = aborted - const tab = this.tabs.get(tabId); + const tab = findTab(); if (!tab) return; tab.state = { ...tab.state, @@ -301,7 +457,7 @@ export class DesktopPreviewController { ); webContents.on("render-process-gone", () => { - const tab = this.tabs.get(tabId); + const tab = findTab(); if (!tab) return; tab.state = { ...tab.state, @@ -313,7 +469,7 @@ export class DesktopPreviewController { webContents.once("destroyed", () => { if (this.disposingTab) return; - const tab = this.tabs.get(tabId); + const tab = findTab(); if (!tab) return; tab.state = { ...tab.state, @@ -325,14 +481,14 @@ export class DesktopPreviewController { // DevTools tracking webContents.on("devtools-opened", () => { - const tab = this.tabs.get(tabId); + const tab = findTab(); if (!tab) return; tab.state = { ...tab.state, devToolsOpen: true }; this.broadcastState(); }); webContents.on("devtools-closed", () => { - const tab = this.tabs.get(tabId); + const tab = findTab(); if (!tab) return; tab.state = { ...tab.state, devToolsOpen: false }; this.broadcastState(); @@ -340,7 +496,7 @@ export class DesktopPreviewController { // Unrestricted navigation: no will-navigate blocker - // window.open() → create a new tab + // window.open() -> create a new tab in the same thread webContents.setWindowOpenHandler((details) => { void this.createTab({ url: details.url }); return { action: "deny" }; @@ -394,21 +550,24 @@ export class DesktopPreviewController { } private activateTabInternal(tabId: PreviewTabId): void { - // Hide current active tab - if (this.activeTabId && this.activeTabId !== tabId) { - const oldEntry = this.tabs.get(this.activeTabId); + const threadSet = this.getActiveThreadSet(); + + // Hide current active tab within this thread + if (threadSet.activeTabId && threadSet.activeTabId !== tabId) { + const oldEntry = threadSet.tabs.get(threadSet.activeTabId); if (oldEntry && !oldEntry.view.webContents.isDestroyed()) { oldEntry.view.setBounds(ZERO_BOUNDS); } } - this.activeTabId = tabId; + threadSet.activeTabId = tabId; this.applyActiveTabBounds(); } private applyActiveTabBounds(): void { - if (!this.activeTabId) return; - const entry = this.tabs.get(this.activeTabId); + const threadSet = this.threadTabs.get(this.activeThreadId ?? ""); + if (!threadSet?.activeTabId) return; + const entry = threadSet.tabs.get(threadSet.activeTabId); if (!entry || entry.view.webContents.isDestroyed()) return; const nextBounds = projectPreviewBoundsToContent(this.bounds, this.window.getContentBounds()); @@ -416,8 +575,9 @@ export class DesktopPreviewController { } private getActiveEntry(): TabEntry | null { - if (!this.activeTabId) return null; - return this.tabs.get(this.activeTabId) ?? null; + const threadSet = this.threadTabs.get(this.activeThreadId ?? ""); + if (!threadSet?.activeTabId) return null; + return threadSet.tabs.get(threadSet.activeTabId) ?? null; } private getActiveView(): WebContentsView | null { @@ -438,9 +598,15 @@ export class DesktopPreviewController { this.disposingTab = false; } + /** Build state for the active thread only (what the renderer sees). */ private buildTabsState(): PreviewTabsState { + const threadSet = this.threadTabs.get(this.activeThreadId ?? ""); + if (!threadSet) { + return { tabs: [], activeTabId: null, visible: false }; + } + const tabs: PreviewTabState[] = []; - for (const entry of this.tabs.values()) { + for (const entry of threadSet.tabs.values()) { // Refresh navigation state from live webContents const wc = entry.view.webContents; if (!wc.isDestroyed()) { @@ -453,11 +619,12 @@ export class DesktopPreviewController { tabs.push(entry.state); } - const visible = this.bounds.visible && tabs.length > 0 && this.activeTabId !== null; + const visible = + this.bounds.visible && tabs.length > 0 && threadSet.activeTabId !== null; return { tabs, - activeTabId: this.activeTabId, + activeTabId: threadSet.activeTabId, visible, }; } diff --git a/apps/web/src/components/PreviewPanel.tsx b/apps/web/src/components/PreviewPanel.tsx index 20ca08592..25659b79a 100644 --- a/apps/web/src/components/PreviewPanel.tsx +++ b/apps/web/src/components/PreviewPanel.tsx @@ -129,6 +129,14 @@ export function PreviewPanel({ threadId, onClose }: PreviewPanelProps) { } }, [activeTab?.tabId, activeTab?.url]); + // Activate this thread's preview tabs when the threadId changes + useEffect(() => { + if (!previewBridge) return; + void previewBridge.activateThread({ threadId }).then((state) => { + setTabsState(state); + }); + }, [previewBridge, threadId]); + // Subscribe to state changes useEffect(() => { if (!previewBridge) { @@ -262,8 +270,8 @@ export function PreviewPanel({ threadId, onClose }: PreviewPanelProps) { // Navigate existing active tab void previewBridge?.navigate({ url: validatedUrl.url }); } else { - // Create a new tab - void previewBridge?.createTab({ url: validatedUrl.url }); + // Create a new tab for this thread + void previewBridge?.createTab({ url: validatedUrl.url, threadId }); } }; @@ -272,12 +280,12 @@ export function PreviewPanel({ threadId, onClose }: PreviewPanelProps) { if (url.length > 0) { const validatedUrl = validateHttpPreviewUrl(url); if (validatedUrl.ok) { - void previewBridge?.createTab({ url: validatedUrl.url }); + void previewBridge?.createTab({ url: validatedUrl.url, threadId }); return; } } // Create tab with a default page - void previewBridge?.createTab({ url: "https://www.google.com" }); + void previewBridge?.createTab({ url: "https://www.google.com", threadId }); }; const onClosePreview = () => { diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index ce2d86f95..73e7804e3 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -237,9 +237,11 @@ export interface DesktopBridge { installUpdate: () => Promise; onUpdateState: (listener: (state: DesktopUpdateState) => void) => () => void; preview: { - createTab: (input: { url: string; title?: string | null }) => Promise; + createTab: (input: { url: string; title?: string | null; threadId?: string | null }) => Promise; closeTab: (input: { tabId: PreviewTabId }) => Promise; activateTab: (input: { tabId: PreviewTabId }) => Promise; + /** Switch the preview controller to show the tabs for the given thread. */ + activateThread: (input: { threadId: string }) => Promise; goBack: () => Promise; goForward: () => Promise; reload: () => Promise;