From 52c77c1ec4c1c2c2c27b917baaec91d050d0a737 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Sun, 3 May 2026 05:16:10 -0700 Subject: [PATCH] feat(preview): in-app browser preview panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a desktop-only browser preview that lives in the right panel slot alongside plan/diff. Lets the user point an Electron at any URL — typed into a chrome-style URL bar, clicked from the empty-state list of detected localhost dev servers, or auto-opened by a project script with `previewUrl` set. Single-tab per thread. Server (Effect/Layers): - PreviewManager: per-(thread, tab) session metadata via SynchronizedRef + PubSub; survives WS reconnect via `list`/replay. - PreviewPortScanner: lsof on macOS/Linux, TCP probe fallback on Windows; reference-counted polling so we only scan when subscribed. - WS RPC + streams (`preview.open|navigate|refresh|close|list|reportStatus`, `subscribePreviewEvents`, `subscribeDiscoveredLocalServers`). Desktop: - PreviewViewManager owns Chromium WebContents per tab, mediates navigation/zoom/devtools/clear-storage. registerWebview gates by webContents.getType() === "webview" and host-window match. - IPC channels for create/close/register/navigate/back/forward/refresh/ zoom/hardReload/openDevTools/clearCookies/clearCache/getBrowserPartition. - Forwards app-level shortcuts (mod+shift+J, mod+K, mod+,, mod+W) from the webview back to the main window. - Persisted browser session partition (cookies, cache). Web: - PreviewPanel/PreviewView/PreviewWebview render the surface; chrome row with back/forward/refresh + URL input + Open-in-browser + 3-dot menu (Hard reload, DevTools, Zoom −/+/reset, Clear cookies/cache). - usePreviewSession subscribes to server events; usePreviewBridge mirrors desktop state into the store and forwards Loading→Success/ LoadFailed back to the server. - previewStateStore: per-thread snapshot + desktopOverlay + recently- seen URLs (Zustand). - rightPanelStore arbitrates plan vs. preview vs. diff; ChatView's toggles strip the `?diff=1` URL hint when switching to preview and vice versa so the panels are mutually exclusive. - Top-nav Globe toggle in ChatHeader (desktop builds only) and a `mod+shift+J` keybinding routed via a typed previewActionBus. - PreviewEmptyState lists detected localhost servers (scanner + configured project URLs + recently-seen) with live "listening" pulse. - PreviewUnreachable: theme-aware port of Chromium's "site can't be reached" page. - Resizable inline panel (RightPanelResizeHandle + useResizableWidth); width persists to localStorage on drag-end. - Terminal link "Open in preview" context-menu integration for loopback URLs. Contracts: - preview.ts schemas (PreviewSessionSnapshot, PreviewNavStatus, PreviewEvent, RPC inputs/results, DiscoveredLocalServer). - ProjectScript schema gains optional `previewUrl` + `autoOpenPreview`. - New keybinding commands: preview.toggle/refresh/focusUrl/zoomIn/Out/ resetZoom; new `when:` contexts `previewFocus` / `previewOpen`. Shared: - @t3tools/shared/preview: normalizePreviewUrl, isPreviewableUrl, isLoopbackHost, newPreviewTabId, LSOF_LOCAL_HOST_TOKENS. Tests: - contracts: schema decode tests for all preview events/snapshots/inputs. - shared: URL normalization coverage. - server: PreviewManager (open/navigate/reportStatus/refresh/close, multi-subscriber isolation, idempotency); PortScanner (lsof parsing including IPv6, TCP probe, reference-counted polling). - web: previewStateStore (per-tab event application, dedupe, reconnect recovery); rightPanelStore arbitration. --- app-preview-browser-plan.md | 420 ++++++++-------- apps/desktop/src/ipc/DesktopIpcHandlers.ts | 4 + apps/desktop/src/ipc/channels.ts | 16 + apps/desktop/src/ipc/methods/preview.ts | 61 +++ apps/desktop/src/preload.ts | 35 +- apps/desktop/src/preview-view-manager.ts | 455 ++++++++++++++++++ apps/desktop/src/window/DesktopWindow.ts | 4 + .../server/src/preview/Layers/Manager.test.ts | 261 ++++++++++ apps/server/src/preview/Layers/Manager.ts | 313 ++++++++++++ .../src/preview/Layers/PortScanner.test.ts | 180 +++++++ apps/server/src/preview/Layers/PortScanner.ts | 243 ++++++++++ apps/server/src/preview/Services/Manager.ts | 94 ++++ .../src/preview/Services/PortScanner.ts | 36 ++ apps/server/src/server.test.ts | 25 + apps/server/src/server.ts | 5 + apps/server/src/ws.ts | 62 +++ apps/web/package.json | 1 + apps/web/src/components/ChatView.browser.tsx | 20 + apps/web/src/components/ChatView.tsx | 154 +++++- .../src/components/ProjectScriptsControl.tsx | 39 ++ .../src/components/ThreadTerminalDrawer.tsx | 26 +- apps/web/src/components/chat/ChatHeader.tsx | 35 +- .../src/components/preview/BrowserMockup.tsx | 24 + .../components/preview/PreviewChromeRow.tsx | 197 ++++++++ .../components/preview/PreviewEmptyState.tsx | 59 +++ .../preview/PreviewLocalServerCard.tsx | 58 +++ .../components/preview/PreviewMoreMenu.tsx | 121 +++++ .../src/components/preview/PreviewPanel.tsx | 35 ++ .../components/preview/PreviewPanelShell.tsx | 77 +++ .../components/preview/PreviewUnreachable.tsx | 90 ++++ .../src/components/preview/PreviewView.tsx | 193 ++++++++ .../src/components/preview/PreviewWebview.tsx | 116 +++++ .../preview/RightPanelResizeHandle.tsx | 34 ++ .../src/components/preview/ZoomIndicator.tsx | 54 +++ .../components/preview/errorCodeMessages.ts | 12 + .../preview/openTerminalLinkInPreview.ts | 70 +++ .../components/preview/previewActionBus.ts | 31 ++ .../src/components/preview/previewBridge.ts | 9 + .../components/preview/previewConstants.ts | 21 + .../preview/useDiscoveredLocalServers.test.ts | 116 +++++ .../preview/useDiscoveredLocalServers.ts | 141 ++++++ .../components/preview/useLoadingProgress.ts | 45 ++ .../components/preview/usePreviewBridge.ts | 137 ++++++ .../components/preview/usePreviewSession.ts | 67 +++ apps/web/src/environmentApi.ts | 10 + apps/web/src/hooks/useResizableWidth.ts | 176 +++++++ apps/web/src/keybindings.ts | 28 ++ apps/web/src/lib/favicon.ts | 20 + apps/web/src/lib/previewFocus.ts | 15 + apps/web/src/main.tsx | 4 + apps/web/src/previewStateStore.test.ts | 227 +++++++++ apps/web/src/previewStateStore.ts | 188 ++++++++ apps/web/src/rightPanelStore.test.ts | 70 +++ apps/web/src/rightPanelStore.ts | 114 +++++ apps/web/src/routes/_chat.tsx | 61 +++ docs/user/keybindings.md | 14 + packages/client-runtime/src/wsRpcClient.ts | 31 ++ packages/contracts/src/index.ts | 1 + packages/contracts/src/ipc.ts | 80 +++ packages/contracts/src/keybindings.ts | 6 + packages/contracts/src/orchestration.ts | 11 + packages/contracts/src/preview.test.ts | 162 +++++++ packages/contracts/src/preview.ts | 181 +++++++ packages/contracts/src/rpc.ts | 78 +++ packages/shared/package.json | 4 + packages/shared/src/keybindings.ts | 7 + packages/shared/src/preview.test.ts | 75 +++ packages/shared/src/preview.ts | 92 ++++ 68 files changed, 5622 insertions(+), 229 deletions(-) create mode 100644 apps/desktop/src/ipc/methods/preview.ts create mode 100644 apps/desktop/src/preview-view-manager.ts create mode 100644 apps/server/src/preview/Layers/Manager.test.ts create mode 100644 apps/server/src/preview/Layers/Manager.ts create mode 100644 apps/server/src/preview/Layers/PortScanner.test.ts create mode 100644 apps/server/src/preview/Layers/PortScanner.ts create mode 100644 apps/server/src/preview/Services/Manager.ts create mode 100644 apps/server/src/preview/Services/PortScanner.ts create mode 100644 apps/web/src/components/preview/BrowserMockup.tsx create mode 100644 apps/web/src/components/preview/PreviewChromeRow.tsx create mode 100644 apps/web/src/components/preview/PreviewEmptyState.tsx create mode 100644 apps/web/src/components/preview/PreviewLocalServerCard.tsx create mode 100644 apps/web/src/components/preview/PreviewMoreMenu.tsx create mode 100644 apps/web/src/components/preview/PreviewPanel.tsx create mode 100644 apps/web/src/components/preview/PreviewPanelShell.tsx create mode 100644 apps/web/src/components/preview/PreviewUnreachable.tsx create mode 100644 apps/web/src/components/preview/PreviewView.tsx create mode 100644 apps/web/src/components/preview/PreviewWebview.tsx create mode 100644 apps/web/src/components/preview/RightPanelResizeHandle.tsx create mode 100644 apps/web/src/components/preview/ZoomIndicator.tsx create mode 100644 apps/web/src/components/preview/errorCodeMessages.ts create mode 100644 apps/web/src/components/preview/openTerminalLinkInPreview.ts create mode 100644 apps/web/src/components/preview/previewActionBus.ts create mode 100644 apps/web/src/components/preview/previewBridge.ts create mode 100644 apps/web/src/components/preview/previewConstants.ts create mode 100644 apps/web/src/components/preview/useDiscoveredLocalServers.test.ts create mode 100644 apps/web/src/components/preview/useDiscoveredLocalServers.ts create mode 100644 apps/web/src/components/preview/useLoadingProgress.ts create mode 100644 apps/web/src/components/preview/usePreviewBridge.ts create mode 100644 apps/web/src/components/preview/usePreviewSession.ts create mode 100644 apps/web/src/hooks/useResizableWidth.ts create mode 100644 apps/web/src/lib/favicon.ts create mode 100644 apps/web/src/lib/previewFocus.ts create mode 100644 apps/web/src/previewStateStore.test.ts create mode 100644 apps/web/src/previewStateStore.ts create mode 100644 apps/web/src/rightPanelStore.test.ts create mode 100644 apps/web/src/rightPanelStore.ts create mode 100644 packages/contracts/src/preview.test.ts create mode 100644 packages/contracts/src/preview.ts create mode 100644 packages/shared/src/preview.test.ts create mode 100644 packages/shared/src/preview.ts diff --git a/app-preview-browser-plan.md b/app-preview-browser-plan.md index 078c1cc93e9dd7c1b3b62d5eab01181dbcb3bf7c..b6675daf8989ea9cff31cbb15b53eb111ba400ba 100644 --- a/app-preview-browser-plan.md +++ b/app-preview-browser-plan.md @@ -143,7 +143,7 @@ export function selectActiveRightPanel( - Remove the local `planSidebarOpen` state (`:688`). - Replace `setPlanSidebarOpen(true)` callsites with `rightPanelStore.open(activeThreadRef, "plan")`. - Replace `closePlanSidebar` with `rightPanelStore.close(activeThreadRef)`. -- Existing `planSidebarDismissedForTurnRef`/`planSidebarOpenOnNextThreadRef` logic stays local — it only governs whether to *call* `open()`/`close()` on turn change. +- Existing `planSidebarDismissedForTurnRef`/`planSidebarOpenOnNextThreadRef` logic stays local — it only governs whether to _call_ `open()`/`close()` on turn change. ### Render arbitration @@ -176,49 +176,49 @@ const useSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY); ### NEW files -| File | Purpose | -|---|---| -| `packages/contracts/src/preview.ts` | Effect/Schema schemas: inputs, snapshot, events, errors | -| `packages/contracts/src/preview.test.ts` | Schema round‑trip tests | -| `apps/server/src/preview/Services/Manager.ts` | `PreviewManager` Service tag + interface | -| `apps/server/src/preview/Layers/Manager.ts` | Implementation: in‑memory map + event subject | -| `apps/server/src/preview/Layers/Manager.test.ts` | Lifecycle, snapshot, event ordering | -| `apps/desktop/src/preview-view-manager.ts` | Plain‑Node Electron port of ami's BrowserManager (subset) | -| `apps/desktop/src/preview-preload.ts` | Webview preload (no‑op v1) | -| `apps/web/src/previewStateStore.ts` | Per‑thread zustand store (mirrors `terminalStateStore.ts`) | -| `apps/web/src/previewStateStore.test.ts` | Reducer tests | -| `apps/web/src/rightPanelStore.ts` | Right‑panel arbiter | -| `apps/web/src/rightPanelStore.test.ts` | Arbiter tests | -| `apps/web/src/components/preview/PreviewPanel.tsx` | Right‑panel entry (wraps `PreviewPanelShell`) | -| `apps/web/src/components/preview/PreviewPanelShell.tsx` | Shell mirroring `DiffPanelShell` (`mode: "inline"\|"sheet"\|"sidebar"`) | -| `apps/web/src/components/preview/PreviewView.tsx` | Chrome bar (URL/back/fwd/refresh) + `` | -| `apps/web/src/components/preview/PreviewWebview.tsx` | Electron `` host; null on web build | -| `apps/web/src/components/preview/PreviewEmptyState.tsx` | Pre‑URL empty state | -| `apps/web/src/components/preview/PreviewUnsupportedToast.ts` | `"Preview is only available in the desktop app"` toast | +| File | Purpose | +| ------------------------------------------------------------ | ----------------------------------------------------------------------- | +| `packages/contracts/src/preview.ts` | Effect/Schema schemas: inputs, snapshot, events, errors | +| `packages/contracts/src/preview.test.ts` | Schema round‑trip tests | +| `apps/server/src/preview/Services/Manager.ts` | `PreviewManager` Service tag + interface | +| `apps/server/src/preview/Layers/Manager.ts` | Implementation: in‑memory map + event subject | +| `apps/server/src/preview/Layers/Manager.test.ts` | Lifecycle, snapshot, event ordering | +| `apps/desktop/src/preview-view-manager.ts` | Plain‑Node Electron port of ami's BrowserManager (subset) | +| `apps/desktop/src/preview-preload.ts` | Webview preload (no‑op v1) | +| `apps/web/src/previewStateStore.ts` | Per‑thread zustand store (mirrors `terminalStateStore.ts`) | +| `apps/web/src/previewStateStore.test.ts` | Reducer tests | +| `apps/web/src/rightPanelStore.ts` | Right‑panel arbiter | +| `apps/web/src/rightPanelStore.test.ts` | Arbiter tests | +| `apps/web/src/components/preview/PreviewPanel.tsx` | Right‑panel entry (wraps `PreviewPanelShell`) | +| `apps/web/src/components/preview/PreviewPanelShell.tsx` | Shell mirroring `DiffPanelShell` (`mode: "inline"\|"sheet"\|"sidebar"`) | +| `apps/web/src/components/preview/PreviewView.tsx` | Chrome bar (URL/back/fwd/refresh) + `` | +| `apps/web/src/components/preview/PreviewWebview.tsx` | Electron `` host; null on web build | +| `apps/web/src/components/preview/PreviewEmptyState.tsx` | Pre‑URL empty state | +| `apps/web/src/components/preview/PreviewUnsupportedToast.ts` | `"Preview is only available in the desktop app"` toast | ### MODIFIED files -| File | Change | -|---|---| -| `packages/contracts/src/keybindings.ts` | Add `preview.toggle`, `preview.refresh`, `preview.focusUrl` to `STATIC_KEYBINDING_COMMANDS`; add `previewFocus`, `previewOpen` to context keys | -| `packages/contracts/src/project.ts` | Add `previewUrl?: string` and `autoOpenPreview?: boolean` to `ProjectScript` schema | -| `packages/contracts/src/server.ts` | Extend `EnvironmentApi` with `preview` namespace | -| `packages/contracts/src/index.ts` | Re‑export new types | -| `apps/server/src/keybindings.ts` | Add defaults: `mod+shift+j` → `preview.toggle`, `mod+shift+r` → `preview.refresh` (when `previewFocus`) | -| `apps/server/src/ws.ts` | Route `preview.open`, `preview.navigate`, `preview.refresh`, `preview.close`, `preview.list`, `preview.onEvent` | -| `apps/server/src/orchestration/runtimeLayer.ts` (or equivalent) | Provide `PreviewManager.Default` | -| `apps/web/src/environmentApi.ts` | Wire `preview` slot in `createEnvironmentApi` | -| `apps/web/src/keybindings.ts` | Add `isPreviewToggleShortcut`, `isPreviewRefreshShortcut` helpers | -| `apps/web/src/routes/_chat.tsx` | Handle `preview.toggle` in global shortcut handler | -| `apps/web/src/components/ChatView.tsx` | Replace local `planSidebarOpen` with `rightPanelStore`; render `PreviewPanel` | -| `apps/web/src/components/ThreadTerminalDrawer.tsx` | At terminal link activation, when `match.kind === "url"` and link looks like a dev URL, show context menu with "Open in preview" / "Open in browser"; pass through to `localApi.preview.openTab(...)` when chosen | -| `apps/web/src/components/ProjectScriptsControl.tsx` | Add `previewUrl` + `autoOpenPreview` form fields in the Add/Edit dialog | -| `apps/web/src/projectScripts.ts` | Carry the new fields through `commandForProjectScript` / serialization | -| `apps/web/src/types.ts` | Add `PreviewSession` mirror types (or re‑export from contracts) | -| `apps/web/src/lib/desktopBridge.d.ts` (or wherever bridge types live) | Add `preview` namespace shape | -| `apps/desktop/src/main.ts` | Register `preview:*` IPC handlers; instantiate `previewViewManager`; wire `mainWindow` injection | -| `apps/desktop/src/preload.ts` | Expose `desktopBridge.preview.*` | -| `KEYBINDINGS.md` | Document new commands and `previewFocus`/`previewOpen` `when` keys | +| File | Change | +| --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `packages/contracts/src/keybindings.ts` | Add `preview.toggle`, `preview.refresh`, `preview.focusUrl` to `STATIC_KEYBINDING_COMMANDS`; add `previewFocus`, `previewOpen` to context keys | +| `packages/contracts/src/project.ts` | Add `previewUrl?: string` and `autoOpenPreview?: boolean` to `ProjectScript` schema | +| `packages/contracts/src/server.ts` | Extend `EnvironmentApi` with `preview` namespace | +| `packages/contracts/src/index.ts` | Re‑export new types | +| `apps/server/src/keybindings.ts` | Add defaults: `mod+shift+j` → `preview.toggle`, `mod+shift+r` → `preview.refresh` (when `previewFocus`) | +| `apps/server/src/ws.ts` | Route `preview.open`, `preview.navigate`, `preview.refresh`, `preview.close`, `preview.list`, `preview.onEvent` | +| `apps/server/src/orchestration/runtimeLayer.ts` (or equivalent) | Provide `PreviewManager.Default` | +| `apps/web/src/environmentApi.ts` | Wire `preview` slot in `createEnvironmentApi` | +| `apps/web/src/keybindings.ts` | Add `isPreviewToggleShortcut`, `isPreviewRefreshShortcut` helpers | +| `apps/web/src/routes/_chat.tsx` | Handle `preview.toggle` in global shortcut handler | +| `apps/web/src/components/ChatView.tsx` | Replace local `planSidebarOpen` with `rightPanelStore`; render `PreviewPanel` | +| `apps/web/src/components/ThreadTerminalDrawer.tsx` | At terminal link activation, when `match.kind === "url"` and link looks like a dev URL, show context menu with "Open in preview" / "Open in browser"; pass through to `localApi.preview.openTab(...)` when chosen | +| `apps/web/src/components/ProjectScriptsControl.tsx` | Add `previewUrl` + `autoOpenPreview` form fields in the Add/Edit dialog | +| `apps/web/src/projectScripts.ts` | Carry the new fields through `commandForProjectScript` / serialization | +| `apps/web/src/types.ts` | Add `PreviewSession` mirror types (or re‑export from contracts) | +| `apps/web/src/lib/desktopBridge.d.ts` (or wherever bridge types live) | Add `preview` namespace shape | +| `apps/desktop/src/main.ts` | Register `preview:*` IPC handlers; instantiate `previewViewManager`; wire `mainWindow` injection | +| `apps/desktop/src/preload.ts` | Expose `desktopBridge.preview.*` | +| `KEYBINDINGS.md` | Document new commands and `previewFocus`/`previewOpen` `when` keys | ### NOT changed @@ -335,10 +335,7 @@ export class PreviewInvalidUrlError extends Schema.TaggedErrorClass Effect.Effect; + readonly open: (input: PreviewOpenInput) => Effect.Effect; readonly navigate: ( input: PreviewNavigateInput, ) => Effect.Effect; - readonly refresh: ( - input: PreviewRefreshInput, - ) => Effect.Effect; + readonly refresh: (input: PreviewRefreshInput) => Effect.Effect; readonly close: (input: PreviewCloseInput) => Effect.Effect; - readonly list: ( - threadId: string, - ) => Effect.Effect>; + readonly list: (threadId: string) => Effect.Effect>; readonly subscribe: ( listener: (event: PreviewEvent) => Effect.Effect, ) => Effect.Effect<() => void>; } -export class PreviewManager extends Context.Service< - PreviewManager, - PreviewManagerShape ->()("t3/preview/Services/Manager/PreviewManager") {} +export class PreviewManager extends Context.Service()( + "t3/preview/Services/Manager/PreviewManager", +) {} ``` ### `apps/server/src/preview/Layers/Manager.ts` @@ -435,15 +425,12 @@ const normalizeUrl = (input: string) => const trimmed = input.trim(); if (!trimmed) throw new Error("empty"); // localhost stays http unless explicitly https - const useHttp = - /^(localhost|127\.0\.0\.1|0\.0\.0\.0)(:|\/|$)/.test(trimmed); + const useHttp = /^(localhost|127\.0\.0\.1|0\.0\.0\.0)(:|\/|$)/.test(trimmed); const parsed = urlParseLax(trimmed, { https: !useHttp }); if (!parsed?.href) throw new Error("unparseable"); return parsed.href; }).pipe( - Effect.catchAll((cause) => - Effect.fail(new PreviewInvalidUrlError({ rawUrl: input, cause })), - ), + Effect.catchAll((cause) => Effect.fail(new PreviewInvalidUrlError({ rawUrl: input, cause }))), ); ``` @@ -513,14 +500,19 @@ export class PreviewViewManager { this.mainWindow = window; } - getPreloadPath(): string { return this.preloadPath; } - getBrowserPartition(): string { return PREVIEW_PARTITION; } + getPreloadPath(): string { + return this.preloadPath; + } + getBrowserPartition(): string { + return PREVIEW_PARTITION; + } getBrowserSession(): Session { if (this.browserSession) return this.browserSession; const sess = session.fromPartition(PREVIEW_PARTITION); // strip electron/t3code from UA so dev preview doesn't trip bot detection - const ua = sess.getUserAgent() + const ua = sess + .getUserAgent() .replace(/Electron\/[\d.]+ /, "") .replace(/\s*t3code\/[\d.]+/, ""); sess.setUserAgent(ua); @@ -593,9 +585,15 @@ export class PreviewViewManager { await wc.loadURL(url); } - goBack(tabId: string): void { this.requireWebContents(tabId).navigationHistory.goBack(); } - goForward(tabId: string): void { this.requireWebContents(tabId).navigationHistory.goForward(); } - refresh(tabId: string): void { this.requireWebContents(tabId).reload(); } + goBack(tabId: string): void { + this.requireWebContents(tabId).navigationHistory.goBack(); + } + goForward(tabId: string): void { + this.requireWebContents(tabId).navigationHistory.goForward(); + } + refresh(tabId: string): void { + this.requireWebContents(tabId).reload(); + } onStateChange(listener: Listener): () => void { this.listeners.add(listener); @@ -656,16 +654,17 @@ export class PreviewViewManager { if (input.type !== "keyDown") return false; // Mirror the t3code keybinding defaults that should always reach the main window. const SHORTCUTS = [ - { key: "j", meta: true, shift: true }, // preview.toggle - { key: "k", meta: true, shift: false }, // commandPalette.toggle - { key: ",", meta: true, shift: false }, // settings - { key: "w", meta: true, shift: false }, // close + { key: "j", meta: true, shift: true }, // preview.toggle + { key: "k", meta: true, shift: false }, // commandPalette.toggle + { key: ",", meta: true, shift: false }, // settings + { key: "w", meta: true, shift: false }, // close // future: terminal.* if user wants them while preview focused ]; - return SHORTCUTS.some((s) => - s.key.toLowerCase() === input.key.toLowerCase() - && s.meta === input.meta - && s.shift === input.shift, + return SHORTCUTS.some( + (s) => + s.key.toLowerCase() === input.key.toLowerCase() && + s.meta === input.meta && + s.shift === input.shift, ); } @@ -704,9 +703,9 @@ export class PreviewViewManager { const trimmed = input.trim(); if (!trimmed) throw new PreviewInvalidUrlError(input); const useHttp = /^(localhost|127\.0\.0\.1|0\.0\.0\.0)(:|\/|$)/.test(trimmed); - const parsed = new URL(trimmed.includes("://") - ? trimmed - : `${useHttp ? "http" : "https"}://${trimmed}`); + const parsed = new URL( + trimmed.includes("://") ? trimmed : `${useHttp ? "http" : "https"}://${trimmed}`, + ); return parsed.href; } } @@ -716,9 +715,15 @@ export class PreviewTabNotFoundError extends Error { super(`Preview tab not found: ${tabId}`); } } -export class PreviewWebContentsNotFoundError extends Error { /* … */ } -export class PreviewWebviewNotInitializedError extends Error { /* … */ } -export class PreviewInvalidUrlError extends Error { /* … */ } +export class PreviewWebContentsNotFoundError extends Error { + /* … */ +} +export class PreviewWebviewNotInitializedError extends Error { + /* … */ +} +export class PreviewInvalidUrlError extends Error { + /* … */ +} export const previewViewManager = new PreviewViewManager(); ``` @@ -734,18 +739,20 @@ previewViewManager.setMainWindow(mainWindow); Register IPC handlers (in `registerIpcHandlers()`): ```ts -ipcMain.handle("preview:createTab", (_e, tabId: string) => - previewViewManager.createTab(tabId)); -ipcMain.handle("preview:closeTab", (_e, tabId: string) => - previewViewManager.closeTab(tabId)); +ipcMain.handle("preview:createTab", (_e, tabId: string) => previewViewManager.createTab(tabId)); +ipcMain.handle("preview:closeTab", (_e, tabId: string) => previewViewManager.closeTab(tabId)); ipcMain.handle("preview:setVisibility", (_e, tabId: string, visible: boolean) => - previewViewManager.setVisibility(tabId, visible)); + previewViewManager.setVisibility(tabId, visible), +); ipcMain.handle("preview:registerWebview", (_e, tabId: string, wcId: number) => - previewViewManager.registerWebview(tabId, wcId)); + previewViewManager.registerWebview(tabId, wcId), +); ipcMain.handle("preview:unregisterWebview", (_e, tabId: string) => - previewViewManager.unregisterWebview(tabId)); + previewViewManager.unregisterWebview(tabId), +); ipcMain.handle("preview:navigate", (_e, tabId: string, url: string) => - previewViewManager.navigate(tabId, url)); + previewViewManager.navigate(tabId, url), +); ipcMain.handle("preview:goBack", (_e, tabId: string) => previewViewManager.goBack(tabId)); ipcMain.handle("preview:goForward", (_e, tabId: string) => previewViewManager.goForward(tabId)); ipcMain.handle("preview:refresh", (_e, tabId: string) => previewViewManager.refresh(tabId)); @@ -776,16 +783,13 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.invoke("preview:setVisibility", tabId, visible), registerWebview: (tabId: string, wcId: number) => ipcRenderer.invoke("preview:registerWebview", tabId, wcId), - unregisterWebview: (tabId: string) => - ipcRenderer.invoke("preview:unregisterWebview", tabId), - navigate: (tabId: string, url: string) => - ipcRenderer.invoke("preview:navigate", tabId, url), + unregisterWebview: (tabId: string) => ipcRenderer.invoke("preview:unregisterWebview", tabId), + navigate: (tabId: string, url: string) => ipcRenderer.invoke("preview:navigate", tabId, url), goBack: (tabId: string) => ipcRenderer.invoke("preview:goBack", tabId), goForward: (tabId: string) => ipcRenderer.invoke("preview:goForward", tabId), refresh: (tabId: string) => ipcRenderer.invoke("preview:refresh", tabId), getPreloadPath: (): Promise => ipcRenderer.invoke("preview:getPreloadPath"), - getBrowserPartition: (): Promise => - ipcRenderer.invoke("preview:getBrowserPartition"), + getBrowserPartition: (): Promise => ipcRenderer.invoke("preview:getBrowserPartition"), onStateChange: (cb: (tabId: string, state: DesktopPreviewTabState) => void) => { const listener = (_e: unknown, tabId: string, state: DesktopPreviewTabState) => cb(tabId, state); @@ -1096,14 +1100,14 @@ Adding `preview.toggle`, `preview.refresh`, `preview.focusUrl` to `STATIC_KEYBIN Following t3code's vitest patterns (`bun run test`, never `bun test`): -| Test file | What it covers | -|---|---| -| `packages/contracts/src/preview.test.ts` | Schema encode/decode round-trips for inputs, snapshot, events; URL trimming; error tagged unions | -| `apps/server/src/preview/Layers/Manager.test.ts` | `open` creates session and emits `opened`; `navigate` updates and emits `navigated`; `close` removes and emits `closed`; subscribers receive monotonic events; `list` returns sorted snapshots | -| `apps/web/src/previewStateStore.test.ts` | `applyServerEvent` reducer correctness; ring buffer cap; `closed` removes entry; `desktopOverlay` is independent of snapshot fields | -| `apps/web/src/rightPanelStore.test.ts` | `open` / `close` / `toggle` semantics; per-thread isolation; `?diff=1` sync compatibility | -| `apps/web/src/components/preview/PreviewView.test.ts` (logic-only via `PreviewView.logic.ts` extraction) | URL bar input → navigation; navigation button enabled-state derivation; visibility toggle on panel hide | -| Existing `ThreadTerminalDrawer.test.ts` | Add a case: link activation with `kind: "url"` and `previewable: true` shows the context menu (mock `localApi.contextMenu.show`) | +| Test file | What it covers | +| -------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `packages/contracts/src/preview.test.ts` | Schema encode/decode round-trips for inputs, snapshot, events; URL trimming; error tagged unions | +| `apps/server/src/preview/Layers/Manager.test.ts` | `open` creates session and emits `opened`; `navigate` updates and emits `navigated`; `close` removes and emits `closed`; subscribers receive monotonic events; `list` returns sorted snapshots | +| `apps/web/src/previewStateStore.test.ts` | `applyServerEvent` reducer correctness; ring buffer cap; `closed` removes entry; `desktopOverlay` is independent of snapshot fields | +| `apps/web/src/rightPanelStore.test.ts` | `open` / `close` / `toggle` semantics; per-thread isolation; `?diff=1` sync compatibility | +| `apps/web/src/components/preview/PreviewView.test.ts` (logic-only via `PreviewView.logic.ts` extraction) | URL bar input → navigation; navigation button enabled-state derivation; visibility toggle on panel hide | +| Existing `ThreadTerminalDrawer.test.ts` | Add a case: link activation with `kind: "url"` and `previewable: true` shows the context menu (mock `localApi.contextMenu.show`) | Manual smoke checklist (drop in `apps/desktop/test/smoke/`): @@ -1120,15 +1124,15 @@ Manual smoke checklist (drop in `apps/desktop/test/smoke/`): ## 12. Risks and resolutions -| Risk | Resolution | -|---|---| -| Hidden `` GPU cost when keeping multiple threads' previews mounted | v1 only mounts the active thread's preview. v2 can adopt the `PersistentThreadTerminalDrawer` pattern + `setVisibility` | -| `` keyboard capture eats `mod+shift+j` etc. | `before-input-event` forwarder in `PreviewViewManager` (mirror of ami's pattern, smaller shortcut list) | -| URL with `X-Frame-Options: DENY` works fine in `` (good — that's why we picked `` over iframe) | n/a | -| Server restart while desktop is alive: `` is still loaded but server has no record | On WS reconnect, web side sends a `preview.list(threadId)` and if empty, sends a `preview.open(...)` to re‑register the current URL. Add a small `useReconciliation` effect in `PreviewView.tsx` | -| Renderer process renders something into `` but never registered → orphaned tab in `PreviewViewManager` | `closeTab` is idempotent; on `` unmount, `unregisterWebview` is called; if that didn't fire (crash), the next `createTab` for the same `tabId` reuses the record | -| `autoOpenPreview` racing with terminal output (URL might not be in stdout yet by the time the script "starts") | Two‑phase: if `script.previewUrl` is set, open eagerly with that URL. If not set but `autoOpenPreview === true`, watch terminal output via existing `terminal-links` extraction and open the first `previewable` URL within a 60s window | -| Multiple windows of the desktop both rendering the same `` for the same thread | `` is per‑renderer; each window creates its own. The shared `persist:t3code-preview` partition keeps cookies in sync. Server records the last navigation URL but doesn't enforce single‑renderer | +| Risk | Resolution | +| ---------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Hidden `` GPU cost when keeping multiple threads' previews mounted | v1 only mounts the active thread's preview. v2 can adopt the `PersistentThreadTerminalDrawer` pattern + `setVisibility` | +| `` keyboard capture eats `mod+shift+j` etc. | `before-input-event` forwarder in `PreviewViewManager` (mirror of ami's pattern, smaller shortcut list) | +| URL with `X-Frame-Options: DENY` works fine in `` (good — that's why we picked `` over iframe) | n/a | +| Server restart while desktop is alive: `` is still loaded but server has no record | On WS reconnect, web side sends a `preview.list(threadId)` and if empty, sends a `preview.open(...)` to re‑register the current URL. Add a small `useReconciliation` effect in `PreviewView.tsx` | +| Renderer process renders something into `` but never registered → orphaned tab in `PreviewViewManager` | `closeTab` is idempotent; on `` unmount, `unregisterWebview` is called; if that didn't fire (crash), the next `createTab` for the same `tabId` reuses the record | +| `autoOpenPreview` racing with terminal output (URL might not be in stdout yet by the time the script "starts") | Two‑phase: if `script.previewUrl` is set, open eagerly with that URL. If not set but `autoOpenPreview === true`, watch terminal output via existing `terminal-links` extraction and open the first `previewable` URL within a 60s window | +| Multiple windows of the desktop both rendering the same `` for the same thread | `` is per‑renderer; each window creates its own. The shared `persist:t3code-preview` partition keeps cookies in sync. Server records the last navigation URL but doesn't enforce single‑renderer | --- @@ -1155,38 +1159,43 @@ Any of these can land in follow-up PRs against the same `PreviewViewManager` + ` Every visible element below is built on existing components. No new design primitives are introduced. References below are to the `apps/web/src/components/ui/` directory. -| Need | Primitive | File ref | Notes | -|---|---|---|---| -| Theme colors / radii | CSS vars `--background`, `--card`, `--muted`, `--muted-foreground`, `--border`, `--input`, `--ring`, `--success`, etc. | `apps/web/src/index.css:86` | Light + `@variant dark` blocks. Always reference vars via tailwind utilities (`bg-card`, `text-muted-foreground`) — never hard‑code hex | -| `cn` class merger | `cn(...inputs)` from `~/lib/utils` | `apps/web/src/lib/utils.ts:8` | Wraps `cx` + `tailwind-merge` | -| Buttons (chrome bar back/fwd/refresh) | `Button` `variant="ghost"` `size="icon-xs"` | `apps/web/src/components/ui/button.tsx` | `icon-xs` is `size-7 rounded-md sm:size-6` — exactly the density in the screenshot | -| Buttons (URL field submit) | `Button` `variant="outline"` `size="sm"` | same | Matches the "ProjectScript primary action" density already in `BranchToolbar` | -| Button group (back / fwd / refresh as a unit) | `Group` + implicit segmenting (no `GroupSeparator`) | `apps/web/src/components/ui/group.tsx` | Group automatically removes outer borders between adjacent `[data-slot]` children | -| URL input (chrome bar editable) | `InputGroup` + `InputGroupInput` + `InputGroupAddon align="inline-start"` (globe icon) | `apps/web/src/components/ui/input-group.tsx` | Click‑anywhere‑to‑focus already wired in `InputGroupAddon`'s `onMouseDown` | -| URL input (chrome bar disabled / read‑only) | Same `InputGroup` with `disabled` on the input | same | Yields the muted look in the screenshot via `has-[input:disabled]:opacity-64` | -| Tab strip cells | Plain ` + ); +} + +function describeServer(server: PreviewableServer): string { + if (server.processName) return server.processName; + if (server.listening) return "Listening"; + if (server.source === "configured") return "Configured"; + return "Recently seen"; +} + +function PulsingDot() { + return ( + + + + + ); +} + +function DimDot() { + return ( + + ); +} diff --git a/apps/web/src/components/preview/PreviewMoreMenu.tsx b/apps/web/src/components/preview/PreviewMoreMenu.tsx new file mode 100644 index 0000000000000000000000000000000000000000..748a4a27019e93b7f8474e158d9a0d82d31f07b5 --- /dev/null +++ b/apps/web/src/components/preview/PreviewMoreMenu.tsx @@ -0,0 +1,121 @@ +"use client"; + +import { Minus, MoreVertical, Plus as PlusIcon, RotateCcw } from "lucide-react"; + +import { Button } from "~/components/ui/button"; +import { Menu, MenuItem, MenuPopup, MenuSeparator, MenuTrigger } from "~/components/ui/menu"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; + +import { previewBridge } from "./previewBridge"; + +interface Props { + /** Active preview tab id. Tab-targeting actions are disabled without it. */ + tabId: string | null; + /** + * True only after the desktop bridge has registered a `webContentsId` for + * the active tab. Tab-targeting actions throw on the desktop side until + * then; we disable those items so the menu doesn't fire silent no-ops. + */ + hasWebContents: boolean; + /** Current zoom factor as a number (1.0 = 100%). */ + zoomFactor: number; +} + +/** + * Three-dot menu in the chrome row. Wires Hard reload, DevTools, zoom + * controls, and storage-clearing actions. Only mounted by `PreviewView` + * when the desktop bridge is present, so we can call it unconditionally. + */ +export function PreviewMoreMenu({ tabId, hasWebContents, zoomFactor }: Props) { + if (!previewBridge) return null; + const bridge = previewBridge; + const tabDisabled = !tabId || !hasWebContents; + const callTab = (op: (tabId: string) => Promise) => () => { + if (!tabId) return; + void op(tabId).catch(() => undefined); + }; + + const zoomLabel = `${Math.round(zoomFactor * 100)}%`; + + return ( + + + + } + /> + } + > + + + More + + + + Hard reload + + + Open DevTools + + + {/* + Zoom row: label + inline control cluster. `closeOnClick=false` + keeps the menu open while the user clicks the +/− buttons. + */} + event.preventDefault()} + className="justify-between" + disabled={tabDisabled} + > + Zoom + + + + {zoomLabel} + + + + + + + void bridge.clearCookies().catch(() => undefined)}> + Clear cookies + + void bridge.clearCache().catch(() => undefined)}> + Clear cache + + + + ); +} diff --git a/apps/web/src/components/preview/PreviewPanel.tsx b/apps/web/src/components/preview/PreviewPanel.tsx new file mode 100644 index 0000000000000000000000000000000000000000..edd19a31c47c3cf3f492a01e504a9ef351796ac4 --- /dev/null +++ b/apps/web/src/components/preview/PreviewPanel.tsx @@ -0,0 +1,35 @@ +"use client"; + +import type { ScopedThreadRef } from "@t3tools/contracts"; + +import { isPreviewSupportedInRuntime } from "~/previewStateStore"; + +import { PreviewPanelShell, type PreviewPanelMode } from "./PreviewPanelShell"; +import { PreviewView } from "./PreviewView"; + +interface Props { + mode: PreviewPanelMode; + threadRef: ScopedThreadRef; + configuredUrls?: ReadonlyArray | undefined; + visible: boolean; +} + +export function PreviewPanel({ mode, threadRef, configuredUrls, visible }: Props) { + if (!isPreviewSupportedInRuntime()) { + return ( + +
+

+ Preview is only available in the T3 Code desktop app. +

+
+
+ ); + } + + return ( + + + + ); +} diff --git a/apps/web/src/components/preview/PreviewPanelShell.tsx b/apps/web/src/components/preview/PreviewPanelShell.tsx new file mode 100644 index 0000000000000000000000000000000000000000..71e8dd561078916f00ffaf90b0f956fbf0e1f4dc --- /dev/null +++ b/apps/web/src/components/preview/PreviewPanelShell.tsx @@ -0,0 +1,77 @@ +import { type ReactNode, useEffect, useState } from "react"; + +import { isElectron } from "~/env"; +import { useResizableWidth } from "~/hooks/useResizableWidth"; +import { cn } from "~/lib/utils"; + +import { RightPanelResizeHandle } from "./RightPanelResizeHandle"; + +export type PreviewPanelMode = "inline" | "sheet" | "sidebar"; + +const PREVIEW_PANEL_WIDTH_STORAGE_KEY = "t3code:preview-panel-width"; +const PREVIEW_PANEL_MIN_WIDTH = 360; +/** Hard ceiling so a wide monitor can't yield a panel that swallows the chat. */ +const PREVIEW_PANEL_MAX_WIDTH_PX = 1400; +/** Fraction of the viewport allowed; the panel is min(this · vw, MAX_PX). */ +const PREVIEW_PANEL_MAX_WIDTH_FRACTION = 0.7; +const PREVIEW_PANEL_DEFAULT_WIDTH = 540; + +/** + * Shell for the preview panel. In inline mode the panel is user-resizable + * via a drag handle on the left edge; width persists per browser. In + * sheet/sidebar modes the parent owns the size. + */ +export function PreviewPanelShell(props: { mode: PreviewPanelMode; children: ReactNode }) { + const useDragRegion = isElectron && props.mode !== "sheet"; + const isInline = props.mode === "inline"; + const maxWidth = useViewportClampedMaxWidth(); + const { width, handlers } = useResizableWidth({ + storageKey: PREVIEW_PANEL_WIDTH_STORAGE_KEY, + defaultWidth: PREVIEW_PANEL_DEFAULT_WIDTH, + minWidth: PREVIEW_PANEL_MIN_WIDTH, + maxWidth, + edge: "left", + }); + + return ( +
+ {isInline ? : null} + {useDragRegion ?
: null} + {props.children} +
+ ); +} + +/** + * Track viewport width to derive a sensible upper bound for the panel. + * Resize-aware so dragging the OS window narrower re-clamps the stored + * width on the next render (the hook's clamp picks this up automatically). + */ +function useViewportClampedMaxWidth(): number { + const [vw, setVw] = useState(() => (typeof window === "undefined" ? 1280 : window.innerWidth)); + useEffect(() => { + if (typeof window === "undefined") return; + let frame = 0; + const onResize = () => { + // Coalesce rapid resize events into one rAF tick. + if (frame !== 0) return; + frame = window.requestAnimationFrame(() => { + frame = 0; + setVw(window.innerWidth); + }); + }; + window.addEventListener("resize", onResize); + return () => { + window.removeEventListener("resize", onResize); + if (frame !== 0) window.cancelAnimationFrame(frame); + }; + }, []); + return Math.min(PREVIEW_PANEL_MAX_WIDTH_PX, Math.floor(vw * PREVIEW_PANEL_MAX_WIDTH_FRACTION)); +} diff --git a/apps/web/src/components/preview/PreviewUnreachable.tsx b/apps/web/src/components/preview/PreviewUnreachable.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c6ada2cb4914748769da1c8baa91630e614d9451 --- /dev/null +++ b/apps/web/src/components/preview/PreviewUnreachable.tsx @@ -0,0 +1,90 @@ +import { useState } from "react"; + +import { Button } from "~/components/ui/button"; + +import { describePreviewError } from "./errorCodeMessages"; + +interface Props { + url: string; + /** Chromium net error code, e.g. -105. */ + code: number; + /** Stringified Chromium error, e.g. "ERR_NAME_NOT_RESOLVED". */ + description: string; + onReload: () => void; +} + +/** Theme-aware tailwind port of Chromium's "This site can't be reached" page. */ +export function PreviewUnreachable({ url, code, description, onReload }: Props) { + const [showDetails, setShowDetails] = useState(false); + const host = safeHost(url) ?? url; + const friendly = describePreviewError(code, description); + const errorLabel = description.length > 0 ? description : `ERR_${Math.abs(code) || "FAILED"}`; + + return ( +
+
+ +

+ This site can’t be reached +

+

+ {host}: {friendly}. +

+ + {showDetails ? ( +
+

Try:

+
    +
  • Checking your connection
  • +
  • Confirming the dev server is running
  • +
  • Checking the proxy and the firewall
  • +
+
+ ) : null} + +
+ {errorLabel} +
+ +
+ +
+ +
+
+
+ ); +} + +function ErrorIcon({ className }: { className?: string }) { + return ( + + + + + + ); +} + +function safeHost(url: string): string | null { + try { + return new URL(url).host; + } catch { + return null; + } +} diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx new file mode 100644 index 0000000000000000000000000000000000000000..352ac97406116b0c164abf0ec97d93550cf29bc9 --- /dev/null +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -0,0 +1,193 @@ +"use client"; + +import { scopedThreadKey } from "@t3tools/client-runtime"; +import { type ScopedThreadRef } from "@t3tools/contracts"; +import { useCallback, useEffect, useState } from "react"; + +import { ensureEnvironmentApi } from "~/environmentApi"; +import { ensureLocalApi } from "~/localApi"; +import { selectThreadPreviewState, usePreviewStateStore } from "~/previewStateStore"; + +import { previewBridge } from "./previewBridge"; +import { subscribePreviewAction } from "./previewActionBus"; +import { PreviewChromeRow } from "./PreviewChromeRow"; +import { PreviewEmptyState } from "./PreviewEmptyState"; +import { PreviewMoreMenu } from "./PreviewMoreMenu"; +import { PreviewUnreachable } from "./PreviewUnreachable"; +import { PreviewWebview } from "./PreviewWebview"; +import { useLoadingProgress } from "./useLoadingProgress"; +import { usePreviewSession } from "./usePreviewSession"; +import { ZoomIndicator } from "./ZoomIndicator"; + +interface Props { + threadRef: ScopedThreadRef; + configuredUrls?: ReadonlyArray | undefined; + visible: boolean; +} + +const localApi = typeof window === "undefined" ? null : ensureLocalApi(); + +/** + * Single-tab preview surface: chrome row on top, one webview below, empty + * state when no session exists for the thread. + */ +export function PreviewView({ threadRef, configuredUrls, visible }: Props) { + const [focusUrlNonce, setFocusUrlNonce] = useState(0); + const previewState = usePreviewStateStore((state) => + selectThreadPreviewState(state.byThreadKey, threadRef), + ); + const rememberUrl = usePreviewStateStore((state) => state.rememberUrl); + + usePreviewSession(threadRef); + + const { snapshot, desktopOverlay } = previewState; + const tabId = snapshot?.tabId ?? null; + const navStatus = snapshot?.navStatus ?? { _tag: "Idle" as const }; + const url = navStatus._tag === "Idle" ? "" : navStatus.url; + const loading = desktopOverlay?.loading ?? navStatus._tag === "Loading"; + const canGoBack = desktopOverlay?.canGoBack ?? snapshot?.canGoBack ?? false; + const canGoForward = desktopOverlay?.canGoForward ?? snapshot?.canGoForward ?? false; + const refreshDisabled = navStatus._tag === "Idle"; + const isUnreachable = navStatus._tag === "LoadFailed"; + const loadProgress = useLoadingProgress(loading); + + const handleSubmitUrl = useCallback( + async (next: string) => { + const api = ensureEnvironmentApi(threadRef.environmentId); + try { + if (tabId && previewBridge) { + // Drive the webview imperatively; `usePreviewBridge` mirrors the + // resolved URL back to the server so other clients stay in sync. + await previewBridge.navigate(tabId, next); + rememberUrl(threadRef, next); + } else { + const resolved = await api.preview.open({ threadId: threadRef.threadId, url: next }); + const resolvedUrl = resolved.navStatus._tag === "Idle" ? next : resolved.navStatus.url; + rememberUrl(threadRef, resolvedUrl); + } + } catch { + // Server-side `failed` event renders the unreachable view. + } + }, + [rememberUrl, tabId, threadRef], + ); + + const handleRefresh = useCallback(() => { + if (previewBridge && tabId) void previewBridge.refresh(tabId); + }, [tabId]); + + const handleZoomIn = useCallback(() => { + if (previewBridge && tabId) void previewBridge.zoomIn(tabId); + }, [tabId]); + + const handleZoomOut = useCallback(() => { + if (previewBridge && tabId) void previewBridge.zoomOut(tabId); + }, [tabId]); + + const handleResetZoom = useCallback(() => { + if (previewBridge && tabId) void previewBridge.resetZoom(tabId); + }, [tabId]); + + const handleBack = useCallback(() => { + if (previewBridge && tabId) void previewBridge.goBack(tabId); + }, [tabId]); + + const handleForward = useCallback(() => { + if (previewBridge && tabId) void previewBridge.goForward(tabId); + }, [tabId]); + + const handleOpenInBrowser = useCallback(() => { + if (!localApi || !url) return; + void localApi.shell.openExternal(url).catch(() => undefined); + }, [url]); + + // Subscribe only while visible; `toggle-panel` is owned by ChatView's + // URL-aware handler regardless of whether the panel is currently mounted. + useEffect(() => { + if (!visible) return; + return subscribePreviewAction((action) => { + switch (action) { + case "refresh": + handleRefresh(); + return; + case "focus-url": + setFocusUrlNonce((value) => value + 1); + return; + case "zoom-in": + handleZoomIn(); + return; + case "zoom-out": + handleZoomOut(); + return; + case "reset-zoom": + handleResetZoom(); + return; + case "toggle-panel": + return; + } + }); + }, [handleRefresh, handleResetZoom, handleZoomIn, handleZoomOut, visible]); + + return ( +
+ void handleSubmitUrl(next)} + onOpenInBrowser={tabId ? handleOpenInBrowser : undefined} + trailingActions={ + previewBridge ? ( + + ) : null + } + /> + +
+ {tabId && snapshot ? ( + + ) : ( + void handleSubmitUrl(next)} + /> + )} + {snapshot && desktopOverlay ? ( + + ) : null} + {isUnreachable && navStatus._tag === "LoadFailed" ? ( +
+ +
+ ) : null} +
+
+ ); +} diff --git a/apps/web/src/components/preview/PreviewWebview.tsx b/apps/web/src/components/preview/PreviewWebview.tsx new file mode 100644 index 0000000000000000000000000000000000000000..3f78c588f904a94ff4d06cc67fa009a4ccdb8fb1 --- /dev/null +++ b/apps/web/src/components/preview/PreviewWebview.tsx @@ -0,0 +1,116 @@ +"use client"; + +import type { ScopedThreadRef } from "@t3tools/contracts"; +import { useEffect, useRef, useState } from "react"; + +import { isElectron } from "~/env"; + +import { previewBridge } from "./previewBridge"; +import { usePreviewBridge } from "./usePreviewBridge"; + +interface Props { + threadRef: ScopedThreadRef; + tabId: string; + /** + * URL to load on first mount. Subsequent prop changes are ignored — once + * the webview is live, navigation flows exclusively through the bridge + * (`previewBridge.navigate`, `goBack`, `goForward`, `refresh`). Otherwise, + * a snapshot URL update from the server would re-set `` and + * race with the `loadURL` we already issued via the bridge. + */ + initialUrl: string | null; + className?: string; +} + +interface ElectronWebview extends HTMLElement { + src: string; + partition: string; + allowpopups: boolean; + reload: () => void; + getWebContentsId: () => number; +} + +declare global { + interface HTMLElementTagNameMap { + webview: ElectronWebview; + } +} + +/** + * Hosts the Electron `` for a single preview tab. Returns null on + * web builds. The two-step handshake (createTab → wait for `dom-ready` → + * registerWebview) is necessary because `getWebContentsId()` only returns a + * valid id once the embedded contents have parsed; calling it synchronously + * after mount throws. + */ +export function PreviewWebview({ threadRef, tabId, initialUrl, className }: Props) { + const [config, setConfig] = useState<{ partition: string } | null>(null); + const webviewRef = useRef(null); + // Capture once at mount; never re-derived from `initialUrl` props later. + const initialSrcRef = useRef(initialUrl ?? "about:blank"); + const bridge = previewBridge; + + // Per-tab desktop lifecycle: createTab on mount, closeTab on unmount, + // mirror state into the store, and reflect navigation back to the server. + usePreviewBridge({ threadRef, tabId }); + + useEffect(() => { + if (!bridge) return; + let cancelled = false; + void bridge + .getBrowserPartition() + .then((partition) => { + if (cancelled) return; + setConfig({ partition }); + }) + .catch(() => undefined); + return () => { + cancelled = true; + }; + }, [bridge]); + + useEffect(() => { + if (!bridge || !config) return; + const webview = webviewRef.current; + if (!webview) return; + + const onDomReady = () => { + try { + const id = webview.getWebContentsId(); + if (Number.isFinite(id)) { + void bridge.registerWebview(tabId, id); + } + } catch { + // The next dom-ready (e.g. cross-document navigation) will retry. + } + }; + webview.addEventListener("dom-ready", onDomReady as EventListener); + // Defensive: dom-ready may have fired between React commit and this + // effect running. Failures fall through to the listener above. + try { + const existing = webview.getWebContentsId(); + if (Number.isFinite(existing)) { + void bridge.registerWebview(tabId, existing); + } + } catch { + // covered by dom-ready listener + } + + return () => { + webview.removeEventListener("dom-ready", onDomReady as EventListener); + }; + }, [bridge, config, tabId]); + + if (!isElectron || !bridge || !config) return null; + + return ( + + ); +} diff --git a/apps/web/src/components/preview/RightPanelResizeHandle.tsx b/apps/web/src/components/preview/RightPanelResizeHandle.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5091c8b5915ad476d21db39dfd87c033fcdb5993 --- /dev/null +++ b/apps/web/src/components/preview/RightPanelResizeHandle.tsx @@ -0,0 +1,34 @@ +import type { ResizableWidthHandlers } from "~/hooks/useResizableWidth"; +import { cn } from "~/lib/utils"; + +interface Props { + handlers: ResizableWidthHandlers; + className?: string; +} + +/** + * Hit target for resizing a right-anchored panel via its left edge. + * + * - Sits on top of the panel's border with a 4px overlap on each side so the + * user can grab a few pixels off the edge without aiming. + * - Visual indicator is a 1px line that lights up on hover/active to mirror + * VS Code / Cursor. + */ +export function RightPanelResizeHandle({ handlers, className }: Props) { + return ( +
+ +
+ ); +} diff --git a/apps/web/src/components/preview/ZoomIndicator.tsx b/apps/web/src/components/preview/ZoomIndicator.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0ac536668acf6b3931f505fb6950708ca0ee8e4c --- /dev/null +++ b/apps/web/src/components/preview/ZoomIndicator.tsx @@ -0,0 +1,54 @@ +import { useEffect, useRef, useState } from "react"; + +import { cn } from "~/lib/utils"; + +const HIDE_AFTER_MS = 1500; +const ZOOM_EPSILON = 0.001; + +interface Props { + /** Current zoom factor (1.0 = 100%); changes drive the transient indicator. */ + zoomFactor: number; +} + +/** + * Floating "X%" pill that surfaces in the top-right of the webview area + * whenever the zoom factor changes, then fades out after a short pause. + * + * Suppressed for the first render's value so we don't flash 100% on mount. + */ +export function ZoomIndicator({ zoomFactor }: Props) { + const [visible, setVisible] = useState(false); + const lastFactorRef = useRef(zoomFactor); + const timerRef = useRef(null); + + useEffect(() => { + if (Math.abs(lastFactorRef.current - zoomFactor) < ZOOM_EPSILON) return; + lastFactorRef.current = zoomFactor; + setVisible(true); + if (timerRef.current !== null) window.clearTimeout(timerRef.current); + timerRef.current = window.setTimeout(() => { + setVisible(false); + timerRef.current = null; + }, HIDE_AFTER_MS); + return () => { + if (timerRef.current !== null) { + window.clearTimeout(timerRef.current); + timerRef.current = null; + } + }; + }, [zoomFactor]); + + const percent = `${Math.round(zoomFactor * 100)}%`; + + return ( +
+ {percent} +
+ ); +} diff --git a/apps/web/src/components/preview/errorCodeMessages.ts b/apps/web/src/components/preview/errorCodeMessages.ts new file mode 100644 index 0000000000000000000000000000000000000000..78ee928800b5d60785432d1320f125b35814461d --- /dev/null +++ b/apps/web/src/components/preview/errorCodeMessages.ts @@ -0,0 +1,12 @@ +import { PREVIEW_ERROR_CODE_MESSAGES } from "./previewConstants"; + +/** + * Resolve a friendly description for a Chromium / network error. Falls back + * to the description string passed in when the code isn't in our table. + */ +export function describePreviewError(code: number, description: string): string { + const friendly = PREVIEW_ERROR_CODE_MESSAGES[description]; + if (friendly) return friendly; + if (description.length > 0) return description; + return `Network error (${code})`; +} diff --git a/apps/web/src/components/preview/openTerminalLinkInPreview.ts b/apps/web/src/components/preview/openTerminalLinkInPreview.ts new file mode 100644 index 0000000000000000000000000000000000000000..0cafb4394836b968d1058ff9c3b312764977b77e --- /dev/null +++ b/apps/web/src/components/preview/openTerminalLinkInPreview.ts @@ -0,0 +1,70 @@ +import type { EnvironmentApi, LocalApi, ScopedThreadRef } from "@t3tools/contracts"; +import { isPreviewableUrl } from "@t3tools/shared/preview"; + +import { isPreviewSupportedInRuntime } from "~/previewStateStore"; +import { useRightPanelStore } from "~/rightPanelStore"; + +interface OpenTerminalLinkInPreviewInput { + readonly url: string; + readonly position: { x: number; y: number }; + readonly threadRef: ScopedThreadRef; + readonly api: EnvironmentApi; + readonly localApi: LocalApi; + /** Called whenever the URL ultimately needs to open in the system browser. */ + readonly fallbackToBrowser: () => void; +} + +/** + * Handles a terminal-link click that resolves to a URL. + * + * - For non-loopback / unsupported runtimes, defers to the system browser. + * - For previewable URLs in the desktop build, presents a context menu to + * choose between the in-app preview and the system browser. + * + * Failures fall back to the system browser so a stuck context-menu doesn't + * leave the user without a way to open the link. + */ +export async function openTerminalLinkInPreview( + input: OpenTerminalLinkInPreviewInput, +): Promise { + const supportsPreview = + isPreviewableUrl(input.url) && + isPreviewSupportedInRuntime() && + input.threadRef.threadId.length > 0; + + if (!supportsPreview) { + input.fallbackToBrowser(); + return; + } + + let choice: "open-in-preview" | "open-in-browser" | null; + try { + choice = await input.localApi.contextMenu.show( + [ + { id: "open-in-preview", label: "Open in preview" }, + { id: "open-in-browser", label: "Open in browser" }, + ], + input.position, + ); + } catch { + input.fallbackToBrowser(); + return; + } + + if (choice === "open-in-preview") { + try { + await input.api.preview.open({ + threadId: input.threadRef.threadId, + url: input.url, + }); + useRightPanelStore.getState().open(input.threadRef, "preview"); + } catch { + input.fallbackToBrowser(); + } + return; + } + + if (choice === "open-in-browser") { + input.fallbackToBrowser(); + } +} diff --git a/apps/web/src/components/preview/previewActionBus.ts b/apps/web/src/components/preview/previewActionBus.ts new file mode 100644 index 0000000000000000000000000000000000000000..23efdf487c6f58ae253d2f3a37d1c55d4ab14a5b --- /dev/null +++ b/apps/web/src/components/preview/previewActionBus.ts @@ -0,0 +1,31 @@ +"use client"; + +/** + * Typed window-event bus for preview-panel actions. Lets the global + * keybinding handler in `routes/_chat.tsx` reach `ChatView`'s URL-aware + * arbitration without prop drilling or shared refs. + */ +export type PreviewAction = + | "toggle-panel" + | "refresh" + | "focus-url" + | "zoom-in" + | "zoom-out" + | "reset-zoom"; + +const EVENT_NAME = "t3code:preview-action"; + +export function dispatchPreviewAction(action: PreviewAction): void { + if (typeof window === "undefined") return; + window.dispatchEvent(new CustomEvent(EVENT_NAME, { detail: action })); +} + +export function subscribePreviewAction(listener: (action: PreviewAction) => void): () => void { + if (typeof window === "undefined") return () => {}; + const handler = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (typeof detail === "string") listener(detail); + }; + window.addEventListener(EVENT_NAME, handler); + return () => window.removeEventListener(EVENT_NAME, handler); +} diff --git a/apps/web/src/components/preview/previewBridge.ts b/apps/web/src/components/preview/previewBridge.ts new file mode 100644 index 0000000000000000000000000000000000000000..90e9bf7241b6ba71a1c73bd452547d370a18dd67 --- /dev/null +++ b/apps/web/src/components/preview/previewBridge.ts @@ -0,0 +1,9 @@ +/** + * Module-level handle to the desktop preview bridge. + * + * Resolved once at import time so React hooks don't pay for repeated + * `window.desktopBridge?.preview` lookups on every render. `null` on the web + * build where there's no Electron host. + */ +export const previewBridge = + typeof window === "undefined" ? null : (window.desktopBridge?.preview ?? null); diff --git a/apps/web/src/components/preview/previewConstants.ts b/apps/web/src/components/preview/previewConstants.ts new file mode 100644 index 0000000000000000000000000000000000000000..d174577a58413649143cf75b4bff649e13e88eb0 --- /dev/null +++ b/apps/web/src/components/preview/previewConstants.ts @@ -0,0 +1,21 @@ +/** Cap for the per-thread "recently seen" URL list shown in the empty state. */ +export const PREVIEW_RECENT_URL_LIMIT = 10; + +/** + * Common Chromium error codes mapped to a short human label. Used by the + * unreachable view to drop the raw `ERR_*` code in favour of friendlier copy. + */ +export const PREVIEW_ERROR_CODE_MESSAGES: Readonly> = Object.freeze({ + ERR_NAME_NOT_RESOLVED: "DNS address could not be found", + ERR_NAME_RESOLUTION_FAILED: "DNS address could not be found", + ERR_CONNECTION_REFUSED: "Connection refused", + ERR_CONNECTION_RESET: "Connection was reset", + ERR_CONNECTION_CLOSED: "Connection was closed", + ERR_CONNECTION_TIMED_OUT: "Connection timed out", + ERR_INTERNET_DISCONNECTED: "No internet connection", + ERR_TIMED_OUT: "Connection timed out", + ERR_CERT_AUTHORITY_INVALID: "Certificate authority is not trusted", + ERR_CERT_COMMON_NAME_INVALID: "Certificate hostname mismatch", + ERR_CERT_DATE_INVALID: "Certificate is expired or not yet valid", + ERR_TOO_MANY_REDIRECTS: "Too many redirects", +}); diff --git a/apps/web/src/components/preview/useDiscoveredLocalServers.test.ts b/apps/web/src/components/preview/useDiscoveredLocalServers.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..43bd879d03293a39cdc71492906dd84337e80631 --- /dev/null +++ b/apps/web/src/components/preview/useDiscoveredLocalServers.test.ts @@ -0,0 +1,116 @@ +import type { DiscoveredLocalServer } from "@t3tools/contracts"; +import { describe, expect, it } from "vitest"; + +import { mergeServers, type PreviewableServer } from "./useDiscoveredLocalServers"; + +const scannerServer = (overrides: Partial): DiscoveredLocalServer => ({ + host: "localhost", + port: 5173, + url: "http://localhost:5173", + processName: "vite", + pid: 1234, + ...overrides, +}); + +describe("mergeServers", () => { + it("returns scanner-only entries unchanged", () => { + const result = mergeServers({ + scanner: [scannerServer({})], + configuredUrls: [], + recentlySeenUrls: [], + }); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + host: "localhost", + port: 5173, + source: "scanner", + listening: true, + processName: "vite", + }); + }); + + it("enriches a configured entry with live process metadata when scanner sees it", () => { + const result = mergeServers({ + scanner: [scannerServer({ port: 5173, processName: "node", pid: 9999 })], + configuredUrls: ["http://localhost:5173"], + recentlySeenUrls: [], + }); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + port: 5173, + source: "configured", + listening: true, + processName: "node", + pid: 9999, + }); + }); + + it("keeps configured entries that the scanner doesn't see, with listening=false", () => { + const result = mergeServers({ + scanner: [], + configuredUrls: ["http://localhost:5173"], + recentlySeenUrls: [], + }); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + source: "configured", + listening: false, + }); + }); + + it("dedupes recently-seen URLs against scanner+configured entries", () => { + const result = mergeServers({ + scanner: [scannerServer({ port: 5173 })], + configuredUrls: [], + recentlySeenUrls: ["http://localhost:5173/", "http://localhost:8080/"], + }); + expect(result.map((s) => s.port)).toEqual([5173, 8080]); + expect(result.find((s) => s.port === 5173)?.source).toBe("scanner"); + expect(result.find((s) => s.port === 8080)?.source).toBe("recent"); + }); + + it("ignores non-loopback URLs in configured/recent inputs", () => { + const result = mergeServers({ + scanner: [], + configuredUrls: ["https://example.com", "ws://localhost:5173"], + recentlySeenUrls: ["https://api.example.com"], + }); + expect(result).toHaveLength(0); + }); + + it("sorts: configured before scanner before recent, then by port", () => { + const result = mergeServers({ + scanner: [scannerServer({ port: 8080 }), scannerServer({ port: 3000 })], + configuredUrls: ["http://localhost:5173"], + recentlySeenUrls: ["http://localhost:9000/", "http://localhost:4321/"], + }); + expect(result.map((s) => `${s.source}:${s.port}`)).toEqual([ + "configured:5173", + "scanner:3000", + "scanner:8080", + "recent:4321", + "recent:9000", + ]); + }); + + it("dedupes by lowercased host", () => { + const result = mergeServers({ + scanner: [scannerServer({ host: "Localhost", port: 5173 })], + configuredUrls: ["http://localhost:5173"], + recentlySeenUrls: [], + }); + expect(result).toHaveLength(1); + }); +}); + +describe("PreviewableServer interface", () => { + it("preserves listening flag through enrichment", () => { + const result = mergeServers({ + scanner: [scannerServer({})], + configuredUrls: ["http://localhost:5173"], + recentlySeenUrls: [], + }); + const merged: PreviewableServer | undefined = result[0]; + expect(merged?.listening).toBe(true); + }); +}); diff --git a/apps/web/src/components/preview/useDiscoveredLocalServers.ts b/apps/web/src/components/preview/useDiscoveredLocalServers.ts new file mode 100644 index 0000000000000000000000000000000000000000..9eab8592d9e43c66fe4b00271a483ea93b8fb8bf --- /dev/null +++ b/apps/web/src/components/preview/useDiscoveredLocalServers.ts @@ -0,0 +1,141 @@ +import type { DiscoveredLocalServer } from "@t3tools/contracts"; +import { isLoopbackHost } from "@t3tools/shared/preview"; +import { useEffect, useMemo, useState } from "react"; + +import { ensureEnvironmentApi } from "~/environmentApi"; +import type { EnvironmentId } from "@t3tools/contracts"; + +export interface PreviewableServer extends DiscoveredLocalServer { + source: "scanner" | "configured" | "recent"; + /** + * True when the port scanner currently sees this server listening. A + * `configured` entry can also be `listening` when the scan enriched it. + */ + listening: boolean; +} + +interface UseDiscoveredLocalServersInput { + environmentId: EnvironmentId; + configuredUrls?: ReadonlyArray | undefined; + recentlySeenUrls?: ReadonlyArray | undefined; +} + +/** + * Subscribe to live localhost port scans, merge in configured / + * recently-seen URLs, and return a stable sorted list. Retains the scanner + * while mounted. + */ +export function useDiscoveredLocalServers( + input: UseDiscoveredLocalServersInput, +): ReadonlyArray { + const [scannerSnapshot, setScannerSnapshot] = useState>([]); + + useEffect(() => { + const api = ensureEnvironmentApi(input.environmentId); + setScannerSnapshot([]); + const unsubscribe = api.preview.subscribePorts((next) => { + setScannerSnapshot(next.servers); + }); + return unsubscribe; + }, [input.environmentId]); + + return useMemo( + () => + mergeServers({ + scanner: scannerSnapshot, + configuredUrls: input.configuredUrls ?? [], + recentlySeenUrls: input.recentlySeenUrls ?? [], + }), + [scannerSnapshot, input.configuredUrls, input.recentlySeenUrls], + ); +} + +export function mergeServers(input: { + scanner: ReadonlyArray; + configuredUrls: ReadonlyArray; + recentlySeenUrls: ReadonlyArray; +}): ReadonlyArray { + const seen = new Map(); + + for (const url of input.configuredUrls) { + const parsed = parseLocalUrl(url); + if (!parsed) continue; + const key = canonicalKey(parsed.host, parsed.port); + if (seen.has(key)) continue; + seen.set(key, { + host: parsed.host, + port: parsed.port, + url: parsed.url, + processName: null, + pid: null, + source: "configured", + listening: false, + }); + } + + for (const server of input.scanner) { + const key = canonicalKey(server.host, server.port); + const existing = seen.get(key); + if (existing) { + // Enrich a configured entry with live process metadata; flip + // `listening` so it pulses green like a scanner-discovered entry. + seen.set(key, { + ...existing, + processName: server.processName ?? existing.processName, + pid: server.pid ?? existing.pid, + listening: true, + }); + continue; + } + seen.set(key, { ...server, source: "scanner", listening: true }); + } + + for (const url of input.recentlySeenUrls) { + const parsed = parseLocalUrl(url); + if (!parsed) continue; + const key = canonicalKey(parsed.host, parsed.port); + if (seen.has(key)) continue; + seen.set(key, { + host: parsed.host, + port: parsed.port, + url: parsed.url, + processName: null, + pid: null, + source: "recent", + listening: false, + }); + } + + return Array.from(seen.values()).toSorted((a, b) => { + const sourceOrder: Record = { + configured: 0, + scanner: 1, + recent: 2, + }; + if (sourceOrder[a.source] !== sourceOrder[b.source]) { + return sourceOrder[a.source] - sourceOrder[b.source]; + } + return a.port - b.port; + }); +} + +function canonicalKey(host: string, port: number): string { + return `${host.toLowerCase()}:${port}`; +} + +function parseLocalUrl(raw: string): { host: string; port: number; url: string } | null { + try { + const parsed = new URL(raw); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null; + if (!isLoopbackHost(parsed.hostname)) return null; + const port = parsed.port + ? Number.parseInt(parsed.port, 10) + : parsed.protocol === "http:" + ? 80 + : 443; + if (!Number.isFinite(port) || port <= 0) return null; + return { host: parsed.hostname, port, url: parsed.href }; + } catch { + return null; + } +} diff --git a/apps/web/src/components/preview/useLoadingProgress.ts b/apps/web/src/components/preview/useLoadingProgress.ts new file mode 100644 index 0000000000000000000000000000000000000000..6c47cea601780cd483312f6f3ed24516a5764b6b --- /dev/null +++ b/apps/web/src/components/preview/useLoadingProgress.ts @@ -0,0 +1,45 @@ +import { useEffect, useRef, useState } from "react"; + +const TICK_INTERVAL_MS = 120; +const FADE_OUT_DELAY_MS = 220; +const SEED_PERCENT = 4; +const ASYMPTOTE_PERCENT = 90; +const APPROACH_FACTOR = 0.08; +const MIN_INCREMENT = 0.5; + +/** + * Indeterminate progress simulator for the preview chrome's loading bar. + * Animates 0 → 90% asymptotically while `loading` is true, snaps to 100% + * on release, then resets after a short pause. + * + * Uses a ref to thread the latest progress through interval ticks without + * needing `loading` to retrigger the effect, which sidesteps the stale- + * closure pitfalls of reading `progress` directly. + */ +export function useLoadingProgress(loading: boolean): number { + const [progress, setProgress] = useState(0); + const progressRef = useRef(0); + progressRef.current = progress; + + useEffect(() => { + if (!loading) { + if (progressRef.current === 0) return; + setProgress(100); + const timer = window.setTimeout(() => setProgress(0), FADE_OUT_DELAY_MS); + return () => window.clearTimeout(timer); + } + + setProgress((value) => (value > 0 && value < 95 ? value : SEED_PERCENT)); + const interval = window.setInterval(() => { + const current = progressRef.current; + if (current >= ASYMPTOTE_PERCENT) return; + const remaining = ASYMPTOTE_PERCENT - current; + const increment = Math.max(MIN_INCREMENT, remaining * APPROACH_FACTOR); + setProgress(Math.min(ASYMPTOTE_PERCENT, current + increment)); + }, TICK_INTERVAL_MS); + + return () => window.clearInterval(interval); + }, [loading]); + + return progress; +} diff --git a/apps/web/src/components/preview/usePreviewBridge.ts b/apps/web/src/components/preview/usePreviewBridge.ts new file mode 100644 index 0000000000000000000000000000000000000000..6a80fb8200138180062c5af1a016aac3de1e2991 --- /dev/null +++ b/apps/web/src/components/preview/usePreviewBridge.ts @@ -0,0 +1,137 @@ +"use client"; + +import type { + DesktopPreviewTabState, + PreviewReportStatusInput, + ScopedThreadRef, + ThreadId, +} from "@t3tools/contracts"; +import { useEffect, useRef } from "react"; + +import { ensureEnvironmentApi } from "~/environmentApi"; +import { type DesktopPreviewOverlay, usePreviewStateStore } from "~/previewStateStore"; + +import { previewBridge } from "./previewBridge"; + +/** + * Owns the desktop tab lifecycle, mirrors low-latency button state into the + * store, and reflects bridge navigation events back to the server. + * + * Tab create/close is anchored to the panel mount, NOT to the snapshot, so + * snapshot transitions (`opened` → `closed` → `opened`) cannot tear down and + * recreate the tab in the wrong order relative to in-flight desktop IPCs. + */ +export function usePreviewBridge(input: { threadRef: ScopedThreadRef; tabId: string }): void { + const { threadRef, tabId } = input; + const applyDesktopState = usePreviewStateStore((state) => state.applyDesktopState); + const bridge = previewBridge; + + useEffect(() => { + if (!bridge) return; + void bridge.createTab(tabId); + return () => { + void bridge.closeTab(tabId); + }; + }, [bridge, tabId]); + + // One bridge subscription does both jobs (mirror state + forward to + // server) so the desktop bridge keeps a single listener entry per tab. + const lastReportedUrl = useRef(null); + const lastReportedKind = useRef(null); + useEffect(() => { + if (!bridge || typeof window === "undefined") return; + const api = ensureEnvironmentApi(threadRef.environmentId); + lastReportedUrl.current = null; + lastReportedKind.current = null; + const unsubscribe = bridge.onStateChange((changedTabId, state) => { + if (changedTabId !== tabId) return; + applyDesktopState(threadRef, projectDesktopState(state)); + const reported = buildReportInput({ + threadId: threadRef.threadId, + tabId, + state, + lastReportedUrl: lastReportedUrl.current, + lastReportedKind: lastReportedKind.current, + }); + if (!reported) return; + lastReportedUrl.current = reported.lastReportedUrl; + lastReportedKind.current = reported.lastReportedKind; + void api.preview.reportStatus(reported.input).catch(() => undefined); + }); + return unsubscribe; + }, [applyDesktopState, bridge, tabId, threadRef]); +} + +function projectDesktopState(state: DesktopPreviewTabState): DesktopPreviewOverlay { + return { + canGoBack: state.canGoBack, + canGoForward: state.canGoForward, + loading: state.navStatus.kind === "Loading", + zoomFactor: state.zoomFactor, + }; +} + +/** + * Decide whether a state change warrants an RPC to the server, and shape + * the report payload. + * + * - Idle never reports — the tab is post-close or pre-load and the server + * already knows the canonical state from `open` / `closed`. + * - We dedupe on (kind, url): consecutive Loading→Loading→Loading for the + * same URL collapses to a single RPC, ditto Success. + * - LoadFailed always reports (the server uses it to emit `failed`). + */ +function buildReportInput(args: { + readonly threadId: ThreadId; + readonly tabId: string; + readonly state: DesktopPreviewTabState; + readonly lastReportedUrl: string | null; + readonly lastReportedKind: DesktopPreviewTabState["navStatus"]["kind"] | null; +}): { + readonly input: PreviewReportStatusInput; + readonly lastReportedUrl: string; + readonly lastReportedKind: DesktopPreviewTabState["navStatus"]["kind"]; +} | null { + const { threadId, tabId, state, lastReportedUrl, lastReportedKind } = args; + const status = state.navStatus; + if (status.kind === "Idle") return null; + + // Skip if we've already reported the same kind+url. LoadFailed always + // reports (rapid duplicate failures are unusual and worth surfacing). + const sameAsLast = + status.kind !== "LoadFailed" && + status.kind === lastReportedKind && + status.url === lastReportedUrl; + if (sameAsLast) return null; + + const base = { + threadId, + tabId, + canGoBack: state.canGoBack, + canGoForward: state.canGoForward, + }; + if (status.kind === "LoadFailed") { + return { + input: { + ...base, + navStatus: { + _tag: "LoadFailed", + url: status.url, + title: status.title, + code: status.code, + description: status.description, + }, + }, + lastReportedUrl: status.url, + lastReportedKind: "LoadFailed", + }; + } + return { + input: { + ...base, + navStatus: { _tag: status.kind, url: status.url, title: status.title }, + }, + lastReportedUrl: status.url, + lastReportedKind: status.kind, + }; +} diff --git a/apps/web/src/components/preview/usePreviewSession.ts b/apps/web/src/components/preview/usePreviewSession.ts new file mode 100644 index 0000000000000000000000000000000000000000..152765213bf64155161edc1d73792a3eb5e46e5b --- /dev/null +++ b/apps/web/src/components/preview/usePreviewSession.ts @@ -0,0 +1,67 @@ +"use client"; + +import { scopedThreadKey } from "@t3tools/client-runtime"; +import type { ScopedThreadRef } from "@t3tools/contracts"; +import { useEffect } from "react"; + +import { ensureEnvironmentApi } from "~/environmentApi"; +import { usePreviewStateStore } from "~/previewStateStore"; + +/** + * Subscribes to the server's per-thread preview events and replays the + * latest snapshot on mount. + * + * Reconnect-recovery: when the local renderer remembers a snapshot but the + * server has none (server restarted while we were alive), re-issue + * `preview.open` so subsequent events land on a real session. + */ +export function usePreviewSession(threadRef: ScopedThreadRef): void { + const applyServerSnapshot = usePreviewStateStore((state) => state.applyServerSnapshot); + const applyServerEvent = usePreviewStateStore((state) => state.applyServerEvent); + + useEffect(() => { + if (typeof window === "undefined") return; + const api = ensureEnvironmentApi(threadRef.environmentId); + const threadIdValue = threadRef.threadId; + let cancelled = false; + + void api.preview + .list({ threadId: threadIdValue }) + .then((result) => { + if (cancelled) return; + // Pick the most recent session. Server returns sessions sorted by + // `updatedAt` ascending, so the last one is freshest. + const serverSnapshot = result.sessions.at(-1) ?? null; + if (serverSnapshot) { + applyServerSnapshot(threadRef, serverSnapshot); + return; + } + // Server has no sessions — try to recover what the renderer + // remembers from before the disconnect. + const localSnapshot = + usePreviewStateStore.getState().byThreadKey[scopedThreadKey(threadRef)]?.snapshot; + const recoverableUrl = + localSnapshot && localSnapshot.navStatus._tag !== "Idle" + ? localSnapshot.navStatus.url + : null; + if (recoverableUrl) { + void api.preview + .open({ threadId: threadIdValue, url: recoverableUrl }) + .catch(() => undefined); + } else { + applyServerSnapshot(threadRef, null); + } + }) + .catch(() => undefined); + + const unsubscribe = api.preview.onEvent((event) => { + if (event.threadId !== threadIdValue) return; + applyServerEvent(threadRef, event); + }); + + return () => { + cancelled = true; + unsubscribe(); + }; + }, [applyServerEvent, applyServerSnapshot, threadRef]); +} diff --git a/apps/web/src/environmentApi.ts b/apps/web/src/environmentApi.ts index bdb2e793069b44747d628ac840b5458969cc54e1..0268d98dcd38e7736d3cc8619a11db43eba2d054 100644 --- a/apps/web/src/environmentApi.ts +++ b/apps/web/src/environmentApi.ts @@ -58,6 +58,16 @@ export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi { subscribeThread: (input, callback, options) => rpcClient.orchestration.subscribeThread(input, callback, options), }, + preview: { + open: (input) => rpcClient.preview.open(input as never), + navigate: (input) => rpcClient.preview.navigate(input as never), + refresh: (input) => rpcClient.preview.refresh(input as never), + close: (input) => rpcClient.preview.close(input as never), + list: (input) => rpcClient.preview.list(input as never), + reportStatus: (input) => rpcClient.preview.reportStatus(input as never), + onEvent: (callback) => rpcClient.preview.onEvent(callback), + subscribePorts: (callback, options) => rpcClient.preview.subscribePorts(callback, options), + }, }; } diff --git a/apps/web/src/hooks/useResizableWidth.ts b/apps/web/src/hooks/useResizableWidth.ts new file mode 100644 index 0000000000000000000000000000000000000000..3552c82d9dc360a0d215b93adff38b8095ea1d59 --- /dev/null +++ b/apps/web/src/hooks/useResizableWidth.ts @@ -0,0 +1,176 @@ +import * as Schema from "effect/Schema"; +import { + type PointerEvent as ReactPointerEvent, + useCallback, + useEffect, + useRef, + useState, +} from "react"; + +import { getLocalStorageItem, setLocalStorageItem } from "./useLocalStorage"; + +const WidthSchema = Schema.Finite; + +export interface UseResizableWidthOptions { + /** localStorage key the persisted width is stored under. */ + readonly storageKey: string; + readonly defaultWidth: number; + readonly minWidth: number; + readonly maxWidth: number; + /** + * Which edge of the host element carries the drag handle: + * - "left" → panel grows leftward (right-anchored panels) + * - "right" → panel grows rightward (left-anchored panels) + */ + readonly edge: "left" | "right"; +} + +export interface ResizableWidthHandlers { + readonly onPointerDown: (event: ReactPointerEvent) => void; + readonly onPointerMove: (event: ReactPointerEvent) => void; + readonly onPointerUp: (event: ReactPointerEvent) => void; + readonly onPointerCancel: (event: ReactPointerEvent) => void; +} + +/** + * Width state for a side-anchored panel resized via a drag handle on the + * specified edge. Width is read from localStorage on mount and persisted on + * drag-end (not on every rAF tick — would otherwise be ~60 writes/sec). + * + * The hook updates an internal `width` state during drag (so the panel + * follows the cursor live) and only commits to localStorage when the user + * lifts the pointer. + */ +export function useResizableWidth(options: UseResizableWidthOptions): { + readonly width: number; + readonly handlers: ResizableWidthHandlers; +} { + const { storageKey, defaultWidth, minWidth, maxWidth, edge } = options; + + const clamp = useCallback( + (value: number): number => { + if (!Number.isFinite(value)) return defaultWidth; + return Math.max(minWidth, Math.min(maxWidth, value)); + }, + [defaultWidth, maxWidth, minWidth], + ); + + // No cross-tab subscription: panel width is per-window state. + const [width, setWidth] = useState(() => { + if (typeof window === "undefined") return defaultWidth; + try { + const stored = getLocalStorageItem(storageKey, WidthSchema); + return clamp(stored ?? defaultWidth); + } catch { + return defaultWidth; + } + }); + + // Re-clamp if min/max change at runtime (e.g. window resize narrows max). + useEffect(() => { + setWidth((current) => clamp(current)); + }, [clamp]); + + const dragStateRef = useRef<{ + pointerId: number; + startX: number; + startWidth: number; + pending: number; + rafId: number | null; + target: HTMLElement; + } | null>(null); + + const releasePointer = useCallback((pointerId: number) => { + const state = dragStateRef.current; + if (!state) return; + if (state.rafId !== null) { + cancelAnimationFrame(state.rafId); + } + try { + if (state.target.hasPointerCapture(pointerId)) { + state.target.releasePointerCapture(pointerId); + } + } catch { + // pointer may already be released; harmless. + } + document.body.style.removeProperty("cursor"); + document.body.style.removeProperty("user-select"); + dragStateRef.current = null; + }, []); + + const onPointerDown = useCallback( + (event: ReactPointerEvent) => { + if (event.button !== 0) return; + event.preventDefault(); + event.stopPropagation(); + const target = event.currentTarget; + try { + target.setPointerCapture(event.pointerId); + } catch { + return; + } + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; + dragStateRef.current = { + pointerId: event.pointerId, + startX: event.clientX, + startWidth: width, + pending: width, + rafId: null, + target, + }; + }, + [width], + ); + + const onPointerMove = useCallback( + (event: ReactPointerEvent) => { + const state = dragStateRef.current; + if (!state || state.pointerId !== event.pointerId) return; + event.preventDefault(); + const delta = edge === "left" ? state.startX - event.clientX : event.clientX - state.startX; + state.pending = clamp(state.startWidth + delta); + if (state.rafId !== null) return; + state.rafId = requestAnimationFrame(() => { + const active = dragStateRef.current; + if (!active) return; + active.rafId = null; + setWidth(active.pending); + }); + }, + [clamp, edge], + ); + + const onPointerUp = useCallback( + (event: ReactPointerEvent) => { + const state = dragStateRef.current; + if (!state || state.pointerId !== event.pointerId) return; + const finalWidth = clamp(state.pending); + releasePointer(event.pointerId); + // Commit once at drag-end to avoid 60Hz localStorage writes. + try { + setLocalStorageItem(storageKey, finalWidth, WidthSchema); + } catch { + // localStorage may be full / disabled; the in-memory state still wins. + } + setWidth(finalWidth); + }, + [clamp, releasePointer, storageKey], + ); + + const onPointerCancel = useCallback( + (event: ReactPointerEvent) => { + const state = dragStateRef.current; + if (!state || state.pointerId !== event.pointerId) return; + // Don't persist a cancelled drag; revert to the start width. + releasePointer(event.pointerId); + setWidth(state.startWidth); + }, + [releasePointer], + ); + + return { + width, + handlers: { onPointerDown, onPointerMove, onPointerUp, onPointerCancel }, + }; +} diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index dbf2450f7944bc7277f48206b60a3d64ee84d15a..f9603bdce6c7a19dc6312c6ac8ce4c37cea03351 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -30,6 +30,8 @@ export interface ShortcutModifierStateLike { export interface ShortcutMatchContext { terminalFocus: boolean; terminalOpen: boolean; + previewFocus: boolean; + previewOpen: boolean; [key: string]: boolean; } @@ -112,6 +114,8 @@ function resolveContext(options: ShortcutMatchOptions | undefined): ShortcutMatc return { terminalFocus: false, terminalOpen: false, + previewFocus: false, + previewOpen: false, ...options?.context, }; } @@ -379,6 +383,30 @@ export function isDiffToggleShortcut( return matchesCommandShortcut(event, keybindings, "diff.toggle", options); } +export function isPreviewToggleShortcut( + event: ShortcutEventLike, + keybindings: ResolvedKeybindingsConfig, + options?: ShortcutMatchOptions, +): boolean { + return matchesCommandShortcut(event, keybindings, "preview.toggle", options); +} + +export function isPreviewRefreshShortcut( + event: ShortcutEventLike, + keybindings: ResolvedKeybindingsConfig, + options?: ShortcutMatchOptions, +): boolean { + return matchesCommandShortcut(event, keybindings, "preview.refresh", options); +} + +export function isPreviewFocusUrlShortcut( + event: ShortcutEventLike, + keybindings: ResolvedKeybindingsConfig, + options?: ShortcutMatchOptions, +): boolean { + return matchesCommandShortcut(event, keybindings, "preview.focusUrl", options); +} + export function isChatNewShortcut( event: ShortcutEventLike, keybindings: ResolvedKeybindingsConfig, diff --git a/apps/web/src/lib/favicon.ts b/apps/web/src/lib/favicon.ts new file mode 100644 index 0000000000000000000000000000000000000000..e5e94b2666fbc9517f3980d66b58e484091003c2 --- /dev/null +++ b/apps/web/src/lib/favicon.ts @@ -0,0 +1,20 @@ +/** + * Favicon helpers for the preview tab strip. + * + * Uses Google's s2 favicon endpoint (same approach as ami's tab strip). + * Callers should always render a `` fallback when the returned URL + * fails to load via an `onError` handler. + */ +const FAVICON_PROVIDER = "https://www.google.com/s2/favicons"; + +export function faviconUrlForOrigin(rawUrl: string | null | undefined, size = 32): string | null { + if (!rawUrl) return null; + try { + const url = new URL(rawUrl); + if (!url.host) return null; + if (url.protocol !== "http:" && url.protocol !== "https:") return null; + return `${FAVICON_PROVIDER}?domain=${encodeURIComponent(url.host)}&sz=${size}`; + } catch { + return null; + } +} diff --git a/apps/web/src/lib/previewFocus.ts b/apps/web/src/lib/previewFocus.ts new file mode 100644 index 0000000000000000000000000000000000000000..af7e76eaf03c42cdc3875fe4c7e6704bc94d9ba1 --- /dev/null +++ b/apps/web/src/lib/previewFocus.ts @@ -0,0 +1,15 @@ +/** + * Returns true when the user's keyboard focus is somewhere inside the + * preview panel (URL bar, chrome buttons, or — once detected via Electron + * `` focus events — the embedded page). + * + * Used by the global keybinding handler to gate `preview.refresh` and + * `preview.focusUrl` to only fire while the preview owns focus. + */ +export function isPreviewFocused(): boolean { + const activeElement = document.activeElement; + if (!(activeElement instanceof HTMLElement)) return false; + if (!activeElement.isConnected) return false; + if (activeElement.tagName.toLowerCase() === "webview") return true; + return activeElement.closest("[data-preview-panel-mode]") !== null; +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index e92cad9aa3dfe065509b0cd418c4ed238fb2f22a..b4aff4f2cacd0794be4e184f479dc688c786dbef 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -18,6 +18,10 @@ import { getRouter } from "./router"; import { APP_DISPLAY_NAME } from "./branding"; import { syncDocumentWindowControlsOverlayClass } from "./lib/windowControlsOverlay"; +if (import.meta.env.DEV) { + void import("react-grab"); +} + // Electron loads the app from a file-backed shell, so hash history avoids path resolution issues. const history = isElectron ? createHashHistory() : createBrowserHistory(); diff --git a/apps/web/src/previewStateStore.test.ts b/apps/web/src/previewStateStore.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..39dc7adee16215d0d36c30aaf6fc80f2e9e27daa --- /dev/null +++ b/apps/web/src/previewStateStore.test.ts @@ -0,0 +1,227 @@ +import { scopeThreadRef } from "@t3tools/client-runtime"; +import { type EnvironmentId, type PreviewSessionSnapshot, ThreadId } from "@t3tools/contracts"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { __testing, selectThreadPreviewState, usePreviewStateStore } from "./previewStateStore"; + +const environmentId = "env-1" as EnvironmentId; +const ref = scopeThreadRef(environmentId, ThreadId.make("thread-1")); + +const makeSnapshot = (overrides: Partial = {}): PreviewSessionSnapshot => ({ + threadId: "thread-1", + tabId: "tab_a", + navStatus: { _tag: "Loading", url: "http://localhost:5173/", title: "" }, + canGoBack: false, + canGoForward: false, + updatedAt: "2026-01-01T00:00:00.000Z", + ...overrides, +}); + +beforeEach(() => { + usePreviewStateStore.setState({ byThreadKey: {} }); +}); + +describe("previewStateStore (single-tab)", () => { + it("opened event seeds the snapshot and remembers the URL", () => { + const snapshot = makeSnapshot(); + usePreviewStateStore.getState().applyServerEvent(ref, { + type: "opened", + threadId: "thread-1", + tabId: snapshot.tabId, + createdAt: snapshot.updatedAt, + snapshot, + }); + const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); + expect(state.snapshot?.tabId).toBe(snapshot.tabId); + expect(state.recentlySeenUrls).toContain("http://localhost:5173/"); + }); + + it("a second `opened` for a different tab replaces the rendered snapshot", () => { + const a = makeSnapshot({ tabId: "tab_a" }); + const b = makeSnapshot({ tabId: "tab_b" }); + const store = usePreviewStateStore.getState(); + store.applyServerEvent(ref, { + type: "opened", + threadId: "thread-1", + tabId: a.tabId, + createdAt: a.updatedAt, + snapshot: a, + }); + store.applyServerEvent(ref, { + type: "opened", + threadId: "thread-1", + tabId: b.tabId, + createdAt: b.updatedAt, + snapshot: b, + }); + const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); + expect(state.snapshot?.tabId).toBe(b.tabId); + }); + + it("navigated event updates the snapshot URL", () => { + const snapshot = makeSnapshot(); + const store = usePreviewStateStore.getState(); + store.applyServerEvent(ref, { + type: "opened", + threadId: "thread-1", + tabId: snapshot.tabId, + createdAt: snapshot.updatedAt, + snapshot, + }); + store.applyServerEvent(ref, { + type: "navigated", + threadId: "thread-1", + tabId: snapshot.tabId, + createdAt: "2026-01-01T00:00:01.000Z", + snapshot: { + ...snapshot, + navStatus: { _tag: "Success", url: "http://localhost:5173/about", title: "About" }, + }, + }); + const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); + expect(state.snapshot?.navStatus._tag).toBe("Success"); + if (state.snapshot?.navStatus._tag === "Success") { + expect(state.snapshot.navStatus.url).toBe("http://localhost:5173/about"); + } + }); + + it("failed event flips the snapshot to LoadFailed when tabId matches", () => { + const snapshot = makeSnapshot(); + const store = usePreviewStateStore.getState(); + store.applyServerEvent(ref, { + type: "opened", + threadId: "thread-1", + tabId: snapshot.tabId, + createdAt: snapshot.updatedAt, + snapshot, + }); + store.applyServerEvent(ref, { + type: "failed", + threadId: "thread-1", + tabId: snapshot.tabId, + createdAt: "2026-01-01T00:00:01.000Z", + url: "http://localhost:5173/", + title: "", + code: -105, + description: "ERR_NAME_NOT_RESOLVED", + }); + const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); + expect(state.snapshot?.navStatus._tag).toBe("LoadFailed"); + }); + + it("failed event for a non-active tab is ignored", () => { + const snapshot = makeSnapshot({ tabId: "tab_a" }); + const store = usePreviewStateStore.getState(); + store.applyServerEvent(ref, { + type: "opened", + threadId: "thread-1", + tabId: snapshot.tabId, + createdAt: snapshot.updatedAt, + snapshot, + }); + store.applyServerEvent(ref, { + type: "failed", + threadId: "thread-1", + tabId: "tab_b", + createdAt: "2026-01-01T00:00:01.000Z", + url: "http://localhost:9999/", + title: "", + code: -105, + description: "ERR_NAME_NOT_RESOLVED", + }); + const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); + expect(state.snapshot?.navStatus._tag).toBe("Loading"); + }); + + it("closed event clears snapshot but retains recently-seen URLs", () => { + const snapshot = makeSnapshot(); + const store = usePreviewStateStore.getState(); + store.applyServerEvent(ref, { + type: "opened", + threadId: "thread-1", + tabId: snapshot.tabId, + createdAt: snapshot.updatedAt, + snapshot, + }); + store.applyServerEvent(ref, { + type: "closed", + threadId: "thread-1", + tabId: snapshot.tabId, + createdAt: "2026-01-01T00:00:01.000Z", + }); + const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); + expect(state.snapshot).toBeNull(); + expect(state.recentlySeenUrls).toContain("http://localhost:5173/"); + }); + + it("closed event for a different tab is a no-op", () => { + const snapshot = makeSnapshot({ tabId: "tab_a" }); + const store = usePreviewStateStore.getState(); + store.applyServerEvent(ref, { + type: "opened", + threadId: "thread-1", + tabId: snapshot.tabId, + createdAt: snapshot.updatedAt, + snapshot, + }); + store.applyServerEvent(ref, { + type: "closed", + threadId: "thread-1", + tabId: "tab_b", + createdAt: "2026-01-01T00:00:01.000Z", + }); + const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); + expect(state.snapshot?.tabId).toBe(snapshot.tabId); + }); + + it("desktopOverlay updates independently of snapshot", () => { + const snapshot = makeSnapshot(); + const store = usePreviewStateStore.getState(); + store.applyServerEvent(ref, { + type: "opened", + threadId: "thread-1", + tabId: snapshot.tabId, + createdAt: snapshot.updatedAt, + snapshot, + }); + store.applyDesktopState(ref, { + canGoBack: true, + canGoForward: false, + loading: false, + zoomFactor: 1, + }); + const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); + expect(state.desktopOverlay?.canGoBack).toBe(true); + expect(state.snapshot?.canGoBack).toBe(false); + }); + + it("applyServerSnapshot null clears snapshot for a thread that had one", () => { + const snapshot = makeSnapshot(); + const store = usePreviewStateStore.getState(); + store.applyServerSnapshot(ref, snapshot); + store.applyServerSnapshot(ref, null); + const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); + expect(state.snapshot).toBeNull(); + }); + + it("rememberUrl dedupes and caps at limit", () => { + const store = usePreviewStateStore.getState(); + for (let i = 0; i < __testing.RECENT_URL_LIMIT + 5; i += 1) { + store.rememberUrl(ref, `http://localhost:${5000 + i}/`); + } + const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); + expect(state.recentlySeenUrls.length).toBeLessThanOrEqual(__testing.RECENT_URL_LIMIT); + expect(state.recentlySeenUrls[0]).toBe( + `http://localhost:${5000 + __testing.RECENT_URL_LIMIT + 4}/`, + ); + }); + + it("removeThread strips the entry", () => { + const snapshot = makeSnapshot(); + const store = usePreviewStateStore.getState(); + store.applyServerSnapshot(ref, snapshot); + store.removeThread(ref); + const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); + expect(state).toEqual(__testing.EMPTY_THREAD_PREVIEW_STATE); + }); +}); diff --git a/apps/web/src/previewStateStore.ts b/apps/web/src/previewStateStore.ts new file mode 100644 index 0000000000000000000000000000000000000000..90719efd4c363458623aaf6d10390008ed22fb23 --- /dev/null +++ b/apps/web/src/previewStateStore.ts @@ -0,0 +1,188 @@ +/** + * Per-thread preview UI state. + * + * Single-tab model: one snapshot per thread, mirrored two ways: + * - `snapshot` is the server-authoritative URL/title/load-status, replayed + * on WS reconnect so the panel survives backend restarts. + * - `desktopOverlay` is low-latency state from the local + * (canGoBack/canGoForward/visible/zoom/loading), used by the chrome row's + * button enablement. + * + * The schema-level `tabId` exists because the server still keys sessions by + * `(threadId, tabId)`; the client just always tracks one and ignores the rest. + */ +import { scopedThreadKey } from "@t3tools/client-runtime"; +import { + type PreviewEvent, + type PreviewSessionSnapshot, + type ScopedThreadRef, +} from "@t3tools/contracts"; +import { create } from "zustand"; + +import { PREVIEW_RECENT_URL_LIMIT } from "./components/preview/previewConstants"; + +export interface DesktopPreviewOverlay { + canGoBack: boolean; + canGoForward: boolean; + loading: boolean; + zoomFactor: number; +} + +export interface ThreadPreviewState { + snapshot: PreviewSessionSnapshot | null; + /** Bridge state takes precedence over `snapshot` for nav button enablement. */ + desktopOverlay: DesktopPreviewOverlay | null; + /** Recently-visited URLs surfaced in the empty state. */ + recentlySeenUrls: string[]; +} + +const EMPTY_THREAD_PREVIEW_STATE: ThreadPreviewState = Object.freeze({ + snapshot: null, + desktopOverlay: null, + recentlySeenUrls: [] as string[], +}); + +interface PreviewStateStoreState { + byThreadKey: Record; + applyServerEvent: (ref: ScopedThreadRef, event: PreviewEvent) => void; + applyServerSnapshot: (ref: ScopedThreadRef, snapshot: PreviewSessionSnapshot | null) => void; + applyDesktopState: (ref: ScopedThreadRef, overlay: DesktopPreviewOverlay | null) => void; + rememberUrl: (ref: ScopedThreadRef, url: string) => void; + removeThread: (ref: ScopedThreadRef) => void; +} + +const ensureState = ( + byThreadKey: Record, + threadKey: string, +): ThreadPreviewState => byThreadKey[threadKey] ?? EMPTY_THREAD_PREVIEW_STATE; + +const updateThread = ( + state: PreviewStateStoreState, + threadKey: string, + updater: (current: ThreadPreviewState) => ThreadPreviewState, +): PreviewStateStoreState["byThreadKey"] => { + const current = ensureState(state.byThreadKey, threadKey); + const next = updater(current); + if (next === current) return state.byThreadKey; + return { ...state.byThreadKey, [threadKey]: next }; +}; + +const removeThreadKey = ( + byThreadKey: Record, + threadKey: string, +): Record => { + if (!(threadKey in byThreadKey)) return byThreadKey; + const { [threadKey]: _removed, ...rest } = byThreadKey; + return rest; +}; + +const dedupeRecentUrls = (existing: string[], url: string): string[] => { + const next = [url, ...existing.filter((entry) => entry !== url)]; + return next.slice(0, PREVIEW_RECENT_URL_LIMIT); +}; + +export const usePreviewStateStore = create()((set) => ({ + byThreadKey: {}, + applyServerEvent: (ref, event) => + set((state) => { + const threadKey = scopedThreadKey(ref); + let nextByThread = state.byThreadKey; + switch (event.type) { + case "opened": + case "navigated": + nextByThread = updateThread(state, threadKey, (current) => { + const snapshot = event.snapshot; + const recentlySeenUrls = + snapshot.navStatus._tag === "Idle" + ? current.recentlySeenUrls + : dedupeRecentUrls(current.recentlySeenUrls, snapshot.navStatus.url); + return { ...current, snapshot, recentlySeenUrls }; + }); + break; + case "failed": + nextByThread = updateThread(state, threadKey, (current) => { + if (!current.snapshot || current.snapshot.tabId !== event.tabId) return current; + return { + ...current, + snapshot: { + ...current.snapshot, + navStatus: { + _tag: "LoadFailed", + url: event.url, + title: event.title, + code: event.code, + description: event.description, + }, + updatedAt: event.createdAt, + }, + }; + }); + break; + case "closed": + nextByThread = updateThread(state, threadKey, (current) => { + // Only clear if the closed tab is the one we were tracking; the + // server may have multiple tabs per thread but we only render one. + if (current.snapshot && current.snapshot.tabId !== event.tabId) return current; + return { ...current, snapshot: null, desktopOverlay: null }; + }); + break; + } + return { byThreadKey: nextByThread }; + }), + applyServerSnapshot: (ref, snapshot) => + set((state) => { + const threadKey = scopedThreadKey(ref); + const nextByThread = updateThread(state, threadKey, (current) => { + if (!snapshot && current.snapshot === null) return current; + const recentlySeenUrls = + snapshot && snapshot.navStatus._tag !== "Idle" + ? dedupeRecentUrls(current.recentlySeenUrls, snapshot.navStatus.url) + : current.recentlySeenUrls; + return { ...current, snapshot, recentlySeenUrls }; + }); + return { byThreadKey: nextByThread }; + }), + applyDesktopState: (ref, overlay) => + set((state) => { + const threadKey = scopedThreadKey(ref); + const nextByThread = updateThread(state, threadKey, (current) => ({ + ...current, + desktopOverlay: overlay, + })); + return { byThreadKey: nextByThread }; + }), + rememberUrl: (ref, url) => + set((state) => { + if (url.trim().length === 0) return state; + const threadKey = scopedThreadKey(ref); + const nextByThread = updateThread(state, threadKey, (current) => ({ + ...current, + recentlySeenUrls: dedupeRecentUrls(current.recentlySeenUrls, url), + })); + return { byThreadKey: nextByThread }; + }), + removeThread: (ref) => + set((state) => { + const threadKey = scopedThreadKey(ref); + if (!(threadKey in state.byThreadKey)) return state; + return { byThreadKey: removeThreadKey(state.byThreadKey, threadKey) }; + }), +})); + +export function selectThreadPreviewState( + byThreadKey: Record, + ref: ScopedThreadRef | null | undefined, +): ThreadPreviewState { + if (!ref) return EMPTY_THREAD_PREVIEW_STATE; + return ensureState(byThreadKey, scopedThreadKey(ref)); +} + +export function isPreviewSupportedInRuntime(): boolean { + if (typeof window === "undefined") return false; + return Boolean(window.desktopBridge?.preview); +} + +export const __testing = { + EMPTY_THREAD_PREVIEW_STATE, + RECENT_URL_LIMIT: PREVIEW_RECENT_URL_LIMIT, +}; diff --git a/apps/web/src/rightPanelStore.test.ts b/apps/web/src/rightPanelStore.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..9074ff49d02a88ad9f909156a1fb56178392eaab --- /dev/null +++ b/apps/web/src/rightPanelStore.test.ts @@ -0,0 +1,70 @@ +import { scopeThreadRef } from "@t3tools/client-runtime"; +import { type EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { + selectActiveRightPanel, + selectActiveRightPanelKindWithUrl, + useRightPanelStore, +} from "./rightPanelStore"; + +const refA = scopeThreadRef("env-1" as EnvironmentId, ThreadId.make("thread-A")); +const refB = scopeThreadRef("env-1" as EnvironmentId, ThreadId.make("thread-B")); + +beforeEach(() => { + useRightPanelStore.setState({ byThreadKey: {} }); +}); + +describe("rightPanelStore", () => { + it("open sets the active panel for a thread", () => { + useRightPanelStore.getState().open(refA, "preview"); + expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("preview"); + expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refB)).toBeNull(); + }); + + it("opening a different kind replaces the previous one", () => { + useRightPanelStore.getState().open(refA, "plan"); + useRightPanelStore.getState().open(refA, "preview"); + expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("preview"); + }); + + it("close clears the active panel", () => { + useRightPanelStore.getState().open(refA, "plan"); + useRightPanelStore.getState().close(refA); + expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBeNull(); + }); + + it("toggle opens then closes the same kind", () => { + useRightPanelStore.getState().toggle(refA, "preview"); + expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("preview"); + useRightPanelStore.getState().toggle(refA, "preview"); + expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBeNull(); + }); + + it("toggle to a different kind switches active", () => { + useRightPanelStore.getState().toggle(refA, "preview"); + useRightPanelStore.getState().toggle(refA, "plan"); + expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("plan"); + }); + + it("?diff=1 always wins over persisted state", () => { + useRightPanelStore.getState().open(refA, "preview"); + expect( + selectActiveRightPanelKindWithUrl(useRightPanelStore.getState().byThreadKey, refA, true), + ).toBe("diff"); + expect( + selectActiveRightPanelKindWithUrl(useRightPanelStore.getState().byThreadKey, refA, false), + ).toBe("preview"); + }); + + it("removeThread clears persisted state", () => { + useRightPanelStore.getState().open(refA, "plan"); + useRightPanelStore.getState().removeThread(refA); + expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBeNull(); + }); + + it("close on never-opened thread is a no-op", () => { + useRightPanelStore.getState().close(refA); + expect(useRightPanelStore.getState().byThreadKey).toEqual({}); + }); +}); diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts new file mode 100644 index 0000000000000000000000000000000000000000..b8bba44fdccf0749a93656c38af361cc6b94a435 --- /dev/null +++ b/apps/web/src/rightPanelStore.ts @@ -0,0 +1,114 @@ +/** + * Per-thread arbiter for the right-side panel. + * + * Three tenants share the same slot: the plan sidebar, the diff panel, and + * the preview panel. Only one is open at a time per thread; the choice is + * remembered across thread switches. + * + * The diff panel still uses `?diff=1` as URL truth for deep-linking — when + * that param is present the diff tenant wins regardless of what's persisted + * here. See `selectActiveRightPanelKindWithUrl` for the resolution rule. + */ +import { scopedThreadKey } from "@t3tools/client-runtime"; +import type { ScopedThreadRef } from "@t3tools/contracts"; +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; + +import { resolveStorage } from "./lib/storage"; + +export const RIGHT_PANEL_KINDS = ["plan", "diff", "preview"] as const; +export type RightPanelKind = (typeof RIGHT_PANEL_KINDS)[number]; + +const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v1"; + +interface ThreadRightPanelState { + active: RightPanelKind | null; +} + +interface RightPanelStoreState { + byThreadKey: Record; + open: (ref: ScopedThreadRef, kind: RightPanelKind) => void; + close: (ref: ScopedThreadRef) => void; + toggle: (ref: ScopedThreadRef, kind: RightPanelKind) => void; + removeThread: (ref: ScopedThreadRef) => void; +} + +const updateThread = ( + byThreadKey: Record, + threadKey: string, + next: ThreadRightPanelState, +): Record => { + const current = byThreadKey[threadKey]; + if (current && current.active === next.active) return byThreadKey; + if (next.active === null) { + if (!current) return byThreadKey; + const { [threadKey]: _removed, ...rest } = byThreadKey; + return rest; + } + return { ...byThreadKey, [threadKey]: next }; +}; + +export const useRightPanelStore = create()( + persist( + (set) => ({ + byThreadKey: {}, + open: (ref, kind) => + set((state) => ({ + byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), { active: kind }), + })), + close: (ref) => + set((state) => ({ + byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), { active: null }), + })), + toggle: (ref, kind) => + set((state) => { + const threadKey = scopedThreadKey(ref); + const current = state.byThreadKey[threadKey]?.active ?? null; + const next: RightPanelKind | null = current === kind ? null : kind; + return { + byThreadKey: updateThread(state.byThreadKey, threadKey, { active: next }), + }; + }), + removeThread: (ref) => + set((state) => { + const threadKey = scopedThreadKey(ref); + if (!(threadKey in state.byThreadKey)) return state; + const { [threadKey]: _removed, ...rest } = state.byThreadKey; + return { byThreadKey: rest }; + }), + }), + { + name: RIGHT_PANEL_STORAGE_KEY, + version: 1, + storage: createJSONStorage(() => + resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined), + ), + partialize: (state) => ({ byThreadKey: state.byThreadKey }), + }, + ), +); + +export function selectActiveRightPanel( + byThreadKey: Record, + ref: ScopedThreadRef | null | undefined, +): RightPanelKind | null { + if (!ref) return null; + return byThreadKey[scopedThreadKey(ref)]?.active ?? null; +} + +/** + * Resolves the active right panel taking the `?diff=1` URL truth into + * account. When `diff=1` the diff panel always wins. + * + * Prefer using `useSyncDiffSearchToRightPanel` (in ChatView) to mirror the + * URL into the store and consume `selectActiveRightPanel` directly — this + * helper exists for callers that don't want to install the sync effect. + */ +export function selectActiveRightPanelKindWithUrl( + byThreadKey: Record, + ref: ScopedThreadRef | null | undefined, + diffSearchActive: boolean, +): RightPanelKind | null { + if (diffSearchActive) return "diff"; + return selectActiveRightPanel(byThreadKey, ref); +} diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index 3ccb2e7f734dcc4eb0a90f69278343573c8cdf2c..f28c80f01280d0efe4f2ec5143f6cf9c1f764125 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -2,16 +2,21 @@ import { Outlet, createFileRoute, redirect } from "@tanstack/react-router"; import { useEffect } from "react"; import { useCommandPaletteStore } from "../commandPaletteStore"; +import { dispatchPreviewAction } from "../components/preview/previewActionBus"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { startNewLocalThreadFromContext, startNewThreadFromContext, } from "../lib/chatThreadActions"; +import { isPreviewFocused } from "../lib/previewFocus"; import { isTerminalFocused } from "../lib/terminalFocus"; import { resolveShortcutCommand } from "../keybindings"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; +import { isPreviewSupportedInRuntime } from "../previewStateStore"; +import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore"; import { useThreadSelectionStore } from "../threadSelectionStore"; import { resolveSidebarNewThreadEnvMode } from "~/components/Sidebar.logic"; +import { stackedThreadToast, toastManager } from "~/components/ui/toast"; import { useSettings } from "~/hooks/useSettings"; import { useServerKeybindings } from "~/rpc/serverState"; @@ -26,6 +31,14 @@ function ChatRouteGlobalShortcuts() { ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen : false, ); + // The `previewOpen` shortcut-context flag here uses the store-only value; + // the URL-aware arbitration lives inside ChatView's `onTogglePreview`, + // which we invoke via the action bus to avoid duplicating the rule. + const previewOpen = useRightPanelStore((state) => + routeThreadRef + ? selectActiveRightPanel(state.byThreadKey, routeThreadRef) === "preview" + : false, + ); const appSettings = useSettings(); useEffect(() => { @@ -35,6 +48,8 @@ function ChatRouteGlobalShortcuts() { context: { terminalFocus: isTerminalFocused(), terminalOpen, + previewFocus: isPreviewFocused(), + previewOpen, }, }); @@ -75,6 +90,50 @@ function ChatRouteGlobalShortcuts() { }), handleNewThread, }); + return; + } + + if (command === "preview.toggle") { + event.preventDefault(); + event.stopPropagation(); + if (!routeThreadRef) return; + if (!isPreviewSupportedInRuntime()) { + toastManager.add( + stackedThreadToast({ + type: "info", + title: "Preview is desktop-only", + description: "Open T3 Code in the desktop app to use the in-app preview.", + }), + ); + return; + } + dispatchPreviewAction("toggle-panel"); + return; + } + + // The remaining preview commands only fire when the panel is the + // currently-focused tenant. The `when: previewFocus` rule already + // gates this, but defend against the keybinding being misconfigured. + if ( + command === "preview.refresh" || + command === "preview.focusUrl" || + command === "preview.zoomIn" || + command === "preview.zoomOut" || + command === "preview.resetZoom" + ) { + event.preventDefault(); + event.stopPropagation(); + const action = + command === "preview.refresh" + ? "refresh" + : command === "preview.focusUrl" + ? "focus-url" + : command === "preview.zoomIn" + ? "zoom-in" + : command === "preview.zoomOut" + ? "zoom-out" + : "reset-zoom"; + dispatchPreviewAction(action); } }; @@ -89,6 +148,8 @@ function ChatRouteGlobalShortcuts() { handleNewThread, keybindings, defaultProjectRef, + previewOpen, + routeThreadRef, selectedThreadKeysSize, terminalOpen, appSettings.defaultThreadEnvMode, diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index 67652cc957db0713a31a2c25cbb1f6411e1da781..254aa92c6a05fc79d4c6f465177a1cc897cd93ce 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -23,6 +23,12 @@ See the full schema for more details: [`packages/contracts/src/keybindings.ts`]( { "key": "mod+d", "command": "terminal.split", "when": "terminalFocus" }, { "key": "mod+n", "command": "terminal.new", "when": "terminalFocus" }, { "key": "mod+w", "command": "terminal.close", "when": "terminalFocus" }, + { "key": "mod+shift+j", "command": "preview.toggle" }, + { "key": "mod+r", "command": "preview.refresh", "when": "previewFocus" }, + { "key": "mod+l", "command": "preview.focusUrl", "when": "previewFocus" }, + { "key": "mod+=", "command": "preview.zoomIn", "when": "previewFocus" }, + { "key": "mod+-", "command": "preview.zoomOut", "when": "previewFocus" }, + { "key": "mod+0", "command": "preview.resetZoom", "when": "previewFocus" }, { "key": "mod+k", "command": "commandPalette.toggle", "when": "!terminalFocus" }, { "key": "mod+n", "command": "chat.new", "when": "!terminalFocus" }, { "key": "mod+shift+o", "command": "chat.new", "when": "!terminalFocus" }, @@ -51,6 +57,12 @@ Invalid rules are ignored. Invalid config files are ignored. Warnings are logged - `terminal.split`: split terminal (in focused terminal context by default) - `terminal.new`: create new terminal (in focused terminal context by default) - `terminal.close`: close/kill the focused terminal (in focused terminal context by default) +- `preview.toggle`: open/close the in-app browser preview panel (desktop app only) +- `preview.refresh`: reload the active preview tab (in focused preview context by default) +- `preview.focusUrl`: focus the URL input of the preview panel (in focused preview context by default) +- `preview.zoomIn`: zoom the preview viewport in one step (in focused preview context by default) +- `preview.zoomOut`: zoom the preview viewport out one step (in focused preview context by default) +- `preview.resetZoom`: reset the preview zoom to 100% (in focused preview context by default) - `commandPalette.toggle`: open or close the global command palette - `chat.new`: create a new chat thread preserving the active thread's branch/worktree state - `chat.newLocal`: create a new chat thread for the active project in a new environment (local/worktree determined by app settings (default `local`)) @@ -80,6 +92,8 @@ Currently available context keys: - `terminalFocus` - `terminalOpen` +- `previewFocus` +- `previewOpen` Supported operators: diff --git a/packages/client-runtime/src/wsRpcClient.ts b/packages/client-runtime/src/wsRpcClient.ts index c1c683616b20a93e5eb06887d4317bc8787044d8..733c65ee13eb6972a5a8f2540557976a464a7bf7 100644 --- a/packages/client-runtime/src/wsRpcClient.ts +++ b/packages/client-runtime/src/wsRpcClient.ts @@ -79,6 +79,16 @@ export interface WsRpcClient { readonly onEvent: RpcStreamMethod; readonly onMetadata: RpcStreamMethod; }; + readonly preview: { + readonly open: RpcUnaryMethod; + readonly navigate: RpcUnaryMethod; + readonly refresh: RpcUnaryMethod; + readonly close: RpcUnaryMethod; + readonly list: RpcUnaryMethod; + readonly reportStatus: RpcUnaryMethod; + readonly onEvent: RpcStreamMethod; + readonly subscribePorts: RpcStreamMethod; + }; readonly projects: { readonly searchEntries: RpcUnaryMethod; readonly writeFile: RpcUnaryMethod; @@ -212,6 +222,27 @@ export function createWsRpcClient( subscriptionOptions(options, WS_METHODS.subscribeTerminalMetadata), ), }, + preview: { + open: (input) => transport.request((client) => client[WS_METHODS.previewOpen](input)), + navigate: (input) => transport.request((client) => client[WS_METHODS.previewNavigate](input)), + refresh: (input) => transport.request((client) => client[WS_METHODS.previewRefresh](input)), + close: (input) => transport.request((client) => client[WS_METHODS.previewClose](input)), + list: (input) => transport.request((client) => client[WS_METHODS.previewList](input)), + reportStatus: (input) => + transport.request((client) => client[WS_METHODS.previewReportStatus](input)), + onEvent: (listener, options) => + transport.subscribe( + (client) => client[WS_METHODS.subscribePreviewEvents]({}), + listener, + options, + ), + subscribePorts: (listener, options) => + transport.subscribe( + (client) => client[WS_METHODS.subscribeDiscoveredLocalServers]({}), + listener, + options, + ), + }, projects: { searchEntries: (input) => transport.request((client) => client[WS_METHODS.projectsSearchEntries](input)), diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 163d5e236cd99106c6c4dfe14787196dd9554f91..1cb46f6c79d9e41868cc7de1e79479904b1cc3d2 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -22,4 +22,5 @@ export * from "./editor.ts"; export * from "./project.ts"; export * from "./filesystem.ts"; export * from "./review.ts"; +export * from "./preview.ts"; export * from "./rpc.ts"; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 1d8656ddf4f662a263cdefc53bf96029c9607319..d003e868de4f5b9a732bbb71e9f6eed2233fc13b 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -54,6 +54,18 @@ import type { } from "./terminal.ts"; import type { ServerRemoveKeybindingInput, ServerUpsertKeybindingInput } from "./server.ts"; import * as Schema from "effect/Schema"; +import type { + DiscoveredLocalServerList, + PreviewCloseInput, + PreviewEvent, + PreviewListInput, + PreviewListResult, + PreviewNavigateInput, + PreviewOpenInput, + PreviewRefreshInput, + PreviewReportStatusInput, + PreviewSessionSnapshot, +} from "./preview.ts"; import type { ClientOrchestrationCommand, OrchestrationGetFullThreadDiffInput, @@ -402,6 +414,33 @@ export const DesktopCloudAuthFetchResultSchema = Schema.Struct({ }); export type DesktopCloudAuthFetchResult = typeof DesktopCloudAuthFetchResultSchema.Type; +/** + * Renderer-facing snapshot of a desktop preview tab. Mirrors the main-process + * PreviewTabState shape but uses serialisable primitives only. + */ +export type DesktopPreviewNavStatus = + | { kind: "Idle" } + | { kind: "Loading"; url: string; title: string } + | { kind: "Success"; url: string; title: string } + | { + kind: "LoadFailed"; + url: string; + title: string; + code: number; + description: string; + }; + +export interface DesktopPreviewTabState { + tabId: string; + webContentsId: number | null; + navStatus: DesktopPreviewNavStatus; + canGoBack: boolean; + canGoForward: boolean; + /** Current zoom factor (1.0 = 100%). */ + zoomFactor: number; + updatedAt: string; +} + export interface DesktopBridge { getAppBranding: () => DesktopAppBranding | null; getLocalEnvironmentBootstrap: () => DesktopEnvironmentBootstrap | null; @@ -460,6 +499,34 @@ export interface DesktopBridge { downloadUpdate: () => Promise; installUpdate: () => Promise; onUpdateState: (listener: (state: DesktopUpdateState) => void) => () => void; + /** + * Desktop-only preview surface. Present iff the renderer is hosted by the + * Electron desktop build; web builds have `preview === undefined`. + */ + preview?: DesktopPreviewBridge; +} + +export interface DesktopPreviewBridge { + createTab: (tabId: string) => Promise; + closeTab: (tabId: string) => Promise; + registerWebview: (tabId: string, webContentsId: number) => Promise; + navigate: (tabId: string, url: string) => Promise; + goBack: (tabId: string) => Promise; + goForward: (tabId: string) => Promise; + refresh: (tabId: string) => Promise; + zoomIn: (tabId: string) => Promise; + zoomOut: (tabId: string) => Promise; + resetZoom: (tabId: string) => Promise; + /** Reload bypassing the HTTP cache. */ + hardReload: (tabId: string) => Promise; + /** Open the guest webview's DevTools (detached). */ + openDevTools: (tabId: string) => Promise; + /** Drop cookies + storage data for the preview partition (all tabs). */ + clearCookies: () => Promise; + /** Drop the HTTP cache for the preview partition (all tabs). */ + clearCache: () => Promise; + getBrowserPartition: () => Promise; + onStateChange: (listener: (tabId: string, state: DesktopPreviewTabState) => void) => () => void; } /** @@ -619,4 +686,17 @@ export interface EnvironmentApi { }, ) => () => void; }; + preview: { + open: (input: typeof PreviewOpenInput.Encoded) => Promise; + navigate: (input: typeof PreviewNavigateInput.Encoded) => Promise; + refresh: (input: typeof PreviewRefreshInput.Encoded) => Promise; + close: (input: typeof PreviewCloseInput.Encoded) => Promise; + list: (input: typeof PreviewListInput.Encoded) => Promise; + reportStatus: (input: typeof PreviewReportStatusInput.Encoded) => Promise; + onEvent: (callback: (event: PreviewEvent) => void) => () => void; + subscribePorts: ( + callback: (servers: DiscoveredLocalServerList) => void, + options?: { onResubscribe?: () => void }, + ) => () => void; + }; } diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 303d5c71c9c3af429b6ed5cba5aa1885830ca1a1..4399d89d36833be7bee3cfdf679ebb203f42fad6 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -53,6 +53,12 @@ const STATIC_KEYBINDING_COMMANDS = [ "terminal.new", "terminal.close", "diff.toggle", + "preview.toggle", + "preview.refresh", + "preview.focusUrl", + "preview.zoomIn", + "preview.zoomOut", + "preview.resetZoom", "commandPalette.toggle", "chat.new", "chat.newLocal", diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 218d0de7437515faa8071f5bf6b58cbf28f01a1b..46d51da371f9d6875a851f45781f88f42deb6c32 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -194,6 +194,17 @@ export const ProjectScript = Schema.Struct({ command: TrimmedNonEmptyString, icon: ProjectScriptIcon, runOnWorktreeCreate: Schema.Boolean, + /** + * URL to open in the in-app browser preview when this script runs (or + * when the user explicitly requests a preview). Optional; only honored on + * the desktop build. + */ + previewUrl: Schema.optional(TrimmedNonEmptyString), + /** + * When true, automatically open the preview panel pointed at `previewUrl` + * the moment this script starts. Ignored without `previewUrl` or on web. + */ + autoOpenPreview: Schema.optional(Schema.Boolean), }); export type ProjectScript = typeof ProjectScript.Type; diff --git a/packages/contracts/src/preview.test.ts b/packages/contracts/src/preview.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..d1f108ab4e9fcc8a0f7e511475f6a68bafc7b538 --- /dev/null +++ b/packages/contracts/src/preview.test.ts @@ -0,0 +1,162 @@ +import { Schema } from "effect"; +import { describe, expect, it } from "vitest"; + +import { + DiscoveredLocalServer, + PreviewEvent, + PreviewNavStatus, + PreviewSessionSnapshot, +} from "./preview.ts"; + +const decodePreviewEvent = Schema.decodeUnknownSync(PreviewEvent); +const decodeSnapshot = Schema.decodeUnknownSync(PreviewSessionSnapshot); +const decodeNavStatus = Schema.decodeUnknownSync(PreviewNavStatus); +const decodeServer = Schema.decodeUnknownSync(DiscoveredLocalServer); + +describe("PreviewNavStatus", () => { + it("decodes Idle", () => { + expect(decodeNavStatus({ _tag: "Idle" })).toEqual({ _tag: "Idle" }); + }); + + it("decodes Loading with title", () => { + expect(decodeNavStatus({ _tag: "Loading", url: "http://localhost:5173/", title: "" })).toEqual({ + _tag: "Loading", + url: "http://localhost:5173/", + title: "", + }); + }); + + it("decodes LoadFailed with code/description", () => { + expect( + decodeNavStatus({ + _tag: "LoadFailed", + url: "https://example.com/", + title: "Example", + code: -105, + description: "ERR_NAME_NOT_RESOLVED", + }), + ).toEqual({ + _tag: "LoadFailed", + url: "https://example.com/", + title: "Example", + code: -105, + description: "ERR_NAME_NOT_RESOLVED", + }); + }); + + it("rejects empty url", () => { + expect(() => decodeNavStatus({ _tag: "Loading", url: "", title: "" })).toThrow(); + }); +}); + +describe("PreviewSessionSnapshot", () => { + it("round-trips a Success snapshot", () => { + const snapshot = decodeSnapshot({ + threadId: "thread-1", + tabId: "preview-thread-1", + navStatus: { + _tag: "Success", + url: "http://localhost:5173/", + title: "Vite App", + }, + canGoBack: false, + canGoForward: false, + updatedAt: "2026-01-01T00:00:00.000Z", + }); + expect(snapshot.tabId).toBe("preview-thread-1"); + expect(snapshot.navStatus._tag).toBe("Success"); + }); +}); + +describe("PreviewEvent", () => { + it("decodes opened", () => { + const event = decodePreviewEvent({ + type: "opened", + threadId: "t", + tabId: "preview-t", + createdAt: "2026-01-01T00:00:00.000Z", + snapshot: { + threadId: "t", + tabId: "preview-t", + navStatus: { _tag: "Idle" }, + canGoBack: false, + canGoForward: false, + updatedAt: "2026-01-01T00:00:00.000Z", + }, + }); + expect(event.type).toBe("opened"); + }); + + it("decodes failed with code/description", () => { + const event = decodePreviewEvent({ + type: "failed", + threadId: "t", + tabId: "preview-t", + createdAt: "2026-01-01T00:00:00.000Z", + url: "https://example.com/", + title: "", + code: -105, + description: "ERR_NAME_NOT_RESOLVED", + }); + expect(event.type).toBe("failed"); + if (event.type === "failed") { + expect(event.code).toBe(-105); + } + }); + + it("decodes closed without snapshot", () => { + const event = decodePreviewEvent({ + type: "closed", + threadId: "t", + tabId: "preview-t", + createdAt: "2026-01-01T00:00:00.000Z", + }); + expect(event.type).toBe("closed"); + }); +}); + +describe("DiscoveredLocalServer", () => { + it("decodes a server with process metadata", () => { + const server = decodeServer({ + host: "localhost", + port: 5173, + url: "http://localhost:5173", + processName: "node", + pid: 12345, + }); + expect(server.port).toBe(5173); + expect(server.processName).toBe("node"); + }); + + it("decodes a server without process metadata", () => { + const server = decodeServer({ + host: "localhost", + port: 3000, + url: "http://localhost:3000", + processName: null, + pid: null, + }); + expect(server.processName).toBeNull(); + }); + + it("rejects invalid ports", () => { + expect(() => + decodeServer({ + host: "localhost", + port: 0, + url: "http://localhost:0", + processName: null, + pid: null, + }), + ).toThrow(); + expect(() => + decodeServer({ + host: "localhost", + port: 70000, + url: "http://localhost:70000", + processName: null, + pid: null, + }), + ).toThrow(); + }); +}); diff --git a/packages/contracts/src/preview.ts b/packages/contracts/src/preview.ts new file mode 100644 index 0000000000000000000000000000000000000000..f9ef049c386d04e48638e1283b9a4df32acbf72c --- /dev/null +++ b/packages/contracts/src/preview.ts @@ -0,0 +1,181 @@ +/** + * Preview - Schemas for the in-app browser preview surface. + * + * The preview is desktop-only (Chromium ); the server tracks per-thread + * tab metadata so it survives client reconnects and multi-window. The desktop + * renderer mediates: it owns the actual and reports navigation back to + * the server via these RPCs, the server fans events to all subscribers. + * + * @module Preview + */ +import { Schema } from "effect"; +import { ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; + +const Url = TrimmedNonEmptyString.check(Schema.isMaxLength(2048)); +const Title = Schema.String.check(Schema.isMaxLength(512)); + +export const PreviewTabId = TrimmedNonEmptyString.check(Schema.isMaxLength(128)); +export type PreviewTabId = typeof PreviewTabId.Type; + +export const PreviewNavStatus = Schema.Union([ + Schema.TaggedStruct("Idle", {}), + Schema.TaggedStruct("Loading", { + url: Url, + title: Title, + }), + Schema.TaggedStruct("Success", { + url: Url, + title: Title, + }), + Schema.TaggedStruct("LoadFailed", { + url: Url, + title: Title, + code: Schema.Int, + description: Schema.String, + }), +]); +export type PreviewNavStatus = typeof PreviewNavStatus.Type; + +export const PreviewSessionSnapshot = Schema.Struct({ + threadId: TrimmedNonEmptyString, + tabId: PreviewTabId, + navStatus: PreviewNavStatus, + canGoBack: Schema.Boolean, + canGoForward: Schema.Boolean, + updatedAt: Schema.String, +}); +export type PreviewSessionSnapshot = typeof PreviewSessionSnapshot.Type; + +export const PreviewOpenInput = Schema.Struct({ + threadId: ThreadId, + /** Omit to create an empty (Idle) tab the user can type into. */ + url: Schema.optional(Url), +}); +export type PreviewOpenInput = typeof PreviewOpenInput.Type; + +export const PreviewNavigateInput = Schema.Struct({ + threadId: ThreadId, + tabId: PreviewTabId, + url: Url, + resolvedTitle: Schema.optional(Title), +}); +export type PreviewNavigateInput = typeof PreviewNavigateInput.Type; + +export const PreviewReportStatusInput = Schema.Struct({ + threadId: ThreadId, + tabId: PreviewTabId, + navStatus: PreviewNavStatus, + canGoBack: Schema.Boolean, + canGoForward: Schema.Boolean, +}); +export type PreviewReportStatusInput = typeof PreviewReportStatusInput.Type; + +export const PreviewRefreshInput = Schema.Struct({ + threadId: ThreadId, + tabId: PreviewTabId, +}); +export type PreviewRefreshInput = typeof PreviewRefreshInput.Type; + +export const PreviewCloseInput = Schema.Struct({ + threadId: ThreadId, + tabId: Schema.optional(PreviewTabId), +}); +export type PreviewCloseInput = typeof PreviewCloseInput.Type; + +export const PreviewListInput = Schema.Struct({ + threadId: ThreadId, +}); +export type PreviewListInput = typeof PreviewListInput.Type; + +export const PreviewListResult = Schema.Struct({ + sessions: Schema.Array(PreviewSessionSnapshot), +}); +export type PreviewListResult = typeof PreviewListResult.Type; + +const PreviewEventBaseSchema = Schema.Struct({ + threadId: TrimmedNonEmptyString, + tabId: PreviewTabId, + createdAt: Schema.String, +}); + +const PreviewOpenedEvent = Schema.Struct({ + ...PreviewEventBaseSchema.fields, + type: Schema.Literal("opened"), + snapshot: PreviewSessionSnapshot, +}); + +const PreviewNavigatedEvent = Schema.Struct({ + ...PreviewEventBaseSchema.fields, + type: Schema.Literal("navigated"), + snapshot: PreviewSessionSnapshot, +}); + +const PreviewFailedEvent = Schema.Struct({ + ...PreviewEventBaseSchema.fields, + type: Schema.Literal("failed"), + url: Url, + title: Title, + code: Schema.Int, + description: Schema.String, +}); + +const PreviewClosedEvent = Schema.Struct({ + ...PreviewEventBaseSchema.fields, + type: Schema.Literal("closed"), +}); + +export const PreviewEvent = Schema.Union([ + PreviewOpenedEvent, + PreviewNavigatedEvent, + PreviewFailedEvent, + PreviewClosedEvent, +]); +export type PreviewEvent = typeof PreviewEvent.Type; + +/** + * A localhost server detected by the port scanner. Used to populate the + * "Local" recommendations in the empty-state of the preview panel. + */ +export const DiscoveredLocalServer = Schema.Struct({ + host: TrimmedNonEmptyString, + port: Schema.Int.check(Schema.isGreaterThan(0)).check(Schema.isLessThan(65536)), + url: Url, + processName: Schema.NullOr(TrimmedNonEmptyString), + pid: Schema.NullOr(Schema.Int.check(Schema.isGreaterThan(0))), +}); +export type DiscoveredLocalServer = typeof DiscoveredLocalServer.Type; + +export const DiscoveredLocalServerList = Schema.Struct({ + servers: Schema.Array(DiscoveredLocalServer), + scannedAt: Schema.String, +}); +export type DiscoveredLocalServerList = typeof DiscoveredLocalServerList.Type; + +export class PreviewSessionLookupError extends Schema.TaggedErrorClass()( + "PreviewSessionLookupError", + { + threadId: Schema.String, + tabId: Schema.String, + }, +) { + override get message() { + return `Unknown preview session: thread=${this.threadId}, tab=${this.tabId}`; + } +} + +export class PreviewInvalidUrlError extends Schema.TaggedErrorClass()( + "PreviewInvalidUrlError", + { + rawUrl: Schema.String, + detail: Schema.optional(Schema.String), + }, +) { + override get message() { + return this.detail + ? `Invalid preview URL: ${this.rawUrl} (${this.detail})` + : `Invalid preview URL: ${this.rawUrl}`; + } +} + +export const PreviewError = Schema.Union([PreviewSessionLookupError, PreviewInvalidUrlError]); +export type PreviewError = typeof PreviewError.Type; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 5a145f3f657b3ebd2f6b2b339038161f7e9ae13b..e8cba6ebe78e8375263e9251859ab6b15280ee75 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -85,6 +85,19 @@ import { TerminalSessionSnapshot, TerminalWriteInput, } from "./terminal.ts"; +import { + DiscoveredLocalServerList, + PreviewCloseInput, + PreviewError, + PreviewEvent, + PreviewListInput, + PreviewListResult, + PreviewNavigateInput, + PreviewOpenInput, + PreviewRefreshInput, + PreviewReportStatusInput, + PreviewSessionSnapshot, +} from "./preview.ts"; import { ServerConfigStreamEvent, ServerConfig, @@ -157,6 +170,14 @@ export const WS_METHODS = { terminalRestart: "terminal.restart", terminalClose: "terminal.close", + // Preview methods + previewOpen: "preview.open", + previewNavigate: "preview.navigate", + previewRefresh: "preview.refresh", + previewClose: "preview.close", + previewList: "preview.list", + previewReportStatus: "preview.reportStatus", + // Server meta serverGetConfig: "server.getConfig", serverRefreshProviders: "server.refreshProviders", @@ -184,6 +205,8 @@ export const WS_METHODS = { subscribeVcsStatus: "subscribeVcsStatus", subscribeTerminalEvents: "subscribeTerminalEvents", subscribeTerminalMetadata: "subscribeTerminalMetadata", + subscribePreviewEvents: "subscribePreviewEvents", + subscribeDiscoveredLocalServers: "subscribeDiscoveredLocalServers", subscribeServerConfig: "subscribeServerConfig", subscribeServerLifecycle: "subscribeServerLifecycle", subscribeAuthAccess: "subscribeAuthAccess", @@ -454,6 +477,53 @@ export const WsTerminalCloseRpc = Rpc.make(WS_METHODS.terminalClose, { error: Schema.Union([TerminalError, EnvironmentAuthorizationError]), }); +export const WsPreviewOpenRpc = Rpc.make(WS_METHODS.previewOpen, { + payload: PreviewOpenInput, + success: PreviewSessionSnapshot, + error: PreviewError, +}); + +export const WsPreviewNavigateRpc = Rpc.make(WS_METHODS.previewNavigate, { + payload: PreviewNavigateInput, + success: PreviewSessionSnapshot, + error: PreviewError, +}); + +export const WsPreviewRefreshRpc = Rpc.make(WS_METHODS.previewRefresh, { + payload: PreviewRefreshInput, + error: PreviewError, +}); + +export const WsPreviewCloseRpc = Rpc.make(WS_METHODS.previewClose, { + payload: PreviewCloseInput, + error: PreviewError, +}); + +export const WsPreviewListRpc = Rpc.make(WS_METHODS.previewList, { + payload: PreviewListInput, + success: PreviewListResult, +}); + +export const WsPreviewReportStatusRpc = Rpc.make(WS_METHODS.previewReportStatus, { + payload: PreviewReportStatusInput, + error: PreviewError, +}); + +export const WsSubscribePreviewEventsRpc = Rpc.make(WS_METHODS.subscribePreviewEvents, { + payload: Schema.Struct({}), + success: PreviewEvent, + stream: true, +}); + +export const WsSubscribeDiscoveredLocalServersRpc = Rpc.make( + WS_METHODS.subscribeDiscoveredLocalServers, + { + payload: Schema.Struct({}), + success: DiscoveredLocalServerList, + stream: true, + }, +); + export const WsOrchestrationDispatchCommandRpc = Rpc.make( ORCHESTRATION_WS_METHODS.dispatchCommand, { @@ -589,6 +659,14 @@ export const WsRpcGroup = RpcGroup.make( WsTerminalCloseRpc, WsSubscribeTerminalEventsRpc, WsSubscribeTerminalMetadataRpc, + WsPreviewOpenRpc, + WsPreviewNavigateRpc, + WsPreviewRefreshRpc, + WsPreviewCloseRpc, + WsPreviewListRpc, + WsPreviewReportStatusRpc, + WsSubscribePreviewEventsRpc, + WsSubscribeDiscoveredLocalServersRpc, WsSubscribeServerConfigRpc, WsSubscribeServerLifecycleRpc, WsSubscribeAuthAccessRpc, diff --git a/packages/shared/package.json b/packages/shared/package.json index 97af1fa58404103ee124a280e92570e1db85f4f1..791e66951bd637079155d33fdffb6263fd4fa944 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -150,6 +150,10 @@ "./relayClient": { "types": "./src/relayClient.ts", "import": "./src/relayClient.ts" + }, + "./preview": { + "types": "./src/preview.ts", + "import": "./src/preview.ts" } }, "scripts": { diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index 3cc2e913621fb8eb7701f3e9ddde17b9cd872378..ca4942cf9fd5d07bf22baecf96eae9fc12ef52b9 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -24,6 +24,13 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+n", command: "terminal.new", when: "terminalFocus" }, { key: "mod+w", command: "terminal.close", when: "terminalFocus" }, { key: "mod+d", command: "diff.toggle", when: "!terminalFocus" }, + { key: "mod+shift+j", command: "preview.toggle" }, + { key: "mod+r", command: "preview.refresh", when: "previewFocus" }, + { key: "mod+l", command: "preview.focusUrl", when: "previewFocus" }, + { key: "mod+=", command: "preview.zoomIn", when: "previewFocus" }, + { key: "mod++", command: "preview.zoomIn", when: "previewFocus" }, + { key: "mod+-", command: "preview.zoomOut", when: "previewFocus" }, + { key: "mod+0", command: "preview.resetZoom", when: "previewFocus" }, { key: "mod+k", command: "commandPalette.toggle", when: "!terminalFocus" }, { key: "mod+n", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, diff --git a/packages/shared/src/preview.test.ts b/packages/shared/src/preview.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..e9b7217247781e8a03e39ba189bc507d6f8e186b --- /dev/null +++ b/packages/shared/src/preview.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; + +import { + isLoopbackHost, + isPreviewableUrl, + newPreviewTabId, + normalizePreviewUrl, + PreviewUrlNormalizationError, +} from "./preview.ts"; + +describe("newPreviewTabId", () => { + it("returns a unique tab id every call", () => { + const a = newPreviewTabId(); + const b = newPreviewTabId(); + expect(a).not.toBe(b); + expect(a.startsWith("tab_")).toBe(true); + }); +}); + +describe("isLoopbackHost", () => { + it.each(["localhost", "127.0.0.1", "0.0.0.0", "::1", "[::1]"])("%s is loopback", (host) => { + expect(isLoopbackHost(host)).toBe(true); + }); + + it.each(["example.com", "192.168.1.10", "10.0.0.1", ""])("%s is not loopback", (host) => { + expect(isLoopbackHost(host)).toBe(false); + }); +}); + +describe("isPreviewableUrl", () => { + it.each([ + "http://localhost:5173", + "http://127.0.0.1:3000/path", + "http://0.0.0.0:8080", + "http://[::1]:5173", + ])("%s is previewable", (url) => { + expect(isPreviewableUrl(url)).toBe(true); + }); + + it.each(["https://example.com", "ws://localhost:5173", "file:///etc/passwd", "not-a-url", ""])( + "%s is not previewable", + (url) => { + expect(isPreviewableUrl(url)).toBe(false); + }, + ); +}); + +describe("normalizePreviewUrl", () => { + it("treats bare loopback hosts as http", () => { + expect(normalizePreviewUrl("localhost:5173")).toBe("http://localhost:5173/"); + expect(normalizePreviewUrl("127.0.0.1:3000")).toBe("http://127.0.0.1:3000/"); + }); + + it("treats bare public hosts as https", () => { + expect(normalizePreviewUrl("example.com")).toBe("https://example.com/"); + }); + + it("respects explicit schemes", () => { + expect(normalizePreviewUrl("https://localhost:5173")).toBe("https://localhost:5173/"); + expect(normalizePreviewUrl("http://example.com/path?q=1")).toBe("http://example.com/path?q=1"); + }); + + it("rejects empty input", () => { + expect(() => normalizePreviewUrl(" ")).toThrow(PreviewUrlNormalizationError); + }); + + it("rejects unsupported protocols", () => { + expect(() => normalizePreviewUrl("ftp://example.com")).toThrow(PreviewUrlNormalizationError); + expect(() => normalizePreviewUrl("file:///etc/passwd")).toThrow(PreviewUrlNormalizationError); + }); + + it("rejects unparseable junk", () => { + expect(() => normalizePreviewUrl("http://")).toThrow(PreviewUrlNormalizationError); + }); +}); diff --git a/packages/shared/src/preview.ts b/packages/shared/src/preview.ts new file mode 100644 index 0000000000000000000000000000000000000000..963023f0ee3f37c8e02193195fe99e0b9dbf35ef --- /dev/null +++ b/packages/shared/src/preview.ts @@ -0,0 +1,92 @@ +/** + * Pure URL helpers shared between the preview server, desktop main process, + * and web renderer. Centralising these guarantees the four call sites agree + * on what counts as "loopback" and how to normalise a free-form URL string. + */ + +const TAB_ID_PREFIX = "tab_"; + +/** + * Generate a fresh preview tab id. Lives in shared (not contracts) because + * the contracts package is schema-only — runtime helpers belong here. + */ +export function newPreviewTabId(): string { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return `${TAB_ID_PREFIX}${crypto.randomUUID()}`; + } + return `${TAB_ID_PREFIX}${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`; +} + +const LOOPBACK_HOSTS: ReadonlySet = new Set(["localhost", "127.0.0.1", "0.0.0.0", "::1"]); + +/** Internal — used by `lsof` parsing where the host string is wire-formatted. */ +export const LSOF_LOCAL_HOST_TOKENS: ReadonlySet = new Set([ + ...LOOPBACK_HOSTS, + "*", + "[::]", + "[::1]", +]); + +const LOOPBACK_PREFIX_PATTERN = /^(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1?\])(?::|\/|$)/i; + +export function isLoopbackHost(host: string): boolean { + if (LOOPBACK_HOSTS.has(host)) return true; + if (host === "[::1]") return true; + return false; +} + +/** True when a raw URL string looks like a loopback dev URL we can preview. */ +export function isPreviewableUrl(rawUrl: string): boolean { + try { + const parsed = new URL(rawUrl); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false; + return isLoopbackHost(parsed.hostname); + } catch { + return false; + } +} + +export class PreviewUrlNormalizationError extends Error { + readonly rawUrl: string; + readonly detail: string; + constructor(rawUrl: string, detail: string) { + super(`Invalid preview URL: ${rawUrl} (${detail})`); + this.name = "PreviewUrlNormalizationError"; + this.rawUrl = rawUrl; + this.detail = detail; + } +} + +/** + * Normalise a free-form URL string into a fully-qualified `http(s)://` URL. + * + * - Bare loopback hosts (`localhost`, `localhost:5173`) become `http://...`. + * - Bare public hosts (`example.com`) become `https://...`. + * - Already-qualified URLs are validated and returned as `URL.href`. + * + * Throws `PreviewUrlNormalizationError` for empty, unparseable, or + * unsupported-protocol inputs. + */ +export function normalizePreviewUrl(rawUrl: string): string { + const trimmed = rawUrl.trim(); + if (trimmed.length === 0) { + throw new PreviewUrlNormalizationError(rawUrl, "empty"); + } + const useHttp = LOOPBACK_PREFIX_PATTERN.test(trimmed); + const candidate = trimmed.includes("://") + ? trimmed + : `${useHttp ? "http" : "https"}://${trimmed}`; + let parsed: URL; + try { + parsed = new URL(candidate); + } catch (cause) { + throw new PreviewUrlNormalizationError( + rawUrl, + cause instanceof Error ? cause.message : "unparseable", + ); + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new PreviewUrlNormalizationError(rawUrl, `unsupported protocol ${parsed.protocol}`); + } + return parsed.href; +} -- 2.54.0