From 98e2e0d8eddd1bd03e0b6c02c755545d90e3e22d Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Sun, 5 Apr 2026 19:34:30 -0500 Subject: [PATCH] Add pinned preview tabs - Add IPC and bridge support for pinning preview tabs - Keep pinned tabs ordered first and prevent direct close - Update preview UI to show pin and unpin controls --- apps/desktop/src/main.ts | 8 ++ apps/desktop/src/preload.ts | 2 + apps/desktop/src/previewController.test.ts | 103 +++++++++++++++++++++ apps/desktop/src/previewController.ts | 16 ++++ apps/web/src/components/PreviewPanel.tsx | 60 +++++++++--- packages/contracts/src/ipc.ts | 2 + 6 files changed, 177 insertions(+), 14 deletions(-) diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index cb714f38..b12c3df3 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -74,6 +74,7 @@ const PREVIEW_NAVIGATE_CHANNEL = "desktop:preview-navigate"; const PREVIEW_TOGGLE_DEVTOOLS_CHANNEL = "desktop:preview-toggle-devtools"; const PREVIEW_GET_STATE_CHANNEL = "desktop:preview-get-state"; const PREVIEW_SET_BOUNDS_CHANNEL = "desktop:preview-set-bounds"; +const PREVIEW_TOGGLE_PIN_TAB_CHANNEL = "desktop:preview-toggle-pin-tab"; const PREVIEW_CLOSE_ALL_CHANNEL = "desktop:preview-close-all"; const PREVIEW_TABS_STATE_CHANNEL = "desktop:preview-tabs-state"; const BASE_DIR = process.env.OKCODE_HOME?.trim() || Path.join(OS.homedir(), ".okcode"); @@ -1348,6 +1349,13 @@ function registerIpcHandlers(): void { getPreviewController(window).toggleDevTools(); }); + ipcMain.removeHandler(PREVIEW_TOGGLE_PIN_TAB_CHANNEL); + ipcMain.handle(PREVIEW_TOGGLE_PIN_TAB_CHANNEL, async (event, input: { tabId?: PreviewTabId }) => { + const window = resolvePreviewWindow(event.sender); + if (!window || !input?.tabId) return createEmptyTabsState(); + return getPreviewController(window).togglePinTab(input.tabId); + }); + ipcMain.removeHandler(PREVIEW_GET_STATE_CHANNEL); ipcMain.handle(PREVIEW_GET_STATE_CHANNEL, async (event) => { const window = resolvePreviewWindow(event.sender); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 44f1c3a5..32242e98 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -23,6 +23,7 @@ const PREVIEW_NAVIGATE_CHANNEL = "desktop:preview-navigate"; const PREVIEW_TOGGLE_DEVTOOLS_CHANNEL = "desktop:preview-toggle-devtools"; const PREVIEW_GET_STATE_CHANNEL = "desktop:preview-get-state"; const PREVIEW_SET_BOUNDS_CHANNEL = "desktop:preview-set-bounds"; +const PREVIEW_TOGGLE_PIN_TAB_CHANNEL = "desktop:preview-toggle-pin-tab"; const PREVIEW_CLOSE_ALL_CHANNEL = "desktop:preview-close-all"; const PREVIEW_TABS_STATE_CHANNEL = "desktop:preview-tabs-state"; const wsUrl = process.env.OKCODE_DESKTOP_WS_URL ?? null; @@ -70,6 +71,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { reload: () => ipcRenderer.invoke(PREVIEW_RELOAD_CHANNEL), navigate: (input) => ipcRenderer.invoke(PREVIEW_NAVIGATE_CHANNEL, input), toggleDevTools: () => ipcRenderer.invoke(PREVIEW_TOGGLE_DEVTOOLS_CHANNEL), + togglePinTab: (input) => ipcRenderer.invoke(PREVIEW_TOGGLE_PIN_TAB_CHANNEL, input), setBounds: (bounds) => ipcRenderer.invoke(PREVIEW_SET_BOUNDS_CHANNEL, bounds), closeAll: () => ipcRenderer.invoke(PREVIEW_CLOSE_ALL_CHANNEL), getState: () => ipcRenderer.invoke(PREVIEW_GET_STATE_CHANNEL), diff --git a/apps/desktop/src/previewController.test.ts b/apps/desktop/src/previewController.test.ts index dea54fd1..fe70eda4 100644 --- a/apps/desktop/src/previewController.test.ts +++ b/apps/desktop/src/previewController.test.ts @@ -349,4 +349,107 @@ describe("DesktopPreviewController", () => { controller.toggleDevTools(); expect(findTab(latestState!, tabId)?.devToolsOpen).toBe(false); }); + + it("pins and unpins a tab", async () => { + let latestState = null as PreviewTabsState | null; + const window = createWindow(); + const controller = new DesktopPreviewController(window, (state) => { + latestState = state; + }); + + controller.setBounds({ + x: 0, + y: 0, + width: 960, + height: 640, + visible: true, + viewportWidth: 1024, + viewportHeight: 768, + }); + + const { tabId } = await controller.createTab({ url: "http://localhost:3000/" }); + expect(findTab(latestState!, tabId)?.isPinned).toBe(false); + + // Pin the tab + controller.togglePinTab(tabId); + expect(findTab(latestState!, tabId)?.isPinned).toBe(true); + + // Unpin the tab + controller.togglePinTab(tabId); + expect(findTab(latestState!, tabId)?.isPinned).toBe(false); + }); + + it("sorts pinned tabs before unpinned tabs", async () => { + let latestState = null as PreviewTabsState | null; + const window = createWindow(); + const controller = new DesktopPreviewController(window, (state) => { + latestState = state; + }); + + controller.setBounds({ + x: 0, + y: 0, + width: 960, + height: 640, + visible: true, + viewportWidth: 1024, + viewportHeight: 768, + }); + + const { tabId: tab1Id } = await controller.createTab({ url: "http://localhost:3000/" }); + const { tabId: tab2Id } = await controller.createTab({ url: "http://localhost:4000/" }); + const { tabId: tab3Id } = await controller.createTab({ url: "http://localhost:5000/" }); + + // Tab order should be: tab1, tab2, tab3 + expect(latestState!.tabs.map((t) => t.tabId)).toEqual([tab1Id, tab2Id, tab3Id]); + + // Pin tab3 — it should move to the front + controller.togglePinTab(tab3Id); + expect(latestState!.tabs[0]!.tabId).toBe(tab3Id); + expect(latestState!.tabs[0]!.isPinned).toBe(true); + + // Pin tab1 — both pinned tabs should come first + controller.togglePinTab(tab1Id); + const pinnedTabs = latestState!.tabs.filter((t) => t.isPinned); + const unpinnedTabs = latestState!.tabs.filter((t) => !t.isPinned); + expect(pinnedTabs).toHaveLength(2); + expect(unpinnedTabs).toHaveLength(1); + expect(unpinnedTabs[0]!.tabId).toBe(tab2Id); + }); + + it("prevents closing a pinned tab", async () => { + let latestState = null as PreviewTabsState | null; + const window = createWindow(); + const controller = new DesktopPreviewController(window, (state) => { + latestState = state; + }); + + controller.setBounds({ + x: 0, + y: 0, + width: 960, + height: 640, + visible: true, + viewportWidth: 1024, + viewportHeight: 768, + }); + + const { tabId } = await controller.createTab({ url: "http://localhost:3000/" }); + + // Pin the tab + controller.togglePinTab(tabId); + expect(findTab(latestState!, tabId)?.isPinned).toBe(true); + + // Attempting to close should have no effect + controller.closeTab(tabId); + expect(latestState!.tabs).toHaveLength(1); + expect(findTab(latestState!, tabId)).toBeTruthy(); + + // Unpin, then close should work + controller.togglePinTab(tabId); + expect(findTab(latestState!, tabId)?.isPinned).toBe(false); + + controller.closeTab(tabId); + expect(latestState!.tabs).toHaveLength(0); + }); }); diff --git a/apps/desktop/src/previewController.ts b/apps/desktop/src/previewController.ts index d4af5002..5c1ebb18 100644 --- a/apps/desktop/src/previewController.ts +++ b/apps/desktop/src/previewController.ts @@ -134,6 +134,7 @@ export class DesktopPreviewController { canGoBack: false, canGoForward: false, devToolsOpen: false, + isPinned: false, }; const entry: TabEntry = { id: tabId, view, state: tabState }; @@ -167,6 +168,9 @@ export class DesktopPreviewController { const entry = threadSet.tabs.get(tabId); if (!entry) return this.buildTabsState(); + // Pinned tabs cannot be closed directly — unpin first + if (entry.state.isPinned) return this.buildTabsState(); + this.disposeTabView(entry); threadSet.tabs.delete(tabId); @@ -182,6 +186,15 @@ export class DesktopPreviewController { return this.broadcastState(); } + togglePinTab(tabId: PreviewTabId): PreviewTabsState { + const threadSet = this.getActiveThreadSet(); + const entry = threadSet.tabs.get(tabId); + if (!entry) return this.buildTabsState(); + + entry.state = { ...entry.state, isPinned: !entry.state.isPinned }; + return this.broadcastState(); + } + activateTab(tabId: PreviewTabId): PreviewTabsState { const threadSet = this.getActiveThreadSet(); if (!threadSet.tabs.has(tabId)) return this.buildTabsState(); @@ -619,6 +632,9 @@ export class DesktopPreviewController { tabs.push(entry.state); } + // Sort pinned tabs first, preserving insertion order within each group + tabs.sort((a, b) => Number(b.isPinned) - Number(a.isPinned)); + const visible = this.bounds.visible && tabs.length > 0 && threadSet.activeTabId !== null; return { diff --git a/apps/web/src/components/PreviewPanel.tsx b/apps/web/src/components/PreviewPanel.tsx index e9a3e156..372205a5 100644 --- a/apps/web/src/components/PreviewPanel.tsx +++ b/apps/web/src/components/PreviewPanel.tsx @@ -9,6 +9,8 @@ import { LoaderCircleIcon, MaximizeIcon, MonitorIcon, + PinIcon, + PinOffIcon, PlusIcon, RefreshCwIcon, RotateCcwIcon, @@ -680,7 +682,8 @@ export function PreviewPanel({ projectId, threadId, onClose }: PreviewPanelProps key={tab.tabId} type="button" className={cn( - "group flex max-w-[180px] items-center gap-1.5 rounded-md px-2.5 py-1 text-[11px] transition-colors", + "group flex items-center gap-1.5 rounded-md py-1 text-[11px] transition-colors", + tab.isPinned ? "max-w-[40px] px-2" : "max-w-[180px] px-2.5", tab.tabId === tabsState.activeTabId ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:bg-background/50 hover:text-foreground", @@ -688,23 +691,52 @@ export function PreviewPanel({ projectId, threadId, onClose }: PreviewPanelProps onClick={() => void previewBridge?.activateTab({ tabId: tab.tabId })} title={tab.url ?? tabDisplayTitle(tab)} > - {tab.status === "loading" ? ( + {tab.isPinned ? ( + + ) : tab.status === "loading" ? ( ) : ( )} - {tabDisplayTitle(tab)} - + {!tab.isPinned && {tabDisplayTitle(tab)}} + {tab.isPinned ? ( + + ) : ( + + + + + )} ))}