authorgravatar for aiden.bai05@gmail.comAiden Bai <aiden.bai05@gmail.com> 2026-05-03 05:16:10-07:00
committergravatar for me@theo.liTheo Browne <me@theo.li> 2026-06-13 22:18:37-07:00
log52c77c1ec4c1c2c2c27b917baaec91d050d0a737
treee00bf772b3631077978c118c0dc39d6091741791
parentd6eb386e3d3a1cb114f7e00ab42c5b8e6b63c181

feat(preview): in-app browser preview panel

Adds a desktop-only browser preview that lives in the right panel slot alongside plan/diff. Lets the user point an Electron <webview> 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<PreviewEvent>; 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.

68 files changed, 5622 insertions(+), 229 deletions(-)

app-preview-browser-plan.md+222-198
......@@ -143,7 +143,7 @@ export function selectActiveRightPanel(
143143- Remove the local `planSidebarOpen` state (`:688`).
144144- Replace `setPlanSidebarOpen(true)` callsites with `rightPanelStore.open(activeThreadRef, "plan")`.
145145- Replace `closePlanSidebar` with `rightPanelStore.close(activeThreadRef)`.
146- Existing `planSidebarDismissedForTurnRef`/`planSidebarOpenOnNextThreadRef` logic stays local — it only governs whether to *call* `open()`/`close()` on turn change.
146- Existing `planSidebarDismissedForTurnRef`/`planSidebarOpenOnNextThreadRef` logic stays local — it only governs whether to _call_ `open()`/`close()` on turn change.
147147
148148### Render arbitration
149149
......@@ -176,49 +176,49 @@ const useSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY);
176176
177177### NEW files
178178
179| File | Purpose |
180|---|---|
181| `packages/contracts/src/preview.ts` | Effect/Schema schemas: inputs, snapshot, events, errors |
182| `packages/contracts/src/preview.test.ts` | Schema round‑trip tests |
183| `apps/server/src/preview/Services/Manager.ts` | `PreviewManager` Service tag + interface |
184| `apps/server/src/preview/Layers/Manager.ts` | Implementation: in‑memory map + event subject |
185| `apps/server/src/preview/Layers/Manager.test.ts` | Lifecycle, snapshot, event ordering |
186| `apps/desktop/src/preview-view-manager.ts` | Plain‑Node Electron port of ami's BrowserManager (subset) |
187| `apps/desktop/src/preview-preload.ts` | Webview preload (no‑op v1) |
188| `apps/web/src/previewStateStore.ts` | Per‑thread zustand store (mirrors `terminalStateStore.ts`) |
189| `apps/web/src/previewStateStore.test.ts` | Reducer tests |
190| `apps/web/src/rightPanelStore.ts` | Right‑panel arbiter |
191| `apps/web/src/rightPanelStore.test.ts` | Arbiter tests |
192| `apps/web/src/components/preview/PreviewPanel.tsx` | Right‑panel entry (wraps `PreviewPanelShell`) |
193| `apps/web/src/components/preview/PreviewPanelShell.tsx` | Shell mirroring `DiffPanelShell` (`mode: "inline"\|"sheet"\|"sidebar"`) |
194| `apps/web/src/components/preview/PreviewView.tsx` | Chrome bar (URL/back/fwd/refresh) + `<PreviewWebview>` |
195| `apps/web/src/components/preview/PreviewWebview.tsx` | Electron `<webview>` host; null on web build |
196| `apps/web/src/components/preview/PreviewEmptyState.tsx` | Pre‑URL empty state |
197| `apps/web/src/components/preview/PreviewUnsupportedToast.ts` | `"Preview is only available in the desktop app"` toast |
179| File | Purpose |
180| ------------------------------------------------------------ | ----------------------------------------------------------------------- |
181| `packages/contracts/src/preview.ts` | Effect/Schema schemas: inputs, snapshot, events, errors |
182| `packages/contracts/src/preview.test.ts` | Schema round‑trip tests |
183| `apps/server/src/preview/Services/Manager.ts` | `PreviewManager` Service tag + interface |
184| `apps/server/src/preview/Layers/Manager.ts` | Implementation: in‑memory map + event subject |
185| `apps/server/src/preview/Layers/Manager.test.ts` | Lifecycle, snapshot, event ordering |
186| `apps/desktop/src/preview-view-manager.ts` | Plain‑Node Electron port of ami's BrowserManager (subset) |
187| `apps/desktop/src/preview-preload.ts` | Webview preload (no‑op v1) |
188| `apps/web/src/previewStateStore.ts` | Per‑thread zustand store (mirrors `terminalStateStore.ts`) |
189| `apps/web/src/previewStateStore.test.ts` | Reducer tests |
190| `apps/web/src/rightPanelStore.ts` | Right‑panel arbiter |
191| `apps/web/src/rightPanelStore.test.ts` | Arbiter tests |
192| `apps/web/src/components/preview/PreviewPanel.tsx` | Right‑panel entry (wraps `PreviewPanelShell`) |
193| `apps/web/src/components/preview/PreviewPanelShell.tsx` | Shell mirroring `DiffPanelShell` (`mode: "inline"\|"sheet"\|"sidebar"`) |
194| `apps/web/src/components/preview/PreviewView.tsx` | Chrome bar (URL/back/fwd/refresh) + `<PreviewWebview>` |
195| `apps/web/src/components/preview/PreviewWebview.tsx` | Electron `<webview>` host; null on web build |
196| `apps/web/src/components/preview/PreviewEmptyState.tsx` | Pre‑URL empty state |
197| `apps/web/src/components/preview/PreviewUnsupportedToast.ts` | `"Preview is only available in the desktop app"` toast |
198198
199199### MODIFIED files
200200
201| File | Change |
202|---|---|
203| `packages/contracts/src/keybindings.ts` | Add `preview.toggle`, `preview.refresh`, `preview.focusUrl` to `STATIC_KEYBINDING_COMMANDS`; add `previewFocus`, `previewOpen` to context keys |
204| `packages/contracts/src/project.ts` | Add `previewUrl?: string` and `autoOpenPreview?: boolean` to `ProjectScript` schema |
205| `packages/contracts/src/server.ts` | Extend `EnvironmentApi` with `preview` namespace |
206| `packages/contracts/src/index.ts` | Re‑export new types |
207| `apps/server/src/keybindings.ts` | Add defaults: `mod+shift+j` → `preview.toggle`, `mod+shift+r` → `preview.refresh` (when `previewFocus`) |
208| `apps/server/src/ws.ts` | Route `preview.open`, `preview.navigate`, `preview.refresh`, `preview.close`, `preview.list`, `preview.onEvent` |
209| `apps/server/src/orchestration/runtimeLayer.ts` (or equivalent) | Provide `PreviewManager.Default` |
210| `apps/web/src/environmentApi.ts` | Wire `preview` slot in `createEnvironmentApi` |
211| `apps/web/src/keybindings.ts` | Add `isPreviewToggleShortcut`, `isPreviewRefreshShortcut` helpers |
212| `apps/web/src/routes/_chat.tsx` | Handle `preview.toggle` in global shortcut handler |
213| `apps/web/src/components/ChatView.tsx` | Replace local `planSidebarOpen` with `rightPanelStore`; render `PreviewPanel` |
214| `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 |
215| `apps/web/src/components/ProjectScriptsControl.tsx` | Add `previewUrl` + `autoOpenPreview` form fields in the Add/Edit dialog |
216| `apps/web/src/projectScripts.ts` | Carry the new fields through `commandForProjectScript` / serialization |
217| `apps/web/src/types.ts` | Add `PreviewSession` mirror types (or re‑export from contracts) |
218| `apps/web/src/lib/desktopBridge.d.ts` (or wherever bridge types live) | Add `preview` namespace shape |
219| `apps/desktop/src/main.ts` | Register `preview:*` IPC handlers; instantiate `previewViewManager`; wire `mainWindow` injection |
220| `apps/desktop/src/preload.ts` | Expose `desktopBridge.preview.*` |
221| `KEYBINDINGS.md` | Document new commands and `previewFocus`/`previewOpen` `when` keys |
201| File | Change |
202| --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
203| `packages/contracts/src/keybindings.ts` | Add `preview.toggle`, `preview.refresh`, `preview.focusUrl` to `STATIC_KEYBINDING_COMMANDS`; add `previewFocus`, `previewOpen` to context keys |
204| `packages/contracts/src/project.ts` | Add `previewUrl?: string` and `autoOpenPreview?: boolean` to `ProjectScript` schema |
205| `packages/contracts/src/server.ts` | Extend `EnvironmentApi` with `preview` namespace |
206| `packages/contracts/src/index.ts` | Re‑export new types |
207| `apps/server/src/keybindings.ts` | Add defaults: `mod+shift+j` → `preview.toggle`, `mod+shift+r` → `preview.refresh` (when `previewFocus`) |
208| `apps/server/src/ws.ts` | Route `preview.open`, `preview.navigate`, `preview.refresh`, `preview.close`, `preview.list`, `preview.onEvent` |
209| `apps/server/src/orchestration/runtimeLayer.ts` (or equivalent) | Provide `PreviewManager.Default` |
210| `apps/web/src/environmentApi.ts` | Wire `preview` slot in `createEnvironmentApi` |
211| `apps/web/src/keybindings.ts` | Add `isPreviewToggleShortcut`, `isPreviewRefreshShortcut` helpers |
212| `apps/web/src/routes/_chat.tsx` | Handle `preview.toggle` in global shortcut handler |
213| `apps/web/src/components/ChatView.tsx` | Replace local `planSidebarOpen` with `rightPanelStore`; render `PreviewPanel` |
214| `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 |
215| `apps/web/src/components/ProjectScriptsControl.tsx` | Add `previewUrl` + `autoOpenPreview` form fields in the Add/Edit dialog |
216| `apps/web/src/projectScripts.ts` | Carry the new fields through `commandForProjectScript` / serialization |
217| `apps/web/src/types.ts` | Add `PreviewSession` mirror types (or re‑export from contracts) |
218| `apps/web/src/lib/desktopBridge.d.ts` (or wherever bridge types live) | Add `preview` namespace shape |
219| `apps/desktop/src/main.ts` | Register `preview:*` IPC handlers; instantiate `previewViewManager`; wire `mainWindow` injection |
220| `apps/desktop/src/preload.ts` | Expose `desktopBridge.preview.*` |
221| `KEYBINDINGS.md` | Document new commands and `previewFocus`/`previewOpen` `when` keys |
222222
223223### NOT changed
224224
......@@ -335,10 +335,7 @@ export class PreviewInvalidUrlError extends Schema.TaggedErrorClass<PreviewInval
335335 }
336336}
337337
338export const PreviewError = Schema.Union([
339 PreviewSessionLookupError,
340 PreviewInvalidUrlError,
341]);
338export const PreviewError = Schema.Union([PreviewSessionLookupError, PreviewInvalidUrlError]);
342339export type PreviewError = typeof PreviewError.Type;
343340```
344341
......@@ -360,9 +357,9 @@ const STATIC_KEYBINDING_COMMANDS = [
360357 "terminal.new",
361358 "terminal.close",
362359 "diff.toggle",
363 "preview.toggle", // NEW
364 "preview.refresh", // NEW
365 "preview.focusUrl", // NEW
360 "preview.toggle", // NEW
361 "preview.refresh", // NEW
362 "preview.focusUrl", // NEW
366363 "commandPalette.toggle",
367364 "chat.new",
368365 "chat.newLocal",
......@@ -399,28 +396,21 @@ import {
399396} from "@t3tools/contracts";
400397
401398export interface PreviewManagerShape {
402 readonly open: (
403 input: PreviewOpenInput,
404 ) => Effect.Effect<PreviewSessionSnapshot, PreviewError>;
399 readonly open: (input: PreviewOpenInput) => Effect.Effect<PreviewSessionSnapshot, PreviewError>;
405400 readonly navigate: (
406401 input: PreviewNavigateInput,
407402 ) => Effect.Effect<PreviewSessionSnapshot, PreviewError>;
408 readonly refresh: (
409 input: PreviewRefreshInput,
410 ) => Effect.Effect<void, PreviewError>;
403 readonly refresh: (input: PreviewRefreshInput) => Effect.Effect<void, PreviewError>;
411404 readonly close: (input: PreviewCloseInput) => Effect.Effect<void, PreviewError>;
412 readonly list: (
413 threadId: string,
414 ) => Effect.Effect<ReadonlyArray<PreviewSessionSnapshot>>;
405 readonly list: (threadId: string) => Effect.Effect<ReadonlyArray<PreviewSessionSnapshot>>;
415406 readonly subscribe: (
416407 listener: (event: PreviewEvent) => Effect.Effect<void>,
417408 ) => Effect.Effect<() => void>;
418409}
419410
420export class PreviewManager extends Context.Service<
421 PreviewManager,
422 PreviewManagerShape
423>()("t3/preview/Services/Manager/PreviewManager") {}
411export class PreviewManager extends Context.Service<PreviewManager, PreviewManagerShape>()(
412 "t3/preview/Services/Manager/PreviewManager",
413) {}
424414```
425415
426416### `apps/server/src/preview/Layers/Manager.ts`
......@@ -435,15 +425,12 @@ const normalizeUrl = (input: string) =>
435425 const trimmed = input.trim();
436426 if (!trimmed) throw new Error("empty");
437427 // localhost stays http unless explicitly https
438 const useHttp =
439 /^(localhost|127\.0\.0\.1|0\.0\.0\.0)(:|\/|$)/.test(trimmed);
428 const useHttp = /^(localhost|127\.0\.0\.1|0\.0\.0\.0)(:|\/|$)/.test(trimmed);
440429 const parsed = urlParseLax(trimmed, { https: !useHttp });
441430 if (!parsed?.href) throw new Error("unparseable");
442431 return parsed.href;
443432 }).pipe(
444 Effect.catchAll((cause) =>
445 Effect.fail(new PreviewInvalidUrlError({ rawUrl: input, cause })),
446 ),
433 Effect.catchAll((cause) => Effect.fail(new PreviewInvalidUrlError({ rawUrl: input, cause }))),
447434 );
448435```
449436
......@@ -513,14 +500,19 @@ export class PreviewViewManager {
513500 this.mainWindow = window;
514501 }
515502
516 getPreloadPath(): string { return this.preloadPath; }
517 getBrowserPartition(): string { return PREVIEW_PARTITION; }
503 getPreloadPath(): string {
504 return this.preloadPath;
505 }
506 getBrowserPartition(): string {
507 return PREVIEW_PARTITION;
508 }
518509
519510 getBrowserSession(): Session {
520511 if (this.browserSession) return this.browserSession;
521512 const sess = session.fromPartition(PREVIEW_PARTITION);
522513 // strip electron/t3code from UA so dev preview doesn't trip bot detection
523 const ua = sess.getUserAgent()
514 const ua = sess
515 .getUserAgent()
524516 .replace(/Electron\/[\d.]+ /, "")
525517 .replace(/\s*t3code\/[\d.]+/, "");
526518 sess.setUserAgent(ua);
......@@ -593,9 +585,15 @@ export class PreviewViewManager {
593585 await wc.loadURL(url);
594586 }
595587
596 goBack(tabId: string): void { this.requireWebContents(tabId).navigationHistory.goBack(); }
597 goForward(tabId: string): void { this.requireWebContents(tabId).navigationHistory.goForward(); }
598 refresh(tabId: string): void { this.requireWebContents(tabId).reload(); }
588 goBack(tabId: string): void {
589 this.requireWebContents(tabId).navigationHistory.goBack();
590 }
591 goForward(tabId: string): void {
592 this.requireWebContents(tabId).navigationHistory.goForward();
593 }
594 refresh(tabId: string): void {
595 this.requireWebContents(tabId).reload();
596 }
599597
600598 onStateChange(listener: Listener): () => void {
601599 this.listeners.add(listener);
......@@ -656,16 +654,17 @@ export class PreviewViewManager {
656654 if (input.type !== "keyDown") return false;
657655 // Mirror the t3code keybinding defaults that should always reach the main window.
658656 const SHORTCUTS = [
659 { key: "j", meta: true, shift: true }, // preview.toggle
660 { key: "k", meta: true, shift: false }, // commandPalette.toggle
661 { key: ",", meta: true, shift: false }, // settings
662 { key: "w", meta: true, shift: false }, // close
657 { key: "j", meta: true, shift: true }, // preview.toggle
658 { key: "k", meta: true, shift: false }, // commandPalette.toggle
659 { key: ",", meta: true, shift: false }, // settings
660 { key: "w", meta: true, shift: false }, // close
663661 // future: terminal.* if user wants them while preview focused
664662 ];
665 return SHORTCUTS.some((s) =>
666 s.key.toLowerCase() === input.key.toLowerCase()
667 && s.meta === input.meta
668 && s.shift === input.shift,
663 return SHORTCUTS.some(
664 (s) =>
665 s.key.toLowerCase() === input.key.toLowerCase() &&
666 s.meta === input.meta &&
667 s.shift === input.shift,
669668 );
670669 }
671670
......@@ -704,9 +703,9 @@ export class PreviewViewManager {
704703 const trimmed = input.trim();
705704 if (!trimmed) throw new PreviewInvalidUrlError(input);
706705 const useHttp = /^(localhost|127\.0\.0\.1|0\.0\.0\.0)(:|\/|$)/.test(trimmed);
707 const parsed = new URL(trimmed.includes("://")
708 ? trimmed
709 : `${useHttp ? "http" : "https"}://${trimmed}`);
706 const parsed = new URL(
707 trimmed.includes("://") ? trimmed : `${useHttp ? "http" : "https"}://${trimmed}`,
708 );
710709 return parsed.href;
711710 }
712711}
......@@ -716,9 +715,15 @@ export class PreviewTabNotFoundError extends Error {
716715 super(`Preview tab not found: ${tabId}`);
717716 }
718717}
719export class PreviewWebContentsNotFoundError extends Error { /* … */ }
720export class PreviewWebviewNotInitializedError extends Error { /* … */ }
721export class PreviewInvalidUrlError extends Error { /* … */ }
718export class PreviewWebContentsNotFoundError extends Error {
719 /* … */
720}
721export class PreviewWebviewNotInitializedError extends Error {
722 /* … */
723}
724export class PreviewInvalidUrlError extends Error {
725 /* … */
726}
722727
723728export const previewViewManager = new PreviewViewManager();
724729```
......@@ -734,18 +739,20 @@ previewViewManager.setMainWindow(mainWindow);
734739Register IPC handlers (in `registerIpcHandlers()`):
735740
736741```ts
737ipcMain.handle("preview:createTab", (_e, tabId: string) =>
738 previewViewManager.createTab(tabId));
739ipcMain.handle("preview:closeTab", (_e, tabId: string) =>
740 previewViewManager.closeTab(tabId));
742ipcMain.handle("preview:createTab", (_e, tabId: string) => previewViewManager.createTab(tabId));
743ipcMain.handle("preview:closeTab", (_e, tabId: string) => previewViewManager.closeTab(tabId));
741744ipcMain.handle("preview:setVisibility", (_e, tabId: string, visible: boolean) =>
742 previewViewManager.setVisibility(tabId, visible));
745 previewViewManager.setVisibility(tabId, visible),
746);
743747ipcMain.handle("preview:registerWebview", (_e, tabId: string, wcId: number) =>
744 previewViewManager.registerWebview(tabId, wcId));
748 previewViewManager.registerWebview(tabId, wcId),
749);
745750ipcMain.handle("preview:unregisterWebview", (_e, tabId: string) =>
746 previewViewManager.unregisterWebview(tabId));
751 previewViewManager.unregisterWebview(tabId),
752);
747753ipcMain.handle("preview:navigate", (_e, tabId: string, url: string) =>
748 previewViewManager.navigate(tabId, url));
754 previewViewManager.navigate(tabId, url),
755);
749756ipcMain.handle("preview:goBack", (_e, tabId: string) => previewViewManager.goBack(tabId));
750757ipcMain.handle("preview:goForward", (_e, tabId: string) => previewViewManager.goForward(tabId));
751758ipcMain.handle("preview:refresh", (_e, tabId: string) => previewViewManager.refresh(tabId));
......@@ -776,16 +783,13 @@ contextBridge.exposeInMainWorld("desktopBridge", {
776783 ipcRenderer.invoke("preview:setVisibility", tabId, visible),
777784 registerWebview: (tabId: string, wcId: number) =>
778785 ipcRenderer.invoke("preview:registerWebview", tabId, wcId),
779 unregisterWebview: (tabId: string) =>
780 ipcRenderer.invoke("preview:unregisterWebview", tabId),
781 navigate: (tabId: string, url: string) =>
782 ipcRenderer.invoke("preview:navigate", tabId, url),
786 unregisterWebview: (tabId: string) => ipcRenderer.invoke("preview:unregisterWebview", tabId),
787 navigate: (tabId: string, url: string) => ipcRenderer.invoke("preview:navigate", tabId, url),
783788 goBack: (tabId: string) => ipcRenderer.invoke("preview:goBack", tabId),
784789 goForward: (tabId: string) => ipcRenderer.invoke("preview:goForward", tabId),
785790 refresh: (tabId: string) => ipcRenderer.invoke("preview:refresh", tabId),
786791 getPreloadPath: (): Promise<string> => ipcRenderer.invoke("preview:getPreloadPath"),
787 getBrowserPartition: (): Promise<string> =>
788 ipcRenderer.invoke("preview:getBrowserPartition"),
792 getBrowserPartition: (): Promise<string> => ipcRenderer.invoke("preview:getBrowserPartition"),
789793 onStateChange: (cb: (tabId: string, state: DesktopPreviewTabState) => void) => {
790794 const listener = (_e: unknown, tabId: string, state: DesktopPreviewTabState) =>
791795 cb(tabId, state);
......@@ -1096,14 +1100,14 @@ Adding `preview.toggle`, `preview.refresh`, `preview.focusUrl` to `STATIC_KEYBIN
10961100
10971101Following t3code's vitest patterns (`bun run test`, never `bun test`):
10981102
1099| Test file | What it covers |
1100|---|---|
1101| `packages/contracts/src/preview.test.ts` | Schema encode/decode round-trips for inputs, snapshot, events; URL trimming; error tagged unions |
1102| `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 |
1103| `apps/web/src/previewStateStore.test.ts` | `applyServerEvent` reducer correctness; ring buffer cap; `closed` removes entry; `desktopOverlay` is independent of snapshot fields |
1104| `apps/web/src/rightPanelStore.test.ts` | `open` / `close` / `toggle` semantics; per-thread isolation; `?diff=1` sync compatibility |
1105| `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 |
1106| Existing `ThreadTerminalDrawer.test.ts` | Add a case: link activation with `kind: "url"` and `previewable: true` shows the context menu (mock `localApi.contextMenu.show`) |
1103| Test file | What it covers |
1104| -------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1105| `packages/contracts/src/preview.test.ts` | Schema encode/decode round-trips for inputs, snapshot, events; URL trimming; error tagged unions |
1106| `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 |
1107| `apps/web/src/previewStateStore.test.ts` | `applyServerEvent` reducer correctness; ring buffer cap; `closed` removes entry; `desktopOverlay` is independent of snapshot fields |
1108| `apps/web/src/rightPanelStore.test.ts` | `open` / `close` / `toggle` semantics; per-thread isolation; `?diff=1` sync compatibility |
1109| `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 |
1110| Existing `ThreadTerminalDrawer.test.ts` | Add a case: link activation with `kind: "url"` and `previewable: true` shows the context menu (mock `localApi.contextMenu.show`) |
11071111
11081112Manual smoke checklist (drop in `apps/desktop/test/smoke/`):
11091113
......@@ -1120,15 +1124,15 @@ Manual smoke checklist (drop in `apps/desktop/test/smoke/`):
11201124
11211125## 12. Risks and resolutions
11221126
1123| Risk | Resolution |
1124|---|---|
1125| Hidden `<webview>` GPU cost when keeping multiple threads' previews mounted | v1 only mounts the active thread's preview. v2 can adopt the `PersistentThreadTerminalDrawer` pattern + `setVisibility` |
1126| `<webview>` keyboard capture eats `mod+shift+j` etc. | `before-input-event` forwarder in `PreviewViewManager` (mirror of ami's pattern, smaller shortcut list) |
1127| URL with `X-Frame-Options: DENY` works fine in `<webview>` (good — that's why we picked `<webview>` over iframe) | n/a |
1128| Server restart while desktop is alive: `<webview>` 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` |
1129| Renderer process renders something into `<webview>` but never registered → orphaned tab in `PreviewViewManager` | `closeTab` is idempotent; on `<webview>` unmount, `unregisterWebview` is called; if that didn't fire (crash), the next `createTab` for the same `tabId` reuses the record |
1130| `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 |
1131| Multiple windows of the desktop both rendering the same `<webview>` for the same thread | `<webview>` 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 |
1127| Risk | Resolution |
1128| ---------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1129| Hidden `<webview>` GPU cost when keeping multiple threads' previews mounted | v1 only mounts the active thread's preview. v2 can adopt the `PersistentThreadTerminalDrawer` pattern + `setVisibility` |
1130| `<webview>` keyboard capture eats `mod+shift+j` etc. | `before-input-event` forwarder in `PreviewViewManager` (mirror of ami's pattern, smaller shortcut list) |
1131| URL with `X-Frame-Options: DENY` works fine in `<webview>` (good — that's why we picked `<webview>` over iframe) | n/a |
1132| Server restart while desktop is alive: `<webview>` 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` |
1133| Renderer process renders something into `<webview>` but never registered → orphaned tab in `PreviewViewManager` | `closeTab` is idempotent; on `<webview>` unmount, `unregisterWebview` is called; if that didn't fire (crash), the next `createTab` for the same `tabId` reuses the record |
1134| `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 |
1135| Multiple windows of the desktop both rendering the same `<webview>` for the same thread | `<webview>` 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 |
11321136
11331137---
11341138
......@@ -1155,38 +1159,43 @@ Any of these can land in follow-up PRs against the same `PreviewViewManager` + `
11551159
11561160Every 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.
11571161
1158| Need | Primitive | File ref | Notes |
1159|---|---|---|---|
1160| 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 |
1161| `cn` class merger | `cn(...inputs)` from `~/lib/utils` | `apps/web/src/lib/utils.ts:8` | Wraps `cx` + `tailwind-merge` |
1162| 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 |
1163| Buttons (URL field submit) | `Button` `variant="outline"` `size="sm"` | same | Matches the "ProjectScript primary action" density already in `BranchToolbar` |
1164| 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 |
1165| 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` |
1166| 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` |
1167| Tab strip cells | Plain `<button>` + `cn` — **not** `Tabs` (base‑ui Tabs is over‑featured for browser tabs); `Group` semantics differ | new file `PreviewTabStrip.tsx` | Same hover/active treatment as the sidebar's `ChatPreview` rows |
1168| "Local" recommendation card | `Card` (no `CardHeader`/`CardPanel` — flat) | `apps/web/src/components/ui/card.tsx` | `Card` already gives `rounded-2xl border bg-card text-card-foreground shadow-xs/5` plus the inset stroke via `before:` |
1169| Status pulse dot (running) | `<span>` with `bg-success` and `animate-pulse` | tailwind built‑in | Theme‑aware via `--success` |
1170| Tooltips (back/fwd/refresh hover) | `Tooltip` + `TooltipTrigger` + `TooltipPopup` | `apps/web/src/components/ui/tooltip.tsx` | Sub‑200ms hover already configured |
1171| Empty state title + description | `Empty`, `EmptyHeader`, `EmptyTitle`, `EmptyDescription`, `EmptyContent`, `EmptyMedia` | `apps/web/src/components/ui/empty.tsx` | `EmptyMedia variant="icon"` gives the stacked‑cards icon with side rotation we want for "no preview yet" |
1172| Loading bar (top of webview while navigating) | Plain `<div>` with `bg-primary` + `transition-all` | n/a | Mirrors ami's loading bar (`browser-view.tsx:434`) — single‑pixel‑height div animating `width` |
1173| Spinner (inline in URL field while loading) | `Spinner` | `apps/web/src/components/ui/spinner.tsx` | Wraps `Loader2Icon` from lucide |
1174| Context menu (terminal link "Open in preview") | `localApi.contextMenu.show(...)` (already exists) | `apps/web/src/localApi.ts:58` | Native menu on desktop, fallback in `contextMenuFallback.ts` on web |
1175| Keybinding shortcut label in tooltips | `shortcutLabelForCommand(keybindings, "preview.toggle")` | `apps/web/src/keybindings.ts` | Renders `⌘⇧J` etc. |
1176| Right‑panel shell sizing | Match `DiffPanelShell` exactly: `w-[42vw] min-w-[360px] max-w-[560px] shrink-0 border-l border-border` | `apps/web/src/components/DiffPanelShell.tsx:32` | Single class string, do not reinvent |
1162| Need | Primitive | File ref | Notes |
1163| ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
1164| 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 |
1165| `cn` class merger | `cn(...inputs)` from `~/lib/utils` | `apps/web/src/lib/utils.ts:8` | Wraps `cx` + `tailwind-merge` |
1166| 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 |
1167| Buttons (URL field submit) | `Button` `variant="outline"` `size="sm"` | same | Matches the "ProjectScript primary action" density already in `BranchToolbar` |
1168| 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 |
1169| 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` |
1170| 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` |
1171| Tab strip cells | Plain `<button>` + `cn` — **not** `Tabs` (base‑ui Tabs is over‑featured for browser tabs); `Group` semantics differ | new file `PreviewTabStrip.tsx` | Same hover/active treatment as the sidebar's `ChatPreview` rows |
1172| "Local" recommendation card | `Card` (no `CardHeader`/`CardPanel` — flat) | `apps/web/src/components/ui/card.tsx` | `Card` already gives `rounded-2xl border bg-card text-card-foreground shadow-xs/5` plus the inset stroke via `before:` |
1173| Status pulse dot (running) | `<span>` with `bg-success` and `animate-pulse` | tailwind built‑in | Theme‑aware via `--success` |
1174| Tooltips (back/fwd/refresh hover) | `Tooltip` + `TooltipTrigger` + `TooltipPopup` | `apps/web/src/components/ui/tooltip.tsx` | Sub‑200ms hover already configured |
1175| Empty state title + description | `Empty`, `EmptyHeader`, `EmptyTitle`, `EmptyDescription`, `EmptyContent`, `EmptyMedia` | `apps/web/src/components/ui/empty.tsx` | `EmptyMedia variant="icon"` gives the stacked‑cards icon with side rotation we want for "no preview yet" |
1176| Loading bar (top of webview while navigating) | Plain `<div>` with `bg-primary` + `transition-all` | n/a | Mirrors ami's loading bar (`browser-view.tsx:434`) — single‑pixel‑height div animating `width` |
1177| Spinner (inline in URL field while loading) | `Spinner` | `apps/web/src/components/ui/spinner.tsx` | Wraps `Loader2Icon` from lucide |
1178| Context menu (terminal link "Open in preview") | `localApi.contextMenu.show(...)` (already exists) | `apps/web/src/localApi.ts:58` | Native menu on desktop, fallback in `contextMenuFallback.ts` on web |
1179| Keybinding shortcut label in tooltips | `shortcutLabelForCommand(keybindings, "preview.toggle")` | `apps/web/src/keybindings.ts` | Renders `⌘⇧J` etc. |
1180| Right‑panel shell sizing | Match `DiffPanelShell` exactly: `w-[42vw] min-w-[360px] max-w-[560px] shrink-0 border-l border-border` | `apps/web/src/components/DiffPanelShell.tsx:32` | Single class string, do not reinvent |
11771181
11781182**Icons.** Use `lucide-react` everywhere (already in `package.json`). The exact icons we need:
11791183
11801184```ts
11811185import {
1182 ArrowLeft, ArrowRight, // back / forward
1183 RotateCw, Loader2, // refresh / spinning
1184 Globe, // favicon fallback
1185 X, // close tab
1186 Plus, // new tab
1187 Copy, ExternalLink, // open in browser, copy URL
1188 Sidebar, Columns2, // right‑side toggles in tab strip (matches screenshot 2)
1189 PanelRightOpen, PanelRightClose, // alt for above
1186 ArrowLeft,
1187 ArrowRight, // back / forward
1188 RotateCw,
1189 Loader2, // refresh / spinning
1190 Globe, // favicon fallback
1191 X, // close tab
1192 Plus, // new tab
1193 Copy,
1194 ExternalLink, // open in browser, copy URL
1195 Sidebar,
1196 Columns2, // right‑side toggles in tab strip (matches screenshot 2)
1197 PanelRightOpen,
1198 PanelRightClose, // alt for above
11901199} from "lucide-react";
11911200```
11921201
......@@ -1200,11 +1209,11 @@ import {
12001209
12011210Three states render inside `PreviewView.tsx` based on the `PreviewSession` for the active thread:
12021211
1203| Session state | Component rendered |
1204|---|---|
1205| no tab open | `<PreviewEmptyState />` — disabled chrome row + "Local" recommendations |
1206| tab loading / loaded | `<PreviewWebview />` + chrome row + tab strip |
1207| tab navigation failed | `<PreviewUnreachable />` overlay above the webview |
1212| Session state | Component rendered |
1213| --------------------- | ----------------------------------------------------------------------- |
1214| no tab open | `<PreviewEmptyState />` — disabled chrome row + "Local" recommendations |
1215| tab loading / loaded | `<PreviewWebview />` + chrome row + tab strip |
1216| tab navigation failed | `<PreviewUnreachable />` overlay above the webview |
12081217
12091218### 15.1 Empty state (`PreviewEmptyState.tsx`) — matches screenshot 1
12101219
......@@ -1259,9 +1268,7 @@ export function PreviewEmptyState({ threadId, onOpen }: Props) {
12591268 return (
12601269 <div className="flex h-full min-h-0 flex-col">
12611270 <div className="px-6 pt-10 pb-4">
1262 <h2 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
1263 Local
1264 </h2>
1271 <h2 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Local</h2>
12651272 </div>
12661273
12671274 {servers.length === 0 ? (
......@@ -1308,10 +1315,7 @@ function LocalServerCard({
13081315 {server.processName ?? `${server.host}:${server.port}`}
13091316 </span>
13101317 </div>
1311 <span
1312 className="size-2 shrink-0 rounded-full bg-success"
1313 aria-label="Listening"
1314 >
1318 <span className="size-2 shrink-0 rounded-full bg-success" aria-label="Listening">
13151319 <span className="block size-2 rounded-full bg-success animate-ping opacity-60" />
13161320 </span>
13171321 </Card>
......@@ -1362,8 +1366,8 @@ The chrome row in the empty state — note the `disabled` props on every interac
13621366 onForward={NOOP}
13631367 onRefresh={NOOP}
13641368 onSubmitUrl={(url) => api.preview.open({ threadId, url })}
1365 inputDisabled={false} // we still want to allow paste-to-open
1366 buttonsDisabled // back/fwd/refresh are visually disabled
1369 inputDisabled={false} // we still want to allow paste-to-open
1370 buttonsDisabled // back/fwd/refresh are visually disabled
13671371/>
13681372```
13691373
......@@ -1568,17 +1572,17 @@ When `navStatus._tag === "LoadFailed"`, render this instead of the failed `<webv
15681572
15691573Color mapping (Chromium → t3code theme):
15701574
1571| Chromium | Tailwind/t3code |
1572|---|---|
1573| `--background-color: #fff` / `--google-gray-900` (dark) | `bg-background` |
1574| `--text-color: --google-gray-700` / `--google-gray-500` (dark) | `text-muted-foreground` |
1575| `--heading-color: --google-gray-900` / `--google-gray-500` (dark) | `text-foreground` |
1576| `--error-code-color: --google-gray-700` / `--google-gray-500` (dark) | `text-muted-foreground/70` |
1577| `--quiet-background-color: rgb(247,247,247)` / `--background` (dark) | `bg-muted/40` |
1578| `--primary-button-fill-color: --google-blue-600` / `--google-blue-300` (dark) | `bg-primary text-primary-foreground` (theme primary is `oklch(0.488 0.217 264)` light / `oklch(0.588 0.217 264)` dark — a very close blue) |
1579| `--secondary-button-*` | `Button variant="outline"` |
1580| `--link-color: rgb(88,88,88)` / `--google-blue-300` (dark) | `text-primary underline-offset-4 hover:underline` |
1581| Body font `system-ui, sans-serif; font-size: 75%` | We keep DM Sans (already in `apps/web/src/index.css:148`) and `text-sm` — the Chromium font tweak just compensates for their `html { font-size: 125% }` — we don't replicate that |
1575| Chromium | Tailwind/t3code |
1576| ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1577| `--background-color: #fff` / `--google-gray-900` (dark) | `bg-background` |
1578| `--text-color: --google-gray-700` / `--google-gray-500` (dark) | `text-muted-foreground` |
1579| `--heading-color: --google-gray-900` / `--google-gray-500` (dark) | `text-foreground` |
1580| `--error-code-color: --google-gray-700` / `--google-gray-500` (dark) | `text-muted-foreground/70` |
1581| `--quiet-background-color: rgb(247,247,247)` / `--background` (dark) | `bg-muted/40` |
1582| `--primary-button-fill-color: --google-blue-600` / `--google-blue-300` (dark) | `bg-primary text-primary-foreground` (theme primary is `oklch(0.488 0.217 264)` light / `oklch(0.588 0.217 264)` dark — a very close blue) |
1583| `--secondary-button-*` | `Button variant="outline"` |
1584| `--link-color: rgb(88,88,88)` / `--google-blue-300` (dark) | `text-primary underline-offset-4 hover:underline` |
1585| Body font `system-ui, sans-serif; font-size: 75%` | We keep DM Sans (already in `apps/web/src/index.css:148`) and `text-sm` — the Chromium font tweak just compensates for their `html { font-size: 125% }` — we don't replicate that |
15821586
15831587Skeleton:
15841588
......@@ -1592,7 +1596,7 @@ import { cn } from "~/lib/utils";
15921596
15931597interface Props {
15941598 url: string;
1595 errorCode: string; // e.g. "ERR_NAME_NOT_RESOLVED", "ERR_CONNECTION_REFUSED"
1599 errorCode: string; // e.g. "ERR_NAME_NOT_RESOLVED", "ERR_CONNECTION_REFUSED"
15961600 description: string;
15971601 onReload: () => void;
15981602}
......@@ -1601,7 +1605,13 @@ const ICON_GENERIC = (
16011605 // Replace with an inline SVG that matches the Chromium "icon-generic"
16021606 // (a stylized broken page). For v1 a Lucide MapPinOff is a fine stand-in —
16031607 // we want a visual that reads "destination unreachable".
1604 <svg viewBox="0 0 64 64" className="size-10 text-muted-foreground/70" fill="none" stroke="currentColor" strokeWidth="2.5">
1608 <svg
1609 viewBox="0 0 64 64"
1610 className="size-10 text-muted-foreground/70"
1611 fill="none"
1612 stroke="currentColor"
1613 strokeWidth="2.5"
1614 >
16051615 <path d="M16 12 L48 12 L48 52 L16 52 Z" />
16061616 <path d="M22 22 L42 22 M22 30 L36 30 M22 38 L40 38" strokeLinecap="round" />
16071617 <path d="M52 8 L12 56" strokeLinecap="round" />
......@@ -1626,7 +1636,13 @@ export function PreviewUnreachable({ url, errorCode, description, onReload }: Pr
16261636 {/* Summary — uses dangerouslySetInnerHTML only for the bold host treatment */}
16271637 <p className="text-sm leading-relaxed text-muted-foreground">
16281638 <strong className="font-semibold text-foreground">{host}</strong>
1629 &rsquo;s server <abbr title="Domain Name System" className="cursor-help underline decoration-dotted underline-offset-2">DNS address</abbr>{" "}
1639 &rsquo;s server{" "}
1640 <abbr
1641 title="Domain Name System"
1642 className="cursor-help underline decoration-dotted underline-offset-2"
1643 >
1644 DNS address
1645 </abbr>{" "}
16301646 could not be found.
16311647 </p>
16321648
......@@ -1668,11 +1684,16 @@ export function PreviewUnreachable({ url, errorCode, description, onReload }: Pr
16681684}
16691685
16701686function safeHost(url: string): string | null {
1671 try { return new URL(url).host; } catch { return null; }
1687 try {
1688 return new URL(url).host;
1689 } catch {
1690 return null;
1691 }
16721692}
16731693```
16741694
16751695The component intentionally:
1696
16761697- Uses theme tokens only — looks correct in light + dark with no extra wiring.
16771698- Drops Chromium's "Diagnose connection" / "Portal sign-in" buttons (irrelevant in our shell).
16781699- Drops the dinosaur game (RIP).
......@@ -1683,15 +1704,17 @@ The component intentionally:
16831704A 1.5px primary-colored bar that fills as the page loads, anchored to the bottom of the chrome row. Direct port of ami's pattern (`browser-view.tsx:434`):
16841705
16851706```tsx
1686{loadProgress > 0 && (
1687 <div
1688 className="pointer-events-none absolute bottom-0 left-0 z-10 h-0.5 rounded-full bg-primary transition-all duration-150 ease-out"
1689 style={{
1690 width: `${loadProgress}%`,
1691 boxShadow: "0 0 6px 1px var(--ring)",
1692 }}
1693 />
1694)}
1707{
1708 loadProgress > 0 && (
1709 <div
1710 className="pointer-events-none absolute bottom-0 left-0 z-10 h-0.5 rounded-full bg-primary transition-all duration-150 ease-out"
1711 style={{
1712 width: `${loadProgress}%`,
1713 boxShadow: "0 0 6px 1px var(--ring)",
1714 }}
1715 />
1716 );
1717}
16951718```
16961719
16971720Progress is computed locally with `useLoadingProgress(isLoading)` — same hook signature as ami's, ~30 lines.
......@@ -1708,9 +1731,9 @@ The "Local" recommendations in §15.1 need a feed. New backend service.
17081731import { Context, Effect } from "effect";
17091732
17101733export interface DiscoveredLocalServer {
1711 host: string; // "localhost"
1712 port: number; // 5175
1713 url: string; // "http://localhost:5175"
1734 host: string; // "localhost"
1735 port: number; // 5175
1736 url: string; // "http://localhost:5175"
17141737 processName: string | null; // "node", "vite", "next-server"
17151738 pid: number | null;
17161739}
......@@ -1740,6 +1763,7 @@ Two strategies, picked at startup:
174017632. **Fallback — TCP connect probe**: iterate a curated list of common dev ports `[3000, 3001, 3333, 4173, 4200, 4321, 5000, 5173, 5174, 5175, 5500, 8000, 8080, 8081, 8888, 9000]` against `127.0.0.1`, mark any that accepts a connection. Used on Windows and as the safety net if `lsof` is missing.
17411764
17421765Polling cadence:
1766
17431767- When `retain()` count is 0 → not polling.
17441768- When ≥1 → poll every 3s. Diff against last result; only emit `subscribe` callbacks when the set differs.
17451769- `retain()` returns a release fn that decrements the counter; goes idle automatically when the empty state is hidden.
......@@ -1762,20 +1786,20 @@ Deduped by URL string. Sort: configured > listening > recent. This yields the "s
17621786
17631787Append these to the file list:
17641788
1765| File | Purpose |
1766|---|---|
1767| `apps/server/src/preview/Services/PortScanner.ts` | Service tag + interface |
1768| `apps/server/src/preview/Layers/PortScanner.ts` | `lsof` strategy + TCP probe fallback + polling |
1769| `apps/server/src/preview/Layers/PortScanner.test.ts` | Parser tests for `lsof -F pcn` output |
1770| `apps/web/src/lib/favicon.ts` | `faviconUrlForOrigin(url, size)` helper |
1771| `apps/web/src/components/preview/PreviewTabStrip.tsx` | Tab strip (screenshot 2) |
1772| `apps/web/src/components/preview/TabFavicon.tsx` | Favicon `<img>` w/ `<Globe>` fallback |
1773| `apps/web/src/components/preview/BrowserMockup.tsx` | Tiny tailwind browser thumbnail icon |
1774| `apps/web/src/components/preview/PreviewLocalServerCard.tsx` | Card row for a discovered server |
1775| `apps/web/src/components/preview/PreviewUnreachable.tsx` | 404.html rewritten in tailwind |
1789| File | Purpose |
1790| -------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
1791| `apps/server/src/preview/Services/PortScanner.ts` | Service tag + interface |
1792| `apps/server/src/preview/Layers/PortScanner.ts` | `lsof` strategy + TCP probe fallback + polling |
1793| `apps/server/src/preview/Layers/PortScanner.test.ts` | Parser tests for `lsof -F pcn` output |
1794| `apps/web/src/lib/favicon.ts` | `faviconUrlForOrigin(url, size)` helper |
1795| `apps/web/src/components/preview/PreviewTabStrip.tsx` | Tab strip (screenshot 2) |
1796| `apps/web/src/components/preview/TabFavicon.tsx` | Favicon `<img>` w/ `<Globe>` fallback |
1797| `apps/web/src/components/preview/BrowserMockup.tsx` | Tiny tailwind browser thumbnail icon |
1798| `apps/web/src/components/preview/PreviewLocalServerCard.tsx` | Card row for a discovered server |
1799| `apps/web/src/components/preview/PreviewUnreachable.tsx` | 404.html rewritten in tailwind |
17761800| `apps/web/src/components/preview/useDiscoveredLocalServers.ts` | Hook subscribing to `EnvironmentApi.preview.subscribePorts` + merging in `recentUrlsFromTerminal` and `ProjectScript.previewUrl` |
1777| `apps/web/src/components/preview/useLoadingProgress.ts` | 30-line progress simulator (port of ami's) |
1778| `apps/web/src/components/preview/errorCodeMessages.ts` | `ERR_*` → human-readable description map |
1801| `apps/web/src/components/preview/useLoadingProgress.ts` | 30-line progress simulator (port of ami's) |
1802| `apps/web/src/components/preview/errorCodeMessages.ts` | `ERR_*` → human-readable description map |
17791803
17801804Update the contract additions in §4 to add:
17811805
apps/desktop/src/ipc/DesktopIpcHandlers.ts+4
......@@ -48,6 +48,7 @@ import {
4848 setTheme,
4949 showContextMenu,
5050} from "./methods/window.ts";
51import { previewMethods } from "./methods/preview.ts";
5152
5253export const installDesktopIpcHandlers = Effect.gen(function* () {
5354 const ipc = yield* DesktopIpc.DesktopIpc;
......@@ -92,4 +93,7 @@ export const installDesktopIpcHandlers = Effect.gen(function* () {
9293 yield* ipc.handle(downloadUpdate);
9394 yield* ipc.handle(installUpdate);
9495 yield* ipc.handle(checkForUpdate);
96 for (const previewMethod of previewMethods) {
97 yield* ipc.handle(previewMethod);
98 }
9599}).pipe(Effect.withSpan("desktop.ipc.installHandlers"));
apps/desktop/src/ipc/channels.ts+16
......@@ -39,3 +39,19 @@ export const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mod
3939export const SET_TAILSCALE_SERVE_ENABLED_CHANNEL = "desktop:set-tailscale-serve-enabled";
4040export const GET_ADVERTISED_ENDPOINTS_CHANNEL = "desktop:get-advertised-endpoints";
4141export const SSH_PASSWORD_PROMPT_CANCELLED_RESULT = "ssh-password-prompt-cancelled";
42export const PREVIEW_CREATE_TAB_CHANNEL = "desktop:preview-create-tab";
43export const PREVIEW_CLOSE_TAB_CHANNEL = "desktop:preview-close-tab";
44export const PREVIEW_REGISTER_WEBVIEW_CHANNEL = "desktop:preview-register-webview";
45export const PREVIEW_NAVIGATE_CHANNEL = "desktop:preview-navigate";
46export const PREVIEW_GO_BACK_CHANNEL = "desktop:preview-go-back";
47export const PREVIEW_GO_FORWARD_CHANNEL = "desktop:preview-go-forward";
48export const PREVIEW_REFRESH_CHANNEL = "desktop:preview-refresh";
49export const PREVIEW_ZOOM_IN_CHANNEL = "desktop:preview-zoom-in";
50export const PREVIEW_ZOOM_OUT_CHANNEL = "desktop:preview-zoom-out";
51export const PREVIEW_RESET_ZOOM_CHANNEL = "desktop:preview-reset-zoom";
52export const PREVIEW_HARD_RELOAD_CHANNEL = "desktop:preview-hard-reload";
53export const PREVIEW_OPEN_DEVTOOLS_CHANNEL = "desktop:preview-open-devtools";
54export const PREVIEW_CLEAR_COOKIES_CHANNEL = "desktop:preview-clear-cookies";
55export const PREVIEW_CLEAR_CACHE_CHANNEL = "desktop:preview-clear-cache";
56export const PREVIEW_GET_BROWSER_PARTITION_CHANNEL = "desktop:preview-get-browser-partition";
57export const PREVIEW_STATE_CHANGE_CHANNEL = "desktop:preview-state-change";
apps/desktop/src/ipc/methods/preview.ts created+61
......@@ -0,0 +1,61 @@
1import * as Effect from "effect/Effect";
2import { BrowserWindow } from "electron";
3
4import { previewViewManager } from "../../preview-view-manager.ts";
5import * as IpcChannels from "../channels.ts";
6import type { DesktopIpcMethod } from "../DesktopIpc.ts";
7
8previewViewManager.getBrowserSession();
9previewViewManager.onStateChange((tabId, state) => {
10 for (const window of BrowserWindow.getAllWindows()) {
11 if (!window.isDestroyed()) {
12 window.webContents.send(IpcChannels.PREVIEW_STATE_CHANGE_CHANNEL, tabId, state);
13 }
14 }
15});
16
17const tabIdFrom = (raw: unknown): string => {
18 if (typeof raw !== "object" || raw === null || !("tabId" in raw)) {
19 throw new Error("preview tab id is required");
20 }
21 const tabId = raw.tabId;
22 if (typeof tabId !== "string" || tabId.trim().length === 0) {
23 throw new Error("preview tab id must be a non-empty string");
24 }
25 return tabId;
26};
27
28const method = (channel: string, handler: (raw: unknown) => unknown | Promise<unknown>): DesktopIpcMethod<unknown, never> => ({
29 channel,
30 handler: (raw) => Effect.tryPromise({ try: () => Promise.resolve(handler(raw)), catch: (error) => error }),
31});
32
33export const previewMethods = [
34 method(IpcChannels.PREVIEW_CREATE_TAB_CHANNEL, (raw) => previewViewManager.createTab(tabIdFrom(raw))),
35 method(IpcChannels.PREVIEW_CLOSE_TAB_CHANNEL, (raw) => previewViewManager.closeTab(tabIdFrom(raw))),
36 method(IpcChannels.PREVIEW_REGISTER_WEBVIEW_CHANNEL, (raw) => {
37 const tabId = tabIdFrom(raw);
38 const webContentsId = typeof raw === "object" && raw !== null && "webContentsId" in raw ? raw.webContentsId : null;
39 if (typeof webContentsId !== "number" || !Number.isInteger(webContentsId) || webContentsId <= 0) {
40 throw new Error("preview webContentsId must be a positive integer");
41 }
42 return previewViewManager.registerWebview(tabId, webContentsId);
43 }),
44 method(IpcChannels.PREVIEW_NAVIGATE_CHANNEL, (raw) => {
45 const tabId = tabIdFrom(raw);
46 const url = typeof raw === "object" && raw !== null && "url" in raw ? raw.url : null;
47 if (typeof url !== "string") throw new Error("preview url must be a string");
48 return previewViewManager.navigate(tabId, url);
49 }),
50 method(IpcChannels.PREVIEW_GO_BACK_CHANNEL, (raw) => previewViewManager.goBack(tabIdFrom(raw))),
51 method(IpcChannels.PREVIEW_GO_FORWARD_CHANNEL, (raw) => previewViewManager.goForward(tabIdFrom(raw))),
52 method(IpcChannels.PREVIEW_REFRESH_CHANNEL, (raw) => previewViewManager.refresh(tabIdFrom(raw))),
53 method(IpcChannels.PREVIEW_ZOOM_IN_CHANNEL, (raw) => previewViewManager.zoomIn(tabIdFrom(raw))),
54 method(IpcChannels.PREVIEW_ZOOM_OUT_CHANNEL, (raw) => previewViewManager.zoomOut(tabIdFrom(raw))),
55 method(IpcChannels.PREVIEW_RESET_ZOOM_CHANNEL, (raw) => previewViewManager.resetZoom(tabIdFrom(raw))),
56 method(IpcChannels.PREVIEW_HARD_RELOAD_CHANNEL, (raw) => previewViewManager.hardReload(tabIdFrom(raw))),
57 method(IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, (raw) => previewViewManager.openDevTools(tabIdFrom(raw))),
58 method(IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL, () => previewViewManager.clearCookies()),
59 method(IpcChannels.PREVIEW_CLEAR_CACHE_CHANNEL, () => previewViewManager.clearCache()),
60 method(IpcChannels.PREVIEW_GET_BROWSER_PARTITION_CHANNEL, () => previewViewManager.getBrowserPartition()),
61] as const;
apps/desktop/src/preload.ts+34-1
......@@ -1,4 +1,4 @@
1import type { DesktopBridge } from "@t3tools/contracts";
1import type { DesktopBridge, DesktopPreviewTabState } from "@t3tools/contracts";
22import { contextBridge, ipcRenderer } from "electron";
33
44import * as IpcChannels from "./ipc/channels.ts";
......@@ -141,4 +141,37 @@ contextBridge.exposeInMainWorld("desktopBridge", {
141141 ipcRenderer.removeListener(IpcChannels.UPDATE_STATE_CHANNEL, wrappedListener);
142142 };
143143 },
144 preview: {
145 createTab: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_CREATE_TAB_CHANNEL, { tabId }),
146 closeTab: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_CLOSE_TAB_CHANNEL, { tabId }),
147 registerWebview: (tabId, webContentsId) =>
148 ipcRenderer.invoke(IpcChannels.PREVIEW_REGISTER_WEBVIEW_CHANNEL, { tabId, webContentsId }),
149 navigate: (tabId, url) =>
150 ipcRenderer.invoke(IpcChannels.PREVIEW_NAVIGATE_CHANNEL, { tabId, url }),
151 goBack: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_GO_BACK_CHANNEL, { tabId }),
152 goForward: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_GO_FORWARD_CHANNEL, { tabId }),
153 refresh: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_REFRESH_CHANNEL, { tabId }),
154 zoomIn: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_ZOOM_IN_CHANNEL, { tabId }),
155 zoomOut: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_ZOOM_OUT_CHANNEL, { tabId }),
156 resetZoom: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_RESET_ZOOM_CHANNEL, { tabId }),
157 hardReload: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_HARD_RELOAD_CHANNEL, { tabId }),
158 openDevTools: (tabId) =>
159 ipcRenderer.invoke(IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, { tabId }),
160 clearCookies: () => ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL),
161 clearCache: () => ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_CACHE_CHANNEL),
162 getBrowserPartition: () =>
163 ipcRenderer.invoke(IpcChannels.PREVIEW_GET_BROWSER_PARTITION_CHANNEL),
164 onStateChange: (listener) => {
165 const wrappedListener = (
166 _event: Electron.IpcRendererEvent,
167 tabId: unknown,
168 state: unknown,
169 ) => {
170 if (typeof tabId !== "string" || typeof state !== "object" || state === null) return;
171 listener(tabId, state as DesktopPreviewTabState);
172 };
173 ipcRenderer.on(IpcChannels.PREVIEW_STATE_CHANGE_CHANNEL, wrappedListener);
174 return () => ipcRenderer.removeListener(IpcChannels.PREVIEW_STATE_CHANGE_CHANNEL, wrappedListener);
175 },
176 },
144177} satisfies DesktopBridge);
apps/desktop/src/preview-view-manager.ts created+455
......@@ -0,0 +1,455 @@
1/**
2 * PreviewViewManager — desktop side of the in-app browser preview.
3 *
4 * Hosts per-tab Chromium WebContents references (the actual <webview>
5 * elements live in the renderer; we only attach listeners and forward state
6 * here). Single layer-scoped browser session partition.
7 */
8import { normalizePreviewUrl } from "@t3tools/shared/preview";
9import { type BrowserWindow, type Session, session, webContents } from "electron";
10
11const PREVIEW_PARTITION = "persist:t3code-preview";
12
13export type PreviewNavStatus =
14 | { kind: "Idle" }
15 | { kind: "Loading"; url: string; title: string }
16 | { kind: "Success"; url: string; title: string }
17 | {
18 kind: "LoadFailed";
19 url: string;
20 title: string;
21 code: number;
22 description: string;
23 };
24
25export interface PreviewTabState {
26 tabId: string;
27 webContentsId: number | null;
28 navStatus: PreviewNavStatus;
29 canGoBack: boolean;
30 canGoForward: boolean;
31 zoomFactor: number;
32 updatedAt: string;
33}
34
35/** Discrete zoom levels mirroring Chrome's preset list. */
36const ZOOM_LEVELS: ReadonlyArray<number> = [
37 0.25, 0.33, 0.5, 0.67, 0.75, 0.8, 0.9, 1.0, 1.1, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0, 4.0, 5.0,
38];
39
40const DEFAULT_ZOOM_FACTOR = 1.0;
41const ZOOM_EPSILON = 0.001;
42
43const findZoomStep = (current: number): number => {
44 for (let index = 0; index < ZOOM_LEVELS.length; index += 1) {
45 if (Math.abs(ZOOM_LEVELS[index]! - current) < ZOOM_EPSILON) return index;
46 if (ZOOM_LEVELS[index]! > current) return index - 1;
47 }
48 return ZOOM_LEVELS.length - 1;
49};
50
51const nextZoomLevel = (current: number, direction: "in" | "out"): number => {
52 const step = findZoomStep(current);
53 if (direction === "in") {
54 return ZOOM_LEVELS[Math.min(step + 1, ZOOM_LEVELS.length - 1)] ?? current;
55 }
56 return ZOOM_LEVELS[Math.max(step - 1, 0)] ?? current;
57};
58
59type Listener = (tabId: string, state: PreviewTabState) => void;
60
61interface ManagedListeners {
62 navigate: () => void;
63 failed: (event: Event, code: number, description: string) => void;
64}
65
66const APP_FORWARDED_SHORTCUTS: ReadonlyArray<{
67 key: string;
68 meta: boolean;
69 shift: boolean;
70 control: boolean;
71}> = Object.freeze([
72 // mod+shift+J → preview.toggle
73 { key: "j", meta: true, shift: true, control: false },
74 // mod+K → command palette
75 { key: "k", meta: true, shift: false, control: false },
76 // mod+, → settings (macOS convention)
77 { key: ",", meta: true, shift: false, control: false },
78 // mod+W → close tab/panel
79 { key: "w", meta: true, shift: false, control: false },
80]);
81
82export class PreviewViewManager {
83 private mainWindow: BrowserWindow | null = null;
84 private readonly tabs = new Map<string, PreviewTabState>();
85 private readonly attached = new Map<number, ManagedListeners>();
86 private browserSession: Session | null = null;
87 private readonly listeners = new Set<Listener>();
88
89 setMainWindow(window: BrowserWindow): void {
90 this.mainWindow = window;
91 }
92
93 getBrowserPartition(): string {
94 return PREVIEW_PARTITION;
95 }
96
97 getBrowserSession(): Session {
98 if (this.browserSession) return this.browserSession;
99 const sess = session.fromPartition(PREVIEW_PARTITION);
100 const ua = sess
101 .getUserAgent()
102 .replace(/Electron\/[\d.]+ /, "")
103 .replace(/\s*t3code\/[\d.]+/, "");
104 sess.setUserAgent(ua);
105 sess.setPermissionRequestHandler((_wc, perm, callback) => {
106 const allow = ["clipboard-read", "clipboard-write", "notifications", "geolocation"];
107 callback(allow.includes(perm));
108 });
109 this.browserSession = sess;
110 return sess;
111 }
112
113 createTab(tabId: string): PreviewTabState {
114 const existing = this.tabs.get(tabId);
115 if (existing) return existing;
116 const initial: PreviewTabState = {
117 tabId,
118 webContentsId: null,
119 navStatus: { kind: "Idle" },
120 canGoBack: false,
121 canGoForward: false,
122 zoomFactor: DEFAULT_ZOOM_FACTOR,
123 updatedAt: new Date().toISOString(),
124 };
125 this.tabs.set(tabId, initial);
126 this.emit(tabId, initial);
127 return initial;
128 }
129
130 closeTab(tabId: string): void {
131 const tab = this.tabs.get(tabId);
132 if (!tab) return;
133 if (tab.webContentsId != null) {
134 this.detachListeners(tab.webContentsId);
135 }
136 const closed: PreviewTabState = {
137 ...tab,
138 webContentsId: null,
139 navStatus: { kind: "Idle" },
140 canGoBack: false,
141 canGoForward: false,
142 zoomFactor: DEFAULT_ZOOM_FACTOR,
143 updatedAt: new Date().toISOString(),
144 };
145 this.tabs.delete(tabId);
146 this.emit(tabId, closed);
147 }
148
149 registerWebview(tabId: string, webContentsId: number): void {
150 const tab = this.tabs.get(tabId);
151 if (!tab) {
152 throw new PreviewTabNotFoundError(tabId);
153 }
154 const wc = webContents.fromId(webContentsId);
155 if (!wc) {
156 throw new PreviewWebContentsNotFoundError(tabId, webContentsId);
157 }
158 // Defence in depth: a malicious renderer could otherwise trick us into
159 // attaching listeners to the main window's WebContents (or any other
160 // process) by passing an arbitrary id.
161 if (wc.getType() !== "webview") {
162 throw new PreviewWebContentsNotFoundError(tabId, webContentsId);
163 }
164 if (this.mainWindow && wc.hostWebContents !== this.mainWindow.webContents) {
165 throw new PreviewWebContentsNotFoundError(tabId, webContentsId);
166 }
167 if (tab.webContentsId === webContentsId && this.attached.has(webContentsId)) {
168 return;
169 }
170 if (tab.webContentsId != null && tab.webContentsId !== webContentsId) {
171 this.detachListeners(tab.webContentsId);
172 }
173 this.attachListeners(tabId, wc);
174 // Restore the persisted zoom factor onto the freshly-attached WebContents
175 // so a thread-switch + remount lands the user back where they were.
176 if (Math.abs(tab.zoomFactor - DEFAULT_ZOOM_FACTOR) > ZOOM_EPSILON) {
177 try {
178 wc.setZoomFactor(tab.zoomFactor);
179 } catch {
180 // wc may have been torn down between resolution and call.
181 }
182 }
183 this.update(tabId, {
184 webContentsId,
185 navStatus: this.computeNavStatus(wc),
186 canGoBack: wc.navigationHistory.canGoBack(),
187 canGoForward: wc.navigationHistory.canGoForward(),
188 zoomFactor: tab.zoomFactor,
189 });
190 }
191
192 async navigate(tabId: string, rawUrl: string): Promise<void> {
193 const wc = this.requireWebContents(tabId);
194 const url = this.normalizeUrl(rawUrl);
195 if (wc.getURL() === url) {
196 wc.reload();
197 return;
198 }
199 await wc.loadURL(url);
200 }
201
202 goBack(tabId: string): void {
203 const wc = this.requireWebContents(tabId);
204 if (wc.navigationHistory.canGoBack()) {
205 wc.navigationHistory.goBack();
206 }
207 }
208
209 goForward(tabId: string): void {
210 const wc = this.requireWebContents(tabId);
211 if (wc.navigationHistory.canGoForward()) {
212 wc.navigationHistory.goForward();
213 }
214 }
215
216 refresh(tabId: string): void {
217 const wc = this.requireWebContents(tabId);
218 wc.reload();
219 }
220
221 /** Bypass HTTP cache on the next load — equivalent to Cmd+Shift+R. */
222 hardReload(tabId: string): void {
223 const wc = this.requireWebContents(tabId);
224 wc.reloadIgnoringCache();
225 }
226
227 /**
228 * Open the guest webview's DevTools, detached so it doesn't steal panel
229 * area. Idempotent — re-invoking focuses the existing window.
230 */
231 openDevTools(tabId: string): void {
232 const wc = this.requireWebContents(tabId);
233 if (wc.isDevToolsOpened()) {
234 wc.devToolsWebContents?.focus();
235 return;
236 }
237 wc.openDevTools({ mode: "detach" });
238 }
239
240 /**
241 * Drop cookies/localStorage/etc. for the preview partition. Affects every
242 * preview tab since they all share `persist:t3code-preview`.
243 */
244 async clearCookies(): Promise<void> {
245 const sess = this.getBrowserSession();
246 await sess.clearStorageData({
247 storages: ["cookies", "localstorage", "indexdb", "websql", "serviceworkers"],
248 });
249 }
250
251 /** Drop the HTTP cache for the preview partition. */
252 async clearCache(): Promise<void> {
253 const sess = this.getBrowserSession();
254 await sess.clearCache();
255 }
256
257 zoomIn(tabId: string): void {
258 this.applyZoom(tabId, (current) => nextZoomLevel(current, "in"));
259 }
260
261 zoomOut(tabId: string): void {
262 this.applyZoom(tabId, (current) => nextZoomLevel(current, "out"));
263 }
264
265 resetZoom(tabId: string): void {
266 this.applyZoom(tabId, () => DEFAULT_ZOOM_FACTOR);
267 }
268
269 private applyZoom(tabId: string, transform: (current: number) => number): void {
270 const tab = this.tabs.get(tabId);
271 if (!tab) return;
272 const next = transform(tab.zoomFactor);
273 if (Math.abs(next - tab.zoomFactor) < ZOOM_EPSILON) return;
274 if (tab.webContentsId != null) {
275 const wc = webContents.fromId(tab.webContentsId);
276 if (wc && !wc.isDestroyed()) {
277 wc.setZoomFactor(next);
278 }
279 }
280 this.update(tabId, { zoomFactor: next });
281 }
282
283 onStateChange(listener: Listener): () => void {
284 this.listeners.add(listener);
285 return () => {
286 this.listeners.delete(listener);
287 };
288 }
289
290 destroy(): void {
291 for (const tabId of Array.from(this.tabs.keys())) {
292 this.closeTab(tabId);
293 }
294 this.listeners.clear();
295 }
296
297 private attachListeners(tabId: string, wc: Electron.WebContents): void {
298 const sync = () => {
299 if (wc.isDestroyed()) return;
300 this.update(tabId, {
301 navStatus: this.computeNavStatus(wc),
302 canGoBack: wc.navigationHistory.canGoBack(),
303 canGoForward: wc.navigationHistory.canGoForward(),
304 });
305 };
306 const failed = (_event: Event, code: number, description: string): void => {
307 // -3 = ABORTED (user navigated away mid-load); ignore.
308 if (code === -3) return;
309 this.update(tabId, {
310 navStatus: {
311 kind: "LoadFailed",
312 url: wc.getURL(),
313 title: wc.getTitle(),
314 code,
315 description,
316 },
317 });
318 };
319
320 wc.on("did-navigate", sync);
321 wc.on("did-navigate-in-page", sync);
322 wc.on("page-title-updated", sync);
323 wc.on("did-start-loading", sync);
324 wc.on("did-stop-loading", sync);
325 wc.on("did-fail-load", failed as never);
326
327 // Keep external links inside the same view (matches ami's policy).
328 wc.setWindowOpenHandler(({ url }) => {
329 void wc.loadURL(url);
330 return { action: "deny" };
331 });
332
333 // Forward app-level shortcuts to the main window so mod+shift+J etc.
334 // still toggles the preview panel even when the webview has focus.
335 wc.on("before-input-event", (event, input) => {
336 if (this.isAppShortcut(input) && this.mainWindow && !this.mainWindow.isDestroyed()) {
337 event.preventDefault();
338 this.mainWindow.webContents.sendInputEvent({
339 type: "keyDown",
340 keyCode: input.key,
341 modifiers: [
342 ...(input.meta ? (["meta"] as const) : []),
343 ...(input.shift ? (["shift"] as const) : []),
344 ...(input.control ? (["control"] as const) : []),
345 ...(input.alt ? (["alt"] as const) : []),
346 ],
347 });
348 }
349 });
350
351 this.attached.set(wc.id, { navigate: sync, failed });
352 }
353
354 private detachListeners(webContentsId: number): void {
355 const handlers = this.attached.get(webContentsId);
356 if (!handlers) return;
357 this.attached.delete(webContentsId);
358 const wc = webContents.fromId(webContentsId);
359 if (!wc || wc.isDestroyed()) return;
360 wc.off("did-navigate", handlers.navigate);
361 wc.off("did-navigate-in-page", handlers.navigate);
362 wc.off("page-title-updated", handlers.navigate);
363 wc.off("did-start-loading", handlers.navigate);
364 wc.off("did-stop-loading", handlers.navigate);
365 wc.off("did-fail-load", handlers.failed as never);
366 }
367
368 private isAppShortcut(input: Electron.Input): boolean {
369 if (input.type !== "keyDown") return false;
370 return APP_FORWARDED_SHORTCUTS.some(
371 (shortcut) =>
372 shortcut.key.toLowerCase() === input.key.toLowerCase() &&
373 shortcut.meta === input.meta &&
374 shortcut.shift === input.shift &&
375 shortcut.control === input.control,
376 );
377 }
378
379 private computeNavStatus(wc: Electron.WebContents): PreviewNavStatus {
380 const url = wc.getURL();
381 const title = wc.getTitle();
382 if (url === "" || url === "about:blank") return { kind: "Idle" };
383 if (wc.isLoading()) return { kind: "Loading", url, title };
384 return { kind: "Success", url, title };
385 }
386
387 private requireWebContents(tabId: string): Electron.WebContents {
388 const tab = this.tabs.get(tabId);
389 if (!tab) throw new PreviewTabNotFoundError(tabId);
390 if (tab.webContentsId == null) throw new PreviewWebviewNotInitializedError(tabId);
391 const wc = webContents.fromId(tab.webContentsId);
392 if (!wc) throw new PreviewWebContentsNotFoundError(tabId, tab.webContentsId);
393 return wc;
394 }
395
396 private update(tabId: string, patch: Partial<PreviewTabState>): void {
397 const current = this.tabs.get(tabId);
398 if (!current) return;
399 const next: PreviewTabState = {
400 ...current,
401 ...patch,
402 updatedAt: new Date().toISOString(),
403 };
404 this.tabs.set(tabId, next);
405 this.emit(tabId, next);
406 }
407
408 private emit(tabId: string, state: PreviewTabState): void {
409 for (const listener of this.listeners) {
410 try {
411 listener(tabId, state);
412 } catch {
413 // listener errors must not crash the manager
414 }
415 }
416 }
417
418 private normalizeUrl(input: string): string {
419 // Surface the shared error directly so the IPC caller (and any future
420 // desktop-side telemetry) gets the `detail` field for free. Defining a
421 // bespoke desktop class would just lose information.
422 return normalizePreviewUrl(input);
423 }
424}
425
426export class PreviewTabNotFoundError extends Error {
427 readonly tabId: string;
428 constructor(tabId: string) {
429 super(`Preview tab not found: ${tabId}`);
430 this.name = "PreviewTabNotFoundError";
431 this.tabId = tabId;
432 }
433}
434
435export class PreviewWebContentsNotFoundError extends Error {
436 readonly tabId: string;
437 readonly webContentsId: number;
438 constructor(tabId: string, webContentsId: number) {
439 super(`WebContents ${webContentsId} not found for preview tab ${tabId}`);
440 this.name = "PreviewWebContentsNotFoundError";
441 this.tabId = tabId;
442 this.webContentsId = webContentsId;
443 }
444}
445
446export class PreviewWebviewNotInitializedError extends Error {
447 readonly tabId: string;
448 constructor(tabId: string) {
449 super(`Preview tab "${tabId}" has no webview registered`);
450 this.name = "PreviewWebviewNotInitializedError";
451 this.tabId = tabId;
452 }
453}
454
455export const previewViewManager = new PreviewViewManager();
apps/desktop/src/window/DesktopWindow.ts+4
......@@ -11,6 +11,7 @@ import * as DesktopAssets from "../app/DesktopAssets.ts";
1111import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
1212import * as DesktopObservability from "../app/DesktopObservability.ts";
1313import * as DesktopState from "../app/DesktopState.ts";
14import { previewViewManager } from "../preview-view-manager.ts";
1415import * as ElectronMenu from "../electron/ElectronMenu.ts";
1516import * as ElectronShell from "../electron/ElectronShell.ts";
1617import * as ElectronTheme from "../electron/ElectronTheme.ts";
......@@ -192,9 +193,12 @@ const make = Effect.gen(function* () {
192193 contextIsolation: true,
193194 nodeIntegration: false,
194195 sandbox: true,
196 webviewTag: true,
195197 },
196198 });
197199
200 previewViewManager.setMainWindow(window);
201
198202 window.webContents.on("context-menu", (event, params) => {
199203 event.preventDefault();
200204
apps/server/src/preview/Layers/Manager.test.ts created+261
......@@ -0,0 +1,261 @@
1import { it } from "@effect/vitest";
2import { type PreviewEvent, ThreadId } from "@t3tools/contracts";
3import { Effect, PubSub } from "effect";
4import { expect } from "vitest";
5
6import { PreviewManager } from "../Services/Manager.ts";
7import { PreviewManagerLive } from "./Manager.ts";
8
9const DRAIN_LIMIT = 100;
10
11interface EventCollector {
12 /** Drain everything published since the last call (or since subscribe). */
13 readonly drain: Effect.Effect<ReadonlyArray<PreviewEvent>>;
14}
15
16/**
17 * Each `it.effect` shares the live PreviewManager layer across the whole
18 * `it.layer` block, so tests that assert per-thread counts must use a unique
19 * thread id to avoid bleeding state from earlier tests.
20 */
21let nextThreadId = 0;
22const freshThreadId = () => ThreadId.make(`thread-${++nextThreadId}`);
23
24/**
25 * Subscribe to the manager's event stream BEFORE the test publishes. We
26 * use `subscribeEvents` (synchronous PubSub.subscribe under the hood) so
27 * no event can land between subscribe and the consumer drain.
28 */
29const collectEvents = Effect.gen(function* () {
30 const manager = yield* PreviewManager;
31 const subscription = yield* manager.subscribeEvents;
32 const collector: EventCollector = {
33 drain: PubSub.takeUpTo(subscription, DRAIN_LIMIT),
34 };
35 return collector;
36}).pipe(Effect.withSpan("preview.test.collectEvents"));
37
38it.layer(PreviewManagerLive)("PreviewManager", (it) => {
39 it.effect("opens a session and emits opened with normalized URL", () =>
40 Effect.gen(function* () {
41 const threadId = freshThreadId();
42 const manager = yield* PreviewManager;
43 const collector = yield* collectEvents;
44
45 const snapshot = yield* manager.open({ threadId, url: "localhost:5173" });
46 expect(snapshot.tabId.startsWith("tab_")).toBe(true);
47 expect(snapshot.navStatus._tag).toBe("Loading");
48 if (snapshot.navStatus._tag === "Loading") {
49 expect(snapshot.navStatus.url).toBe("http://localhost:5173/");
50 }
51
52 const events = yield* collector.drain;
53 expect(events).toHaveLength(1);
54 expect(events[0]?.type).toBe("opened");
55 if (events[0]?.type === "opened") {
56 expect(events[0].tabId).toBe(snapshot.tabId);
57 }
58 }),
59 );
60
61 it.effect("opens an Idle tab when no URL is supplied", () =>
62 Effect.gen(function* () {
63 const threadId = freshThreadId();
64 const manager = yield* PreviewManager;
65 const snapshot = yield* manager.open({ threadId });
66 expect(snapshot.navStatus._tag).toBe("Idle");
67 }),
68 );
69
70 it.effect("treats bare hosts as https", () =>
71 Effect.gen(function* () {
72 const threadId = freshThreadId();
73 const manager = yield* PreviewManager;
74 const snapshot = yield* manager.open({ threadId, url: "example.com" });
75 if (snapshot.navStatus._tag === "Loading") {
76 expect(snapshot.navStatus.url).toBe("https://example.com/");
77 }
78 }),
79 );
80
81 it.effect("rejects empty URL with PreviewInvalidUrlError", () =>
82 Effect.gen(function* () {
83 const threadId = freshThreadId();
84 const manager = yield* PreviewManager;
85 const error = yield* Effect.flip(manager.open({ threadId, url: " " }));
86 expect(error._tag).toBe("PreviewInvalidUrlError");
87 }),
88 );
89
90 it.effect("navigate updates snapshot and emits navigated", () =>
91 Effect.gen(function* () {
92 const threadId = freshThreadId();
93 const manager = yield* PreviewManager;
94 const collector = yield* collectEvents;
95
96 const opened = yield* manager.open({ threadId, url: "http://localhost:5173" });
97 const snapshot = yield* manager.navigate({
98 threadId,
99 tabId: opened.tabId,
100 url: "http://localhost:5173/about",
101 resolvedTitle: "About",
102 });
103
104 expect(snapshot.navStatus._tag).toBe("Success");
105 if (snapshot.navStatus._tag === "Success") {
106 expect(snapshot.navStatus.url).toBe("http://localhost:5173/about");
107 expect(snapshot.navStatus.title).toBe("About");
108 }
109 const events = yield* collector.drain;
110 expect(events.map((e) => e.type)).toEqual(["opened", "navigated"]);
111 }),
112 );
113
114 it.effect("navigate fails for unknown tab", () =>
115 Effect.gen(function* () {
116 const threadId = freshThreadId();
117 const manager = yield* PreviewManager;
118 const error = yield* Effect.flip(
119 manager.navigate({
120 threadId,
121 tabId: "tab_missing",
122 url: "http://localhost:5173",
123 }),
124 );
125 expect(error._tag).toBe("PreviewSessionLookupError");
126 }),
127 );
128
129 it.effect("reportStatus emits failed for LoadFailed nav", () =>
130 Effect.gen(function* () {
131 const threadId = freshThreadId();
132 const manager = yield* PreviewManager;
133 const collector = yield* collectEvents;
134
135 const opened = yield* manager.open({ threadId, url: "http://localhost:5173" });
136 yield* manager.reportStatus({
137 threadId,
138 tabId: opened.tabId,
139 navStatus: {
140 _tag: "LoadFailed",
141 url: "http://localhost:5173",
142 title: "",
143 code: -105,
144 description: "ERR_NAME_NOT_RESOLVED",
145 },
146 canGoBack: false,
147 canGoForward: false,
148 });
149
150 const events = yield* collector.drain;
151 const failed = events.find((e) => e.type === "failed");
152 expect(failed?.type).toBe("failed");
153 if (failed?.type === "failed") {
154 expect(failed.code).toBe(-105);
155 expect(failed.description).toBe("ERR_NAME_NOT_RESOLVED");
156 }
157 }),
158 );
159
160 it.effect("close removes the session and emits closed", () =>
161 Effect.gen(function* () {
162 const threadId = freshThreadId();
163 const manager = yield* PreviewManager;
164 const collector = yield* collectEvents;
165
166 yield* manager.open({ threadId, url: "http://localhost:5173" });
167 yield* manager.close({ threadId });
168
169 const result = yield* manager.list({ threadId });
170 expect(result.sessions).toHaveLength(0);
171 const events = yield* collector.drain;
172 const closed = events.find((e) => e.type === "closed");
173 expect(closed?.type).toBe("closed");
174 }),
175 );
176
177 it.effect("close is idempotent for unknown threads", () =>
178 Effect.gen(function* () {
179 const threadId = freshThreadId();
180 const manager = yield* PreviewManager;
181 yield* manager.close({ threadId });
182 const result = yield* manager.list({ threadId });
183 expect(result.sessions).toHaveLength(0);
184 }),
185 );
186
187 it.effect("list returns every snapshot for the thread sorted by updatedAt", () =>
188 Effect.gen(function* () {
189 const threadId = freshThreadId();
190 const manager = yield* PreviewManager;
191 const first = yield* manager.open({ threadId, url: "http://localhost:5173" });
192 const second = yield* manager.open({ threadId, url: "http://localhost:3000" });
193 const result = yield* manager.list({ threadId });
194 expect(result.sessions).toHaveLength(2);
195 const ids = result.sessions.map((s) => s.tabId);
196 expect(ids).toContain(first.tabId);
197 expect(ids).toContain(second.tabId);
198 }),
199 );
200
201 it.effect("open creates an independent tab on every call", () =>
202 Effect.gen(function* () {
203 const threadId = freshThreadId();
204 const manager = yield* PreviewManager;
205 const collector = yield* collectEvents;
206
207 const a = yield* manager.open({ threadId, url: "http://localhost:5173" });
208 const b = yield* manager.open({ threadId, url: "http://localhost:3000/path" });
209
210 expect(a.tabId).not.toBe(b.tabId);
211 const list = yield* manager.list({ threadId });
212 expect(list.sessions).toHaveLength(2);
213
214 const events = yield* collector.drain;
215 expect(events.map((e) => e.type)).toEqual(["opened", "opened"]);
216 }),
217 );
218
219 it.effect("close with mismatching tabId is a no-op", () =>
220 Effect.gen(function* () {
221 const threadId = freshThreadId();
222 const manager = yield* PreviewManager;
223 yield* manager.open({ threadId, url: "http://localhost:5173" });
224 yield* manager.close({ threadId, tabId: "tab_missing" });
225
226 const list = yield* manager.list({ threadId });
227 expect(list.sessions).toHaveLength(1);
228 }),
229 );
230
231 it.effect("close with explicit tabId removes only that tab", () =>
232 Effect.gen(function* () {
233 const threadId = freshThreadId();
234 const manager = yield* PreviewManager;
235 const a = yield* manager.open({ threadId, url: "http://localhost:5173" });
236 const b = yield* manager.open({ threadId, url: "http://localhost:3000" });
237
238 yield* manager.close({ threadId, tabId: a.tabId });
239
240 const list = yield* manager.list({ threadId });
241 expect(list.sessions.map((s) => s.tabId)).toEqual([b.tabId]);
242 }),
243 );
244
245 it.effect("multiple subscribers receive every event independently", () =>
246 Effect.gen(function* () {
247 const threadId = freshThreadId();
248 const manager = yield* PreviewManager;
249 const aSub = yield* manager.subscribeEvents;
250 const bSub = yield* manager.subscribeEvents;
251
252 yield* manager.open({ threadId, url: "http://localhost:5173" });
253 yield* manager.open({ threadId, url: "http://localhost:3000" });
254
255 const aEvents = yield* PubSub.takeUpTo(aSub, DRAIN_LIMIT);
256 const bEvents = yield* PubSub.takeUpTo(bSub, DRAIN_LIMIT);
257 expect(aEvents.map((e) => e.type)).toEqual(["opened", "opened"]);
258 expect(bEvents.map((e) => e.type)).toEqual(["opened", "opened"]);
259 }),
260 );
261});
apps/server/src/preview/Layers/Manager.ts created+313
......@@ -0,0 +1,313 @@
1/**
2 * In-memory PreviewManager implementation.
3 *
4 * Sessions are keyed by `(threadId, tabId)`; a single thread can host
5 * multiple tabs (browser-style). `open` always creates a new tab — tab
6 * lifecycle is owned by the renderer.
7 *
8 * Events are published via Effect's `PubSub`, so subscriber failures are
9 * isolated from the publishing call (a closed WS subscriber queue cannot
10 * fail an in-progress `navigate()`).
11 */
12import {
13 type PreviewEvent,
14 PreviewInvalidUrlError,
15 type PreviewListResult,
16 PreviewSessionLookupError,
17 type PreviewSessionSnapshot,
18} from "@t3tools/contracts";
19import {
20 newPreviewTabId,
21 normalizePreviewUrl,
22 PreviewUrlNormalizationError,
23} from "@t3tools/shared/preview";
24import { Effect, Layer, PubSub, Stream, SynchronizedRef } from "effect";
25
26import { PreviewManager, type PreviewManagerShape } from "../Services/Manager.ts";
27
28interface PreviewSessionState {
29 readonly threadId: string;
30 readonly tabId: string;
31 readonly snapshot: PreviewSessionSnapshot;
32}
33
34interface ManagerState {
35 /** All sessions across every thread, keyed by `${threadId}\u0000${tabId}`. */
36 readonly sessions: ReadonlyMap<string, PreviewSessionState>;
37}
38
39const initialState: ManagerState = { sessions: new Map() };
40
41const compositeKey = (threadId: string, tabId: string): string => `${threadId}\u0000${tabId}`;
42
43const sessionsForThread = (
44 state: ManagerState,
45 threadId: string,
46): ReadonlyArray<PreviewSessionState> => {
47 const out: PreviewSessionState[] = [];
48 for (const session of state.sessions.values()) {
49 if (session.threadId === threadId) out.push(session);
50 }
51 return out;
52};
53
54const normalizeUrl = (rawUrl: string): Effect.Effect<string, PreviewInvalidUrlError> =>
55 Effect.try({
56 try: () => normalizePreviewUrl(rawUrl),
57 catch: (cause) =>
58 new PreviewInvalidUrlError({
59 rawUrl,
60 detail:
61 cause instanceof PreviewUrlNormalizationError
62 ? cause.detail
63 : cause instanceof Error
64 ? cause.message
65 : String(cause),
66 }),
67 });
68
69const buildLoadingSnapshot = (input: {
70 readonly threadId: string;
71 readonly tabId: string;
72 readonly url: string;
73 readonly title: string;
74}): PreviewSessionSnapshot => ({
75 threadId: input.threadId,
76 tabId: input.tabId,
77 navStatus: { _tag: "Loading", url: input.url, title: input.title },
78 canGoBack: false,
79 canGoForward: false,
80 updatedAt: new Date().toISOString(),
81});
82
83const buildIdleSnapshot = (input: {
84 readonly threadId: string;
85 readonly tabId: string;
86}): PreviewSessionSnapshot => ({
87 threadId: input.threadId,
88 tabId: input.tabId,
89 navStatus: { _tag: "Idle" },
90 canGoBack: false,
91 canGoForward: false,
92 updatedAt: new Date().toISOString(),
93});
94
95export const makePreviewManager = Effect.gen(function* () {
96 const stateRef = yield* SynchronizedRef.make<ManagerState>(initialState);
97 // Unbounded PubSub is fine here — events are tiny and we don't want to
98 // block publishers if a subscriber is slow. WS clients backpressure on
99 // their own queues downstream.
100 const eventsPubSub = yield* PubSub.unbounded<PreviewEvent>();
101 const events: Stream.Stream<PreviewEvent> = Stream.fromPubSub(eventsPubSub);
102
103 /**
104 * Atomic read-modify-write over the session for `(threadId, tabId)`. The
105 * mutator runs under the SynchronizedRef so concurrent writers cannot
106 * interleave. Lookup failures travel through the modify result so both
107 * branches yield the same `[A, S]` shape `modifyEffect` requires.
108 *
109 * The event is published INSIDE the lock so observers see events in the
110 * same order as the underlying state transitions. Publishing an unbounded
111 * PubSub is non-blocking, so this is cheap.
112 */
113 const mutateExistingSession = <R, E>(
114 threadId: string,
115 tabId: string,
116 mutator: (
117 session: PreviewSessionState,
118 ) => Effect.Effect<{ next: PreviewSessionState; emit: PreviewEvent | null; result: R }, E>,
119 ): Effect.Effect<R, E | PreviewSessionLookupError> => {
120 type ModifyResult =
121 | { kind: "fail"; error: PreviewSessionLookupError }
122 | { kind: "ok"; result: R };
123
124 return SynchronizedRef.modifyEffect(stateRef, (state) => {
125 const session = state.sessions.get(compositeKey(threadId, tabId));
126 if (!session) {
127 return Effect.succeed([
128 { kind: "fail", error: new PreviewSessionLookupError({ threadId, tabId }) },
129 state,
130 ] as readonly [ModifyResult, ManagerState]);
131 }
132 return mutator(session).pipe(
133 Effect.flatMap(({ next, emit, result }) =>
134 Effect.gen(function* () {
135 if (emit) yield* PubSub.publish(eventsPubSub, emit);
136 const sessions = new Map(state.sessions);
137 sessions.set(compositeKey(threadId, tabId), next);
138 return [{ kind: "ok", result } as ModifyResult, { sessions }] as readonly [
139 ModifyResult,
140 ManagerState,
141 ];
142 }),
143 ),
144 );
145 }).pipe(
146 Effect.flatMap((modify) =>
147 modify.kind === "fail" ? Effect.fail(modify.error) : Effect.succeed(modify.result),
148 ),
149 );
150 };
151
152 const open: PreviewManagerShape["open"] = (input) =>
153 Effect.gen(function* () {
154 const tabId = newPreviewTabId();
155 const snapshot = input.url
156 ? buildLoadingSnapshot({
157 threadId: input.threadId,
158 tabId,
159 url: yield* normalizeUrl(input.url),
160 title: "",
161 })
162 : buildIdleSnapshot({ threadId: input.threadId, tabId });
163 yield* SynchronizedRef.update(stateRef, (state) => {
164 const sessions = new Map(state.sessions);
165 sessions.set(compositeKey(input.threadId, tabId), {
166 threadId: input.threadId,
167 tabId,
168 snapshot,
169 });
170 return { sessions };
171 });
172 yield* PubSub.publish(eventsPubSub, {
173 type: "opened",
174 threadId: input.threadId,
175 tabId,
176 createdAt: snapshot.updatedAt,
177 snapshot,
178 });
179 return snapshot;
180 });
181
182 const navigate: PreviewManagerShape["navigate"] = (input) =>
183 Effect.gen(function* () {
184 const url = yield* normalizeUrl(input.url);
185 return yield* mutateExistingSession(input.threadId, input.tabId, (session) =>
186 Effect.sync(() => {
187 const previousTitle =
188 session.snapshot.navStatus._tag === "Idle" ? "" : session.snapshot.navStatus.title;
189 const resolvedTitle = input.resolvedTitle ?? previousTitle;
190 const snapshot: PreviewSessionSnapshot = {
191 threadId: session.threadId,
192 tabId: session.tabId,
193 navStatus: { _tag: "Success", url, title: resolvedTitle },
194 canGoBack: session.snapshot.canGoBack,
195 canGoForward: session.snapshot.canGoForward,
196 updatedAt: new Date().toISOString(),
197 };
198 return {
199 next: { ...session, snapshot },
200 emit: {
201 type: "navigated",
202 threadId: session.threadId,
203 tabId: session.tabId,
204 createdAt: snapshot.updatedAt,
205 snapshot,
206 },
207 result: snapshot,
208 };
209 }),
210 );
211 });
212
213 const reportStatus: PreviewManagerShape["reportStatus"] = (input) =>
214 mutateExistingSession(input.threadId, input.tabId, (session) =>
215 Effect.sync(() => {
216 const snapshot: PreviewSessionSnapshot = {
217 threadId: session.threadId,
218 tabId: session.tabId,
219 navStatus: input.navStatus,
220 canGoBack: input.canGoBack,
221 canGoForward: input.canGoForward,
222 updatedAt: new Date().toISOString(),
223 };
224 const emit: PreviewEvent =
225 input.navStatus._tag === "LoadFailed"
226 ? {
227 type: "failed",
228 threadId: session.threadId,
229 tabId: session.tabId,
230 createdAt: snapshot.updatedAt,
231 url: input.navStatus.url,
232 title: input.navStatus.title,
233 code: input.navStatus.code,
234 description: input.navStatus.description,
235 }
236 : {
237 type: "navigated",
238 threadId: session.threadId,
239 tabId: session.tabId,
240 createdAt: snapshot.updatedAt,
241 snapshot,
242 };
243 return {
244 next: { ...session, snapshot },
245 emit,
246 result: undefined as void,
247 };
248 }),
249 );
250
251 const refresh: PreviewManagerShape["refresh"] = (input) =>
252 // Verify the session exists; the desktop bridge handles the actual reload
253 // and will report progress back via `reportStatus`. No event emitted.
254 mutateExistingSession(input.threadId, input.tabId, (session) =>
255 Effect.succeed({ next: session, emit: null, result: undefined as void }),
256 );
257
258 const close: PreviewManagerShape["close"] = (input) =>
259 Effect.flatMap(
260 SynchronizedRef.modify(stateRef, (state) => {
261 const eventsToEmit: PreviewEvent[] = [];
262 const sessions = new Map(state.sessions);
263 const targets = input.tabId
264 ? [state.sessions.get(compositeKey(input.threadId, input.tabId))].filter(
265 (entry): entry is PreviewSessionState => entry !== undefined,
266 )
267 : sessionsForThread(state, input.threadId);
268 for (const target of targets) {
269 sessions.delete(compositeKey(target.threadId, target.tabId));
270 eventsToEmit.push({
271 type: "closed",
272 threadId: target.threadId,
273 tabId: target.tabId,
274 createdAt: new Date().toISOString(),
275 });
276 }
277 if (eventsToEmit.length === 0) {
278 return [eventsToEmit, state] as const;
279 }
280 return [eventsToEmit, { sessions }] as const;
281 }),
282 (events) =>
283 events.length === 0
284 ? Effect.void
285 : Effect.forEach(events, (event) => PubSub.publish(eventsPubSub, event), {
286 discard: true,
287 }),
288 );
289
290 const list: PreviewManagerShape["list"] = (input) =>
291 SynchronizedRef.get(stateRef).pipe(
292 Effect.map(
293 (state): PreviewListResult => ({
294 sessions: sessionsForThread(state, input.threadId)
295 .map((s) => s.snapshot)
296 .toSorted((a, b) => a.updatedAt.localeCompare(b.updatedAt)),
297 }),
298 ),
299 );
300
301 return {
302 open,
303 navigate,
304 reportStatus,
305 refresh,
306 close,
307 list,
308 events,
309 subscribeEvents: PubSub.subscribe(eventsPubSub),
310 } satisfies PreviewManagerShape;
311});
312
313export const PreviewManagerLive = Layer.effect(PreviewManager, makePreviewManager);
apps/server/src/preview/Layers/PortScanner.test.ts created+180
......@@ -0,0 +1,180 @@
1import * as net from "node:net";
2
3import { Effect } from "effect";
4import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
6import { PreviewPortScanner } from "../Services/PortScanner.ts";
7import { __testing, PreviewPortScannerLive } from "./PortScanner.ts";
8
9const { parseLsofOutput, parsePortFromLsofName, serversEqual } = __testing;
10
11describe("parsePortFromLsofName", () => {
12 it("parses *:port", () => {
13 expect(parsePortFromLsofName("*:5173")).toBe(5173);
14 });
15
16 it("parses 127.0.0.1:port", () => {
17 expect(parsePortFromLsofName("127.0.0.1:5173")).toBe(5173);
18 });
19
20 it("parses localhost:port", () => {
21 expect(parsePortFromLsofName("localhost:5173")).toBe(5173);
22 });
23
24 it("parses [::1]:port", () => {
25 expect(parsePortFromLsofName("[::1]:5173")).toBe(5173);
26 });
27
28 it("ignores non-local hosts", () => {
29 expect(parsePortFromLsofName("192.168.1.10:5173")).toBeNull();
30 });
31
32 it("strips trailing description", () => {
33 expect(parsePortFromLsofName("*:5173 (LISTEN)")).toBe(5173);
34 });
35
36 it("rejects garbage", () => {
37 expect(parsePortFromLsofName("")).toBeNull();
38 expect(parsePortFromLsofName("not-a-port")).toBeNull();
39 expect(parsePortFromLsofName("*:0")).toBeNull();
40 expect(parsePortFromLsofName("*:99999")).toBeNull();
41 });
42});
43
44describe("parseLsofOutput", () => {
45 it("parses a typical lsof -F pcn output", () => {
46 const sample = [
47 "p12345",
48 "cnode",
49 "n*:5173",
50 "p67890",
51 "cnext-server",
52 "n127.0.0.1:3000",
53 "n127.0.0.1:9229", // node debug port too — same process
54 "p13579",
55 "cChrome",
56 "n192.168.1.10:443", // not local — ignored
57 ].join("\n");
58
59 const servers = parseLsofOutput(sample);
60 expect(servers).toEqual([
61 {
62 host: "localhost",
63 port: 3000,
64 url: "http://localhost:3000",
65 processName: "next-server",
66 pid: 67890,
67 },
68 {
69 host: "localhost",
70 port: 5173,
71 url: "http://localhost:5173",
72 processName: "node",
73 pid: 12345,
74 },
75 {
76 host: "localhost",
77 port: 9229,
78 url: "http://localhost:9229",
79 processName: "next-server",
80 pid: 67890,
81 },
82 ]);
83 });
84
85 it("handles empty input", () => {
86 expect(parseLsofOutput("")).toEqual([]);
87 });
88
89 it("dedupes by host:port", () => {
90 const sample = ["p1", "cnode", "n*:5173", "n127.0.0.1:5173"].join("\n");
91 const servers = parseLsofOutput(sample);
92 expect(servers).toHaveLength(1);
93 expect(servers[0]?.port).toBe(5173);
94 });
95});
96
97describe("serversEqual", () => {
98 const a = {
99 host: "localhost",
100 port: 5173,
101 url: "http://localhost:5173",
102 processName: "node",
103 pid: 1,
104 };
105 it("returns true for identical lists", () => {
106 expect(serversEqual([a], [{ ...a }])).toBe(true);
107 });
108 it("returns false for different lengths", () => {
109 expect(serversEqual([a], [])).toBe(false);
110 });
111 it("returns false for different processName", () => {
112 expect(serversEqual([a], [{ ...a, processName: "other" }])).toBe(false);
113 });
114});
115
116/**
117 * Integration tests against a real TCP listener. We force the Windows code
118 * path (TCP-probe fallback) by monkey-patching `process.platform` for the
119 * duration of the test so we don't depend on `lsof` being installed.
120 */
121describe("PreviewPortScanner integration (TCP probe path)", () => {
122 let originalPlatform: NodeJS.Platform;
123 let server: net.Server;
124
125 beforeEach(async () => {
126 originalPlatform = process.platform;
127 Object.defineProperty(process, "platform", { value: "win32", configurable: true });
128 // 3001 is in COMMON_DEV_PORTS so the TCP-probe pass will check it.
129 server = net.createServer();
130 await new Promise<void>((resolve, reject) => {
131 server.once("error", reject);
132 server.listen(3001, "127.0.0.1", () => resolve());
133 });
134 });
135
136 afterEach(async () => {
137 Object.defineProperty(process, "platform", {
138 value: originalPlatform,
139 configurable: true,
140 });
141 await new Promise<void>((resolve) => server.close(() => resolve()));
142 });
143
144 it("scan() returns a server we just opened on a curated dev port", async () => {
145 const result = await Effect.runPromise(
146 Effect.scoped(
147 Effect.gen(function* () {
148 const scanner = yield* PreviewPortScanner;
149 return yield* scanner.scan();
150 }).pipe(Effect.provide(PreviewPortScannerLive)),
151 ),
152 );
153 const found = result.find((s) => s.port === 3001);
154 expect(found).toBeDefined();
155 expect(found?.host).toBe("localhost");
156 });
157
158 it("retain() drives an immediate broadcast to subscribers", async () => {
159 const received: number[] = [];
160 await Effect.runPromise(
161 Effect.scoped(
162 Effect.gen(function* () {
163 const scanner = yield* PreviewPortScanner;
164 const unsubscribe = yield* scanner.subscribe((servers) =>
165 Effect.sync(() => {
166 for (const server of servers) received.push(server.port);
167 }),
168 );
169 const release = yield* scanner.retain();
170 // The retain() pollTick is fire-and-forget within Effect, so wait
171 // a frame for the broadcast to land.
172 yield* Effect.sleep("100 millis");
173 unsubscribe();
174 release();
175 }).pipe(Effect.provide(PreviewPortScannerLive)),
176 ),
177 );
178 expect(received).toContain(3001);
179 });
180});
apps/server/src/preview/Layers/PortScanner.ts created+243
......@@ -0,0 +1,243 @@
1/**
2 * In-process PortScanner implementation.
3 *
4 * macOS/Linux: parses `lsof -iTCP -sTCP:LISTEN -P -n -F pcn` (-F output is a
5 * stable line-prefixed field format; this is the only `lsof` flag set we rely
6 * on).
7 *
8 * Windows / lsof missing: TCP-connects to a curated list of common dev ports
9 * on 127.0.0.1.
10 *
11 * Polling is reference-counted via `retain()`. A single layer-scoped fiber
12 * polls forever, but each tick is a no-op when the retain count is zero.
13 */
14import * as net from "node:net";
15
16import type { DiscoveredLocalServer } from "@t3tools/contracts";
17import { LSOF_LOCAL_HOST_TOKENS } from "@t3tools/shared/preview";
18import { Cause, Data, Duration, Effect, Layer, Ref, Schedule } from "effect";
19
20import { runProcess } from "../../processRunner.ts";
21import {
22 COMMON_DEV_PORTS,
23 PreviewPortScanner,
24 type PreviewPortScannerShape,
25} from "../Services/PortScanner.ts";
26
27const POLL_INTERVAL = Duration.seconds(3);
28const TCP_PROBE_TIMEOUT_MS = 200;
29const LSOF_TIMEOUT_MS = 5_000;
30
31type Listener = (servers: ReadonlyArray<DiscoveredLocalServer>) => Effect.Effect<void>;
32
33class LsofProbeError extends Data.TaggedError("LsofProbeError")<{
34 readonly cause: unknown;
35}> {}
36
37interface ScannerState {
38 readonly lastSnapshot: ReadonlyArray<DiscoveredLocalServer>;
39}
40
41const parseLsofOutput = (raw: string): ReadonlyArray<DiscoveredLocalServer> => {
42 const seen = new Map<string, DiscoveredLocalServer>();
43 let pid: number | null = null;
44 let processName: string | null = null;
45
46 for (const line of raw.split("\n")) {
47 if (line.length === 0) continue;
48 const tag = line.charAt(0);
49 const value = line.slice(1);
50 if (tag === "p") {
51 const parsed = Number.parseInt(value, 10);
52 pid = Number.isFinite(parsed) && parsed > 0 ? parsed : null;
53 processName = null;
54 continue;
55 }
56 if (tag === "c") {
57 processName = value.trim() || null;
58 continue;
59 }
60 if (tag === "n") {
61 const portMatch = parsePortFromLsofName(value);
62 if (portMatch == null) continue;
63 const url = `http://localhost:${portMatch}`;
64 const key = `localhost:${portMatch}`;
65 if (seen.has(key)) continue;
66 seen.set(key, {
67 host: "localhost",
68 port: portMatch,
69 url,
70 processName,
71 pid,
72 });
73 }
74 }
75
76 return Array.from(seen.values()).toSorted((a, b) => a.port - b.port);
77};
78
79const parsePortFromLsofName = (name: string): number | null => {
80 // Examples: "*:5173", "127.0.0.1:5173", "[::1]:5173", "localhost:5173",
81 // "192.168.1.10:5173 (LISTEN)" — we only care if the host part is local.
82 const trimmed = name.split(" ", 1)[0]?.trim() ?? "";
83 if (trimmed.length === 0) return null;
84 const lastColon = trimmed.lastIndexOf(":");
85 if (lastColon < 0) return null;
86 const hostPart = trimmed.slice(0, lastColon);
87 const portPart = trimmed.slice(lastColon + 1);
88 if (!LSOF_LOCAL_HOST_TOKENS.has(hostPart)) return null;
89 const port = Number.parseInt(portPart, 10);
90 if (!Number.isFinite(port) || port <= 0 || port >= 65536) return null;
91 return port;
92};
93
94const probeLsof = (): Effect.Effect<ReadonlyArray<DiscoveredLocalServer> | null> =>
95 Effect.tryPromise({
96 try: () =>
97 runProcess("lsof", ["-iTCP", "-sTCP:LISTEN", "-P", "-n", "-F", "pcn"], {
98 timeoutMs: LSOF_TIMEOUT_MS,
99 allowNonZeroExit: true,
100 maxBufferBytes: 1024 * 1024,
101 outputMode: "truncate",
102 }),
103 catch: (cause) => new LsofProbeError({ cause }),
104 }).pipe(
105 Effect.map((result) => parseLsofOutput(result.stdout)),
106 Effect.catch(() => Effect.succeed(null)),
107 );
108
109const probeTcpPort = (port: number): Promise<boolean> =>
110 new Promise((resolve) => {
111 const socket = new net.Socket();
112 let settled = false;
113 const finish = (ok: boolean) => {
114 if (settled) return;
115 settled = true;
116 socket.destroy();
117 resolve(ok);
118 };
119 socket.setTimeout(TCP_PROBE_TIMEOUT_MS);
120 socket.once("timeout", () => finish(false));
121 socket.once("error", () => finish(false));
122 socket.once("connect", () => finish(true));
123 socket.connect({ host: "127.0.0.1", port });
124 });
125
126const probeCommonPorts = (): Effect.Effect<ReadonlyArray<DiscoveredLocalServer>> =>
127 Effect.promise(async () => {
128 const results = await Promise.all(
129 COMMON_DEV_PORTS.map(async (port) => ({ port, listening: await probeTcpPort(port) })),
130 );
131 return results
132 .filter((r) => r.listening)
133 .map<DiscoveredLocalServer>((r) => ({
134 host: "localhost",
135 port: r.port,
136 url: `http://localhost:${r.port}`,
137 processName: null,
138 pid: null,
139 }));
140 });
141
142const serversEqual = (
143 left: ReadonlyArray<DiscoveredLocalServer>,
144 right: ReadonlyArray<DiscoveredLocalServer>,
145): boolean => {
146 if (left.length !== right.length) return false;
147 for (let i = 0; i < left.length; i += 1) {
148 const a = left[i];
149 const b = right[i];
150 if (!a || !b) return false;
151 if (
152 a.host !== b.host ||
153 a.port !== b.port ||
154 a.url !== b.url ||
155 a.processName !== b.processName ||
156 a.pid !== b.pid
157 ) {
158 return false;
159 }
160 }
161 return true;
162};
163
164export const makePreviewPortScanner = Effect.gen(function* () {
165 const stateRef = yield* Ref.make<ScannerState>({
166 lastSnapshot: [],
167 });
168 const listeners = new Set<Listener>();
169 // Plain integer because the release callback returned by `retain()` runs
170 // outside any Effect context (the WS subscriber's release path) and must
171 // be a synchronous side-effect-only function.
172 let retainCount = 0;
173
174 const scanOnce = (): Effect.Effect<ReadonlyArray<DiscoveredLocalServer>> =>
175 Effect.gen(function* () {
176 if (process.platform === "win32") {
177 return yield* probeCommonPorts();
178 }
179 const lsof = yield* probeLsof();
180 if (lsof !== null) return lsof;
181 return yield* probeCommonPorts();
182 });
183
184 const broadcast = (servers: ReadonlyArray<DiscoveredLocalServer>): Effect.Effect<void> =>
185 Effect.forEach(Array.from(listeners), (listener) => listener(servers), { discard: true });
186
187 const pollTick = Effect.gen(function* () {
188 if (retainCount <= 0) return;
189 const next = yield* scanOnce();
190 const state = yield* Ref.get(stateRef);
191 if (serversEqual(state.lastSnapshot, next)) return;
192 yield* Ref.update(stateRef, (s) => ({ ...s, lastSnapshot: next }));
193 yield* broadcast(next);
194 }).pipe(
195 Effect.catchCause((cause: Cause.Cause<never>) =>
196 Effect.logWarning("preview port scan failed", Cause.pretty(cause)),
197 ),
198 );
199
200 // Single layer-scoped polling fiber. Ticks are no-ops when no client is
201 // currently retained, so the cost is one Ref.get every POLL_INTERVAL.
202 yield* Effect.forkScoped(pollTick.pipe(Effect.repeat(Schedule.spaced(POLL_INTERVAL))));
203
204 const retain: PreviewPortScannerShape["retain"] = () =>
205 Effect.gen(function* () {
206 const wasIdle = retainCount === 0;
207 retainCount += 1;
208 if (wasIdle) {
209 // Run an immediate scan + broadcast so the new retainer doesn't have
210 // to wait up to POLL_INTERVAL for the first emission.
211 yield* pollTick;
212 }
213 let released = false;
214 return () => {
215 if (released) return;
216 released = true;
217 retainCount = Math.max(0, retainCount - 1);
218 };
219 });
220
221 const subscribe: PreviewPortScannerShape["subscribe"] = (listener) =>
222 Effect.sync(() => {
223 listeners.add(listener);
224 return () => {
225 listeners.delete(listener);
226 };
227 });
228
229 return {
230 scan: scanOnce,
231 subscribe,
232 retain,
233 } satisfies PreviewPortScannerShape;
234});
235
236export const PreviewPortScannerLive = Layer.effect(PreviewPortScanner, makePreviewPortScanner);
237
238/** Exposed for tests. */
239export const __testing = {
240 parseLsofOutput,
241 parsePortFromLsofName,
242 serversEqual,
243};
apps/server/src/preview/Services/Manager.ts created+94
......@@ -0,0 +1,94 @@
1/**
2 * PreviewManager - Per-thread browser preview session orchestration.
3 *
4 * Owns metadata-only preview sessions keyed by `(threadId, tabId)`. A thread
5 * can host multiple concurrent tabs (browser-style). The actual Chromium
6 * <webview> lives in the desktop renderer; this service tracks the canonical
7 * URL/title/nav state per tab so sessions survive reconnects and are visible
8 * to multiple connected clients.
9 *
10 * Event delivery uses Effect's `PubSub` so listener failures (e.g. a
11 * disconnected WS subscriber's queue being closed) cannot propagate back
12 * into the mutating call's failure channel — matches the codebase
13 * convention used by `SessionCredentialService`.
14 *
15 * @module PreviewManager
16 */
17import {
18 type PreviewCloseInput,
19 type PreviewError,
20 type PreviewEvent,
21 type PreviewListInput,
22 type PreviewListResult,
23 type PreviewNavigateInput,
24 type PreviewOpenInput,
25 type PreviewRefreshInput,
26 type PreviewReportStatusInput,
27 type PreviewSessionSnapshot,
28} from "@t3tools/contracts";
29import { Context, type Effect, type PubSub, type Scope, type Stream } from "effect";
30
31export interface PreviewManagerShape {
32 /**
33 * Open a brand new preview tab for `threadId`. When `url` is omitted the
34 * tab starts in the `Idle` state so the user can type into the URL bar;
35 * otherwise it transitions straight to `Loading`. Always emits `opened`.
36 */
37 readonly open: (input: PreviewOpenInput) => Effect.Effect<PreviewSessionSnapshot, PreviewError>;
38
39 /**
40 * Update the session's URL/title from the renderer (after the <webview>
41 * resolved navigation, including redirects). Emits a `navigated` event.
42 */
43 readonly navigate: (
44 input: PreviewNavigateInput,
45 ) => Effect.Effect<PreviewSessionSnapshot, PreviewError>;
46
47 /**
48 * Renderer reports current nav status (Loading/Success/LoadFailed) and
49 * back/forward availability. Drives `failed` and `navigated` events.
50 */
51 readonly reportStatus: (input: PreviewReportStatusInput) => Effect.Effect<void, PreviewError>;
52
53 /**
54 * Renderer requested a reload. Server records intent (no state change) so
55 * subscribers can reconcile if needed; the desktop bridge actually reloads.
56 */
57 readonly refresh: (input: PreviewRefreshInput) => Effect.Effect<void, PreviewError>;
58
59 /**
60 * Close the session (drop server-side state, emit `closed`). When `tabId`
61 * is omitted, closes every session for the thread.
62 */
63 readonly close: (input: PreviewCloseInput) => Effect.Effect<void, PreviewError>;
64
65 /**
66 * List active preview sessions for a thread. Returns an empty array when
67 * the thread has no sessions.
68 */
69 readonly list: (input: PreviewListInput) => Effect.Effect<PreviewListResult>;
70
71 /**
72 * Stream of preview events. Each subscriber gets its own buffered stream;
73 * subscriber failures are isolated and do not affect other subscribers
74 * or publishers.
75 *
76 * NOTE: `Stream.fromPubSub` defers `PubSub.subscribe` until the stream
77 * starts running, so `Stream.runForEach(...).pipe(Effect.forkScoped)` may
78 * miss a publish landing between fork and stream-start. Callers that
79 * cannot tolerate this gap should use `subscribeEvents` below.
80 */
81 readonly events: Stream.Stream<PreviewEvent>;
82
83 /**
84 * Acquire a PubSub subscription synchronously in the caller's fiber so
85 * no publish can land in the narrow gap between subscribe and consumer
86 * loop start. The subscription's lifetime is bound to the provided
87 * `Scope`; release happens automatically on scope close.
88 */
89 readonly subscribeEvents: Effect.Effect<PubSub.Subscription<PreviewEvent>, never, Scope.Scope>;
90}
91
92export class PreviewManager extends Context.Service<PreviewManager, PreviewManagerShape>()(
93 "t3/preview/Services/Manager/PreviewManager",
94) {}
apps/server/src/preview/Services/PortScanner.ts created+36
......@@ -0,0 +1,36 @@
1/**
2 * PreviewPortScanner - Discovers listening localhost ports for the preview
3 * empty-state recommendations.
4 *
5 * Reference-counted polling: the scanner only runs when at least one client
6 * has called `retain()`. This keeps idle desktops from running `lsof` every
7 * 3 seconds for nothing.
8 *
9 * @module PreviewPortScanner
10 */
11import type { DiscoveredLocalServer } from "@t3tools/contracts";
12import { Context, type Effect } from "effect";
13
14export interface PreviewPortScannerShape {
15 /** One-shot snapshot of currently listening localhost ports. */
16 readonly scan: () => Effect.Effect<ReadonlyArray<DiscoveredLocalServer>>;
17 /** Subscribe to changes. Listener invoked on every diff. Returns unsubscribe fn. */
18 readonly subscribe: (
19 listener: (servers: ReadonlyArray<DiscoveredLocalServer>) => Effect.Effect<void>,
20 ) => Effect.Effect<() => void>;
21 /**
22 * Hint that at least one client is interested → starts polling. Returns
23 * release fn. When release count hits 0, polling stops.
24 */
25 readonly retain: () => Effect.Effect<() => void>;
26}
27
28export class PreviewPortScanner extends Context.Service<
29 PreviewPortScanner,
30 PreviewPortScannerShape
31>()("t3/preview/Services/PortScanner") {}
32
33/** Curated list of common dev-server ports for the Windows TCP-probe fallback. */
34export const COMMON_DEV_PORTS: ReadonlyArray<number> = Object.freeze([
35 3000, 3001, 3333, 4173, 4200, 4321, 5000, 5173, 5174, 5175, 5500, 8000, 8080, 8081, 8888, 9000,
36]);
apps/server/src/server.test.ts+25
......@@ -20,6 +20,7 @@ import {
2020 type OrchestrationCommand,
2121 type OrchestrationEvent,
2222 ORCHESTRATION_WS_METHODS,
23 type PreviewEvent,
2324 ProjectId,
2425 ProviderDriverKind,
2526 ProviderInstanceId,
......@@ -48,6 +49,7 @@ import * as Layer from "effect/Layer";
4849import * as ManagedRuntime from "effect/ManagedRuntime";
4950import * as Option from "effect/Option";
5051import * as Path from "effect/Path";
52import * as PubSub from "effect/PubSub";
5153import * as Stream from "effect/Stream";
5254import { ChildProcessSpawner } from "effect/unstable/process";
5355import {
......@@ -97,6 +99,8 @@ import { ServerLifecycleEvents, type ServerLifecycleEventsShape } from "./server
9799import { ServerRuntimeStartup, type ServerRuntimeStartupShape } from "./serverRuntimeStartup.ts";
98100import { ServerSettingsService, type ServerSettingsShape } from "./serverSettings.ts";
99101import { TerminalManager, type TerminalManagerShape } from "./terminal/Services/Manager.ts";
102import { PreviewManager } from "./preview/Services/Manager.ts";
103import { PreviewPortScanner } from "./preview/Services/PortScanner.ts";
100104import {
101105 BrowserTraceCollector,
102106 type BrowserTraceCollectorShape,
......@@ -663,6 +667,27 @@ const buildAppUnderTest = (options?: {
663667 ...options?.layers?.terminalManager,
664668 }),
665669 ),
670 Layer.provide(
671 Layer.mock(PreviewManager)({
672 open: () => Effect.die("PreviewManager not stubbed in this test"),
673 navigate: () => Effect.die("PreviewManager not stubbed in this test"),
674 reportStatus: () => Effect.void,
675 refresh: () => Effect.void,
676 close: () => Effect.void,
677 list: () => Effect.succeed({ sessions: [] }),
678 events: Stream.empty,
679 subscribeEvents: Effect.flatMap(PubSub.unbounded<PreviewEvent>(), (pubsub) =>
680 PubSub.subscribe(pubsub),
681 ),
682 }),
683 ),
684 Layer.provide(
685 Layer.mock(PreviewPortScanner)({
686 scan: () => Effect.succeed([]),
687 subscribe: () => Effect.succeed(() => {}),
688 retain: () => Effect.succeed(() => {}),
689 }),
690 ),
666691 Layer.provide(
667692 Layer.mock(OrchestrationEngineService)({
668693 readEvents: () => Stream.empty,
apps/server/src/server.ts+5
......@@ -35,6 +35,8 @@ import * as GitLabCli from "./sourceControl/GitLabCli.ts";
3535import * as TextGeneration from "./textGeneration/TextGeneration.ts";
3636import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/ProviderInstanceRegistryHydration.ts";
3737import { TerminalManagerLive } from "./terminal/Layers/Manager.ts";
38import { PreviewManagerLive } from "./preview/Layers/Manager.ts";
39import { PreviewPortScannerLive } from "./preview/Layers/PortScanner.ts";
3840import * as GitManager from "./git/GitManager.ts";
3941import { KeybindingsLive } from "./keybindings.ts";
4042import { ServerRuntimeStartup, ServerRuntimeStartupLive } from "./serverRuntimeStartup.ts";
......@@ -233,6 +235,8 @@ const CheckpointingLayerLive = Layer.empty.pipe(
233235
234236const TerminalLayerLive = TerminalManagerLive.pipe(Layer.provide(PtyAdapterLive));
235237
238const PreviewLayerLive = Layer.mergeAll(PreviewManagerLive, PreviewPortScannerLive);
239
236240const WorkspaceEntriesLayerLive = WorkspaceEntriesLive.pipe(
237241 Layer.provide(WorkspacePathsLive),
238242 Layer.provideMerge(VcsDriverRegistryLayerLive),
......@@ -275,6 +279,7 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(
275279 Layer.provideMerge(VcsLayerLive),
276280 Layer.provideMerge(ProviderRuntimeLayerLive),
277281 Layer.provideMerge(TerminalLayerLive),
282 Layer.provideMerge(PreviewLayerLive),
278283 Layer.provideMerge(PersistenceLayerLive),
279284 Layer.provideMerge(KeybindingsLive),
280285 Layer.provideMerge(ProviderRegistryLive),
apps/server/src/ws.ts+62
......@@ -22,6 +22,7 @@ import {
2222 type AuthEnvironmentScope,
2323 AuthSessionId,
2424 CommandId,
25 type DiscoveredLocalServerList,
2526 EventId,
2627 type OrchestrationCommand,
2728 type GitActionProgressEvent,
......@@ -70,6 +71,8 @@ import { ServerLifecycleEvents } from "./serverLifecycleEvents.ts";
7071import { ServerRuntimeStartup } from "./serverRuntimeStartup.ts";
7172import { redactServerSettingsForClient, ServerSettingsService } from "./serverSettings.ts";
7273import { TerminalManager } from "./terminal/Services/Manager.ts";
74import { PreviewManager } from "./preview/Services/Manager.ts";
75import { PreviewPortScanner } from "./preview/Services/PortScanner.ts";
7376import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries.ts";
7477import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem.ts";
7578import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePaths.ts";
......@@ -240,6 +243,8 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) =>
240243 const vcsProvisioning = yield* VcsProvisioningService;
241244 const vcsStatusBroadcaster = yield* VcsStatusBroadcaster;
242245 const terminalManager = yield* TerminalManager;
246 const previewManager = yield* PreviewManager;
247 const previewPortScanner = yield* PreviewPortScanner;
243248 const providerRegistry = yield* ProviderRegistry;
244249 const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner;
245250 const config = yield* ServerConfig;
......@@ -1350,6 +1355,63 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) =>
13501355 ),
13511356 { "rpc.aggregate": "terminal" },
13521357 ),
1358 [WS_METHODS.previewOpen]: (input) =>
1359 observeRpcEffect(WS_METHODS.previewOpen, previewManager.open(input), {
1360 "rpc.aggregate": "preview",
1361 }),
1362 [WS_METHODS.previewNavigate]: (input) =>
1363 observeRpcEffect(WS_METHODS.previewNavigate, previewManager.navigate(input), {
1364 "rpc.aggregate": "preview",
1365 }),
1366 [WS_METHODS.previewRefresh]: (input) =>
1367 observeRpcEffect(WS_METHODS.previewRefresh, previewManager.refresh(input), {
1368 "rpc.aggregate": "preview",
1369 }),
1370 [WS_METHODS.previewClose]: (input) =>
1371 observeRpcEffect(WS_METHODS.previewClose, previewManager.close(input), {
1372 "rpc.aggregate": "preview",
1373 }),
1374 [WS_METHODS.previewList]: (input) =>
1375 observeRpcEffect(WS_METHODS.previewList, previewManager.list(input), {
1376 "rpc.aggregate": "preview",
1377 }),
1378 [WS_METHODS.previewReportStatus]: (input) =>
1379 observeRpcEffect(WS_METHODS.previewReportStatus, previewManager.reportStatus(input), {
1380 "rpc.aggregate": "preview",
1381 }),
1382 [WS_METHODS.subscribePreviewEvents]: (_input) =>
1383 observeRpcStream(WS_METHODS.subscribePreviewEvents, previewManager.events, {
1384 "rpc.aggregate": "preview",
1385 }),
1386 [WS_METHODS.subscribeDiscoveredLocalServers]: (_input) =>
1387 observeRpcStream(
1388 WS_METHODS.subscribeDiscoveredLocalServers,
1389 Stream.callback<DiscoveredLocalServerList>((queue) =>
1390 Effect.acquireRelease(
1391 Effect.gen(function* () {
1392 const release = yield* previewPortScanner.retain();
1393 const initial = yield* previewPortScanner.scan();
1394 yield* Queue.offer(queue, {
1395 servers: initial,
1396 scannedAt: new Date().toISOString(),
1397 });
1398 const unsubscribe = yield* previewPortScanner.subscribe((servers) =>
1399 Queue.offer(queue, {
1400 servers,
1401 scannedAt: new Date().toISOString(),
1402 }),
1403 );
1404 return { unsubscribe, release };
1405 }),
1406 ({ unsubscribe, release }) =>
1407 Effect.sync(() => {
1408 unsubscribe();
1409 release();
1410 }),
1411 ),
1412 ),
1413 { "rpc.aggregate": "preview" },
1414 ),
13531415 [WS_METHODS.subscribeServerConfig]: (_input) =>
13541416 observeRpcStreamEffect(
13551417 WS_METHODS.subscribeServerConfig,
apps/web/package.json+1
......@@ -64,6 +64,7 @@
6464 "babel-plugin-react-compiler": "1.0.0",
6565 "msw": "2.12.11",
6666 "playwright": "^1.58.2",
67 "react-grab": "^0.1.32",
6768 "tailwindcss": "^4.0.0",
6869 "vite": "catalog:",
6970 "vite-plus": "catalog:",
apps/web/src/components/ChatView.browser.tsx+20
......@@ -268,6 +268,26 @@ function createMockEnvironmentApi(input: {
268268 subscribeThread: (() => () =>
269269 undefined) as EnvironmentApi["orchestration"]["subscribeThread"],
270270 },
271 preview: {
272 open: () => {
273 throw new Error("Not implemented in browser test.");
274 },
275 navigate: () => {
276 throw new Error("Not implemented in browser test.");
277 },
278 refresh: () => {
279 throw new Error("Not implemented in browser test.");
280 },
281 close: () => {
282 throw new Error("Not implemented in browser test.");
283 },
284 list: () => Promise.resolve({ sessions: [] }),
285 reportStatus: () => {
286 throw new Error("Not implemented in browser test.");
287 },
288 onEvent: () => () => undefined,
289 subscribePorts: () => () => undefined,
290 } as EnvironmentApi["preview"],
271291 };
272292}
273293
apps/web/src/components/ChatView.tsx+130-24
......@@ -36,7 +36,7 @@ import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/proje
3636import { truncate } from "@t3tools/shared/String";
3737import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels";
3838import { Debouncer } from "@tanstack/react-pacer";
39import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
39import { lazy, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
4040import { useNavigate, useSearch } from "@tanstack/react-router";
4141import { useShallow } from "zustand/react/shallow";
4242import { useVcsStatus } from "~/lib/vcsStatusState";
......@@ -98,6 +98,14 @@ import { useCommandPaletteStore } from "../commandPaletteStore";
9898import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git";
9999import { useMediaQuery } from "../hooks/useMediaQuery";
100100import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout";
101import { selectActiveRightPanelKindWithUrl, useRightPanelStore } from "../rightPanelStore";
102import { isPreviewSupportedInRuntime } from "../previewStateStore";
103import { subscribePreviewAction } from "./preview/previewActionBus";
104// Lazy: keeps the entire preview component graph (webview host, favicon
105// helper, Chromium error icon) out of the web bundle until first open.
106const PreviewPanel = lazy(() =>
107 import("./preview/PreviewPanel").then((mod) => ({ default: mod.PreviewPanel })),
108);
101109import { BranchToolbar } from "./BranchToolbar";
102110import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";
103111import PlanSidebar from "./PlanSidebar";
......@@ -910,7 +918,6 @@ export default function ChatView(props: ChatViewProps) {
910918 >({});
911919 const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] =
912920 useState<Record<string, number>>({});
913 const [planSidebarOpen, setPlanSidebarOpen] = useState(false);
914921 const shouldUsePlanSidebarSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY);
915922 // Tracks whether the user explicitly dismissed the sidebar for the active turn.
916923 const planSidebarDismissedForTurnRef = useRef<string | null>(null);
......@@ -1045,7 +1052,6 @@ export default function ChatView(props: ChatViewProps) {
10451052 [activeThread],
10461053 );
10471054 const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null;
1048
10491055 useEffect(() => {
10501056 if (!activeThreadRef) {
10511057 return;
......@@ -1068,7 +1074,11 @@ export default function ChatView(props: ChatViewProps) {
10681074 reconcileTerminalIds,
10691075 terminalUiState.terminalIds,
10701076 ]);
1071
1077 const activeRightPanelKind = useRightPanelStore((store) =>
1078 selectActiveRightPanelKindWithUrl(store.byThreadKey, activeThreadRef, diffOpen),
1079 );
1080 const planSidebarOpen = activeRightPanelKind === "plan";
1081 const previewPanelOpen = activeRightPanelKind === "preview" && isPreviewSupportedInRuntime();
10721082 const existingOpenTerminalThreadKeys = useMemo(() => {
10731083 const existingThreadKeys = new Set<string>([...serverThreadKeys, ...draftThreadKeys]);
10741084 return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey));
......@@ -1925,12 +1935,47 @@ export default function ChatView(props: ChatViewProps) {
19251935 () => shortcutLabelForCommand(keybindings, "diff.toggle", nonTerminalShortcutLabelOptions),
19261936 [keybindings, nonTerminalShortcutLabelOptions],
19271937 );
1938 const previewPanelShortcutLabel = useMemo(
1939 () => shortcutLabelForCommand(keybindings, "preview.toggle", nonTerminalShortcutLabelOptions),
1940 [keybindings, nonTerminalShortcutLabelOptions],
1941 );
1942 // Right-panel arbitration:
1943 // - The diff panel's openness is mirrored by the `?diff=1` URL search
1944 // param so it deep-links cleanly. The store still records preview/plan
1945 // openness; when both fight, `selectActiveRightPanelKindWithUrl` lets
1946 // diff win (URL is truth).
1947 // - The two toggles below treat the panels as mutually exclusive: opening
1948 // one always tears down the other in BOTH the URL and the store. Without
1949 // this, e.g. clicking the browser button while diff is URL-pinned would
1950 // just toggle the (overridden) store value and look like a no-op.
1951 const onTogglePreview = useCallback(() => {
1952 if (!activeThreadRef) return;
1953 if (previewPanelOpen) {
1954 useRightPanelStore.getState().close(activeThreadRef);
1955 return;
1956 }
1957 if (diffOpen) {
1958 void navigate({
1959 to: "/$environmentId/$threadId",
1960 params: { environmentId, threadId },
1961 replace: true,
1962 search: (previous) => ({ ...stripDiffSearchParams(previous), diff: undefined }),
1963 });
1964 }
1965 useRightPanelStore.getState().open(activeThreadRef, "preview");
1966 }, [activeThreadRef, diffOpen, environmentId, navigate, previewPanelOpen, threadId]);
19281967 const onToggleDiff = useCallback(() => {
19291968 if (!isServerThread) {
19301969 return;
19311970 }
19321971 if (!diffOpen) {
19331972 onDiffPanelOpen?.();
1973 // Switching to diff: drop whatever the store had (e.g. preview) so
1974 // that when the user later closes diff, an old store entry doesn't
1975 // unexpectedly resurface.
1976 if (activeThreadRef) {
1977 useRightPanelStore.getState().close(activeThreadRef);
1978 }
19341979 }
19351980 void navigate({
19361981 to: "/$environmentId/$threadId",
......@@ -1944,7 +1989,25 @@ export default function ChatView(props: ChatViewProps) {
19441989 return diffOpen ? { ...rest, diff: undefined } : { ...rest, diff: "1" };
19451990 },
19461991 });
1947 }, [diffOpen, environmentId, isServerThread, navigate, onDiffPanelOpen, threadId]);
1992 }, [
1993 activeThreadRef,
1994 diffOpen,
1995 environmentId,
1996 isServerThread,
1997 navigate,
1998 onDiffPanelOpen,
1999 threadId,
2000 ]);
2001
2002 // Route the global mod+shift+J shortcut (dispatched from `routes/_chat.tsx`
2003 // via `previewActionBus`) through the URL-aware toggle defined above.
2004 useEffect(() => {
2005 return subscribePreviewAction((action) => {
2006 if (action !== "toggle-panel") return;
2007 if (!isPreviewSupportedInRuntime()) return;
2008 onTogglePreview();
2009 });
2010 }, [onTogglePreview]);
19482011
19492012 const envLocked = Boolean(
19502013 activeThread &&
......@@ -2223,6 +2286,19 @@ export default function ChatView(props: ChatViewProps) {
22232286 terminalId: targetTerminalId,
22242287 data: `${script.command}\r`,
22252288 });
2289 if (
2290 script.autoOpenPreview &&
2291 script.previewUrl &&
2292 isPreviewSupportedInRuntime() &&
2293 activeThreadRef
2294 ) {
2295 try {
2296 await api.preview.open({ threadId: activeThreadId, url: script.previewUrl });
2297 useRightPanelStore.getState().open(activeThreadRef, "preview");
2298 } catch {
2299 // Preview open failures are surfaced via the panel itself.
2300 }
2301 }
22262302 } catch (error) {
22272303 setThreadError(
22282304 activeThreadId,
......@@ -2295,6 +2371,8 @@ export default function ChatView(props: ChatViewProps) {
22952371 command: input.command,
22962372 icon: input.icon,
22972373 runOnWorktreeCreate: input.runOnWorktreeCreate,
2374 ...(input.previewUrl ? { previewUrl: input.previewUrl } : {}),
2375 ...(input.autoOpenPreview ? { autoOpenPreview: input.autoOpenPreview } : {}),
22982376 };
22992377 const nextScripts = input.runOnWorktreeCreate
23002378 ? [
......@@ -2330,6 +2408,10 @@ export default function ChatView(props: ChatViewProps) {
23302408 command: input.command,
23312409 icon: input.icon,
23322410 runOnWorktreeCreate: input.runOnWorktreeCreate,
2411 ...(input.previewUrl ? { previewUrl: input.previewUrl } : { previewUrl: undefined }),
2412 ...(input.autoOpenPreview
2413 ? { autoOpenPreview: input.autoOpenPreview }
2414 : { autoOpenPreview: undefined }),
23332415 };
23342416 const nextScripts = activeProject.scripts.map((script) =>
23352417 script.id === scriptId
......@@ -2424,21 +2506,25 @@ export default function ChatView(props: ChatViewProps) {
24242506 handleInteractionModeChange(interactionMode === "plan" ? "default" : "plan");
24252507 }, [handleInteractionModeChange, interactionMode]);
24262508 const togglePlanSidebar = useCallback(() => {
2427 setPlanSidebarOpen((open) => {
2428 if (open) {
2429 planSidebarDismissedForTurnRef.current =
2430 activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__";
2431 } else {
2432 planSidebarDismissedForTurnRef.current = null;
2433 }
2434 return !open;
2435 });
2436 }, [activePlan?.turnId, sidebarProposedPlan?.turnId]);
2509 if (!activeThreadRef) return;
2510 if (planSidebarOpen) {
2511 planSidebarDismissedForTurnRef.current =
2512 activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__";
2513 } else {
2514 planSidebarDismissedForTurnRef.current = null;
2515 }
2516 useRightPanelStore.getState().toggle(activeThreadRef, "plan");
2517 }, [activePlan?.turnId, activeThreadRef, planSidebarOpen, sidebarProposedPlan?.turnId]);
24372518 const closePlanSidebar = useCallback(() => {
2438 setPlanSidebarOpen(false);
2519 if (!activeThreadRef) return;
2520 useRightPanelStore.getState().close(activeThreadRef);
24392521 planSidebarDismissedForTurnRef.current =
24402522 activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__";
2441 }, [activePlan?.turnId, sidebarProposedPlan?.turnId]);
2523 }, [activePlan?.turnId, activeThreadRef, sidebarProposedPlan?.turnId]);
2524 const closePreviewPanel = useCallback(() => {
2525 if (!activeThreadRef) return;
2526 useRightPanelStore.getState().close(activeThreadRef);
2527 }, [activeThreadRef]);
24422528
24432529 const persistThreadSettingsForNextTurn = useCallback(
24442530 async (input: {
......@@ -2523,12 +2609,12 @@ export default function ChatView(props: ChatViewProps) {
25232609 setShowScrollToBottom(false);
25242610 if (planSidebarOpenOnNextThreadRef.current) {
25252611 planSidebarOpenOnNextThreadRef.current = false;
2526 setPlanSidebarOpen(true);
2527 } else {
2528 planSidebarOpenOnNextThreadRef.current = false;
2529 setPlanSidebarOpen(false);
2612 if (activeThreadRef) {
2613 useRightPanelStore.getState().open(activeThreadRef, "plan");
2614 }
25302615 }
25312616 planSidebarDismissedForTurnRef.current = null;
2617 // eslint-disable-next-line react-hooks/exhaustive-deps -- activeThreadRef is reset transitively
25322618 }, [activeThread?.id]);
25332619
25342620 // Auto-open the plan sidebar when plan/todo steps arrive for the current turn.
......@@ -2541,10 +2627,13 @@ export default function ChatView(props: ChatViewProps) {
25412627 if (latestTurnId && activePlan.turnId !== latestTurnId) return;
25422628 const turnKey = activePlan.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__";
25432629 if (planSidebarDismissedForTurnRef.current === turnKey) return;
2544 setPlanSidebarOpen(true);
2630 if (activeThreadRef) {
2631 useRightPanelStore.getState().open(activeThreadRef, "plan");
2632 }
25452633 }, [
25462634 activePlan,
25472635 activeLatestTurn?.turnId,
2636 activeThreadRef,
25482637 autoOpenPlanSidebar,
25492638 planSidebarOpen,
25502639 sidebarProposedPlan?.turnId,
......@@ -3462,7 +3551,9 @@ export default function ChatView(props: ChatViewProps) {
34623551 // step-tracking activities that the sidebar will display.
34633552 if (nextInteractionMode === "default" && autoOpenPlanSidebar) {
34643553 planSidebarDismissedForTurnRef.current = null;
3465 setPlanSidebarOpen(true);
3554 if (activeThreadRef) {
3555 useRightPanelStore.getState().open(activeThreadRef, "plan");
3556 }
34663557 }
34673558 sendInFlightRef.current = false;
34683559 } catch (err) {
......@@ -3828,6 +3919,9 @@ export default function ChatView(props: ChatViewProps) {
38283919 terminalOpen={terminalUiState.terminalOpen}
38293920 terminalToggleShortcutLabel={terminalToggleShortcutLabel}
38303921 diffToggleShortcutLabel={diffPanelShortcutLabel}
3922 previewAvailable={isPreviewSupportedInRuntime()}
3923 previewOpen={previewPanelOpen}
3924 previewToggleShortcutLabel={previewPanelShortcutLabel}
38313925 gitCwd={gitCwd}
38323926 diffOpen={diffOpen}
38333927 onRunProjectScript={runProjectScript}
......@@ -3835,6 +3929,7 @@ export default function ChatView(props: ChatViewProps) {
38353929 onUpdateProjectScript={updateProjectScript}
38363930 onDeleteProjectScript={deleteProjectScript}
38373931 onToggleTerminal={toggleTerminalVisibility}
3932 onTogglePreview={onTogglePreview}
38383933 onToggleDiff={onToggleDiff}
38393934 />
38403935 </header>
......@@ -4020,7 +4115,6 @@ export default function ChatView(props: ChatViewProps) {
40204115 </div>
40214116 {/* end chat column */}
40224117
4023 {/* Plan sidebar */}
40244118 {planSidebarOpen && !shouldUsePlanSidebarSheet ? (
40254119 <PlanSidebar
40264120 activePlan={activePlan}
......@@ -4034,6 +4128,11 @@ export default function ChatView(props: ChatViewProps) {
40344128 onClose={closePlanSidebar}
40354129 />
40364130 ) : null}
4131 {previewPanelOpen && !shouldUsePlanSidebarSheet && activeThreadRef ? (
4132 <Suspense fallback={null}>
4133 <PreviewPanel mode="inline" threadRef={activeThreadRef} visible />
4134 </Suspense>
4135 ) : null}
40374136 </div>
40384137 {/* end horizontal flex container */}
40394138
......@@ -4054,6 +4153,13 @@ export default function ChatView(props: ChatViewProps) {
40544153 onAddTerminalContext={addTerminalContextToDraft}
40554154 />
40564155 ))}
4156 {shouldUsePlanSidebarSheet && previewPanelOpen && activeThreadRef ? (
4157 <RightPanelSheet open onClose={closePreviewPanel}>
4158 <Suspense fallback={null}>
4159 <PreviewPanel mode="sheet" threadRef={activeThreadRef} visible />
4160 </Suspense>
4161 </RightPanelSheet>
4162 ) : null}
40574163 {shouldUsePlanSidebarSheet ? (
40584164 <RightPanelSheet open={planSidebarOpen} onClose={closePlanSidebar}>
40594165 <PlanSidebar
apps/web/src/components/ProjectScriptsControl.tsx+39
......@@ -85,6 +85,10 @@ export interface NewProjectScriptInput {
8585 icon: ProjectScriptIcon;
8686 runOnWorktreeCreate: boolean;
8787 keybinding: string | null;
88 /** Optional URL to open in the in-app preview when this script runs. */
89 previewUrl: string | null;
90 /** When true, automatically open the preview panel pointed at `previewUrl`. */
91 autoOpenPreview: boolean;
8892}
8993
9094interface ProjectScriptsControlProps {
......@@ -115,6 +119,8 @@ export default function ProjectScriptsControl({
115119 const [iconPickerOpen, setIconPickerOpen] = useState(false);
116120 const [runOnWorktreeCreate, setRunOnWorktreeCreate] = useState(false);
117121 const [keybinding, setKeybinding] = useState("");
122 const [previewUrl, setPreviewUrl] = useState("");
123 const [autoOpenPreview, setAutoOpenPreview] = useState(false);
118124 const [validationError, setValidationError] = useState<string | null>(null);
119125 const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
120126
......@@ -166,12 +172,15 @@ export default function ProjectScriptsControl({
166172 keybinding,
167173 command: commandForProjectScript(scriptIdForValidation),
168174 });
175 const trimmedPreviewUrl = previewUrl.trim();
169176 const payload = {
170177 name: trimmedName,
171178 command: trimmedCommand,
172179 icon,
173180 runOnWorktreeCreate,
174181 keybinding: keybindingRule?.key ?? null,
182 previewUrl: trimmedPreviewUrl.length > 0 ? trimmedPreviewUrl : null,
183 autoOpenPreview: trimmedPreviewUrl.length > 0 ? autoOpenPreview : false,
175184 } satisfies NewProjectScriptInput;
176185 if (editingScriptId) {
177186 await onUpdateScript(editingScriptId, payload);
......@@ -193,6 +202,8 @@ export default function ProjectScriptsControl({
193202 setIconPickerOpen(false);
194203 setRunOnWorktreeCreate(false);
195204 setKeybinding("");
205 setPreviewUrl("");
206 setAutoOpenPreview(false);
196207 setValidationError(null);
197208 setDialogOpen(true);
198209 };
......@@ -205,6 +216,8 @@ export default function ProjectScriptsControl({
205216 setIconPickerOpen(false);
206217 setRunOnWorktreeCreate(script.runOnWorktreeCreate);
207218 setKeybinding(keybindingValueForCommand(keybindings, commandForProjectScript(script.id)) ?? "");
219 setPreviewUrl(script.previewUrl ?? "");
220 setAutoOpenPreview(script.autoOpenPreview ?? false);
208221 setValidationError(null);
209222 setDialogOpen(true);
210223 };
......@@ -327,6 +340,8 @@ export default function ProjectScriptsControl({
327340 setIcon("play");
328341 setRunOnWorktreeCreate(false);
329342 setKeybinding("");
343 setPreviewUrl("");
344 setAutoOpenPreview(false);
330345 setValidationError(null);
331346 }}
332347 open={dialogOpen}
......@@ -413,6 +428,18 @@ export default function ProjectScriptsControl({
413428 onChange={(event) => setCommand(event.target.value)}
414429 />
415430 </div>
431 <div className="space-y-1.5">
432 <Label htmlFor="script-preview-url">Preview URL (optional)</Label>
433 <Input
434 id="script-preview-url"
435 placeholder="http://localhost:5173"
436 value={previewUrl}
437 onChange={(event) => setPreviewUrl(event.target.value)}
438 />
439 <p className="text-xs text-muted-foreground">
440 Open this URL in the in-app preview when this action runs.
441 </p>
442 </div>
416443 <label className="flex items-center justify-between gap-3 rounded-md border border-border/70 px-3 py-2 text-sm">
417444 <span>Run automatically on worktree creation</span>
418445 <Switch
......@@ -420,6 +447,18 @@ export default function ProjectScriptsControl({
420447 onCheckedChange={(checked) => setRunOnWorktreeCreate(Boolean(checked))}
421448 />
422449 </label>
450 <label
451 className={`flex items-center justify-between gap-3 rounded-md border border-border/70 px-3 py-2 text-sm ${
452 previewUrl.trim().length === 0 ? "opacity-60" : ""
453 }`}
454 >
455 <span>Open preview automatically when this action runs</span>
456 <Switch
457 checked={autoOpenPreview}
458 disabled={previewUrl.trim().length === 0}
459 onCheckedChange={(checked) => setAutoOpenPreview(Boolean(checked))}
460 />
461 </label>
423462 {validationError && <p className="text-sm text-destructive">{validationError}</p>}
424463 </form>
425464 </DialogPanel>
apps/web/src/components/ThreadTerminalDrawer.tsx+21-5
......@@ -48,6 +48,7 @@ import {
4848import { readEnvironmentApi } from "~/environmentApi";
4949import { readLocalApi } from "~/localApi";
5050import { attachTerminalSession } from "../terminalSessionState";
51import { openTerminalLinkInPreview } from "./preview/openTerminalLinkInPreview";
5152
5253const MIN_DRAWER_HEIGHT = 180;
5354const MAX_DRAWER_HEIGHT_RATIO = 0.75;
......@@ -509,11 +510,26 @@ export function TerminalViewport({
509510 }
510511
511512 if (match.kind === "url") {
512 void localApi.shell.openExternal(match.text).catch((error: unknown) => {
513 writeSystemMessage(
514 latestTerminal,
515 error instanceof Error ? error.message : "Unable to open link",
516 );
513 const fallbackToBrowser = () => {
514 void localApi.shell.openExternal(match.text).catch((error: unknown) => {
515 writeSystemMessage(
516 latestTerminal,
517 error instanceof Error ? error.message : "Unable to open link",
518 );
519 });
520 };
521 const api = readEnvironmentApi(threadRef.environmentId);
522 if (!api) {
523 fallbackToBrowser();
524 return;
525 }
526 void openTerminalLinkInPreview({
527 url: match.text,
528 position: { x: event.clientX, y: event.clientY },
529 threadRef,
530 api,
531 localApi,
532 fallbackToBrowser,
517533 });
518534 return;
519535 }
apps/web/src/components/chat/ChatHeader.tsx+34-1
......@@ -9,7 +9,8 @@ import { scopeThreadRef } from "@t3tools/client-runtime";
99import { memo } from "react";
1010import GitActionsControl from "../GitActionsControl";
1111import { type DraftId } from "~/composerDraftStore";
12import { DiffIcon, TerminalSquareIcon } from "lucide-react";
12import { DiffIcon, Globe, TerminalSquareIcon } from "lucide-react";
13import { Badge } from "../ui/badge";
1314import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
1415import ProjectScriptsControl, { type NewProjectScriptInput } from "../ProjectScriptsControl";
1516import { Toggle } from "../ui/toggle";
......@@ -33,6 +34,10 @@ interface ChatHeaderProps {
3334 terminalOpen: boolean;
3435 terminalToggleShortcutLabel: string | null;
3536 diffToggleShortcutLabel: string | null;
37 /** False on the web build; true only when the desktop preview bridge is present. */
38 previewAvailable: boolean;
39 previewOpen: boolean;
40 previewToggleShortcutLabel: string | null;
3641 gitCwd: string | null;
3742 diffOpen: boolean;
3843 onRunProjectScript: (script: ProjectScript) => void;
......@@ -40,6 +45,7 @@ interface ChatHeaderProps {
4045 onUpdateProjectScript: (scriptId: string, input: NewProjectScriptInput) => Promise<void>;
4146 onDeleteProjectScript: (scriptId: string) => Promise<void>;
4247 onToggleTerminal: () => void;
48 onTogglePreview: () => void;
4349 onToggleDiff: () => void;
4450}
4551
......@@ -71,6 +77,9 @@ export const ChatHeader = memo(function ChatHeader({
7177 terminalOpen,
7278 terminalToggleShortcutLabel,
7379 diffToggleShortcutLabel,
80 previewAvailable,
81 previewOpen,
82 previewToggleShortcutLabel,
7483 gitCwd,
7584 diffOpen,
7685 onRunProjectScript,
......@@ -78,6 +87,7 @@ export const ChatHeader = memo(function ChatHeader({
7887 onUpdateProjectScript,
7988 onDeleteProjectScript,
8089 onToggleTerminal,
90 onTogglePreview,
8191 onToggleDiff,
8292}: ChatHeaderProps) {
8393 const primaryEnvironmentId = usePrimaryEnvironmentId();
......@@ -155,6 +165,29 @@ export const ChatHeader = memo(function ChatHeader({
155165 : "Toggle terminal drawer"}
156166 </TooltipPopup>
157167 </Tooltip>
168 {previewAvailable ? (
169 <Tooltip>
170 <TooltipTrigger
171 render={
172 <Toggle
173 className="shrink-0"
174 pressed={previewOpen}
175 onPressedChange={onTogglePreview}
176 aria-label="Toggle preview browser"
177 variant="outline"
178 size="xs"
179 >
180 <Globe className="size-3" />
181 </Toggle>
182 }
183 />
184 <TooltipPopup side="bottom">
185 {previewToggleShortcutLabel
186 ? `Toggle preview browser (${previewToggleShortcutLabel})`
187 : "Toggle preview browser"}
188 </TooltipPopup>
189 </Tooltip>
190 ) : null}
158191 <Tooltip>
159192 <TooltipTrigger
160193 render={
apps/web/src/components/preview/BrowserMockup.tsx created+24
......@@ -0,0 +1,24 @@
1import { cn } from "~/lib/utils";
2
3/** Browser-window thumbnail glyph for the "Local" recommendation cards. */
4export function BrowserMockup({ className }: { className?: string }) {
5 return (
6 <div
7 aria-hidden
8 className={cn(
9 "relative flex flex-col gap-0.5 overflow-hidden rounded-[5px] border border-border/60 bg-card p-1 shadow-xs/5",
10 className,
11 )}
12 >
13 <div className="flex gap-[2px]">
14 <span className="size-[3px] rounded-full bg-destructive/80" />
15 <span className="size-[3px] rounded-full bg-warning/80" />
16 <span className="size-[3px] rounded-full bg-success/80" />
17 </div>
18 <div className="mt-[1px] flex flex-1 flex-col gap-[2px]">
19 <span className="h-[2px] w-full rounded-full bg-muted-foreground/30" />
20 <span className="h-[2px] w-3/5 rounded-full bg-muted-foreground/20" />
21 </div>
22 </div>
23 );
24}
apps/web/src/components/preview/PreviewChromeRow.tsx created+197
......@@ -0,0 +1,197 @@
1import { ArrowLeft, ArrowRight, ExternalLink, Globe, RotateCw } from "lucide-react";
2import {
3 type FormEvent,
4 type KeyboardEvent,
5 type ReactNode,
6 useEffect,
7 useRef,
8 useState,
9} from "react";
10
11import { Button } from "~/components/ui/button";
12import { InputGroup, InputGroupAddon, InputGroupInput } from "~/components/ui/input-group";
13import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
14import { cn } from "~/lib/utils";
15
16interface Props {
17 url: string;
18 loading: boolean;
19 loadProgress: number;
20 canGoBack: boolean;
21 canGoForward: boolean;
22 refreshDisabled: boolean;
23 inputDisabled?: boolean | undefined;
24 /** Bumping this value re-focuses and selects the URL input. */
25 focusUrlNonce?: number | undefined;
26 onBack: () => void;
27 onForward: () => void;
28 onRefresh: () => void;
29 onSubmit: (url: string) => void;
30 /** When provided, renders an "Open in browser" affordance to the right. */
31 onOpenInBrowser?: (() => void) | undefined;
32 /**
33 * Trailing slot rendered after the URL input. Used by the preview view
34 * to mount the three-dot menu (hard reload, devtools, zoom, clear data).
35 */
36 trailingActions?: ReactNode;
37}
38
39const NOOP = () => {};
40
41export function PreviewChromeRow({
42 url,
43 loading,
44 loadProgress,
45 canGoBack,
46 canGoForward,
47 refreshDisabled,
48 inputDisabled,
49 focusUrlNonce,
50 onBack,
51 onForward,
52 onRefresh,
53 onSubmit,
54 onOpenInBrowser,
55 trailingActions,
56}: Props) {
57 const inputRef = useRef<HTMLInputElement | null>(null);
58 const [draft, setDraft] = useState(url);
59
60 // Sync the input with external URL changes, but only when the user isn't
61 // actively typing (preserves in-progress edits during navigation events).
62 useEffect(() => {
63 setDraft((previous) => (document.activeElement === inputRef.current ? previous : url));
64 }, [url]);
65
66 useEffect(() => {
67 if (focusUrlNonce == null) return;
68 const node = inputRef.current;
69 if (!node) return;
70 node.focus();
71 node.select();
72 }, [focusUrlNonce]);
73
74 const submit = (event?: FormEvent | KeyboardEvent) => {
75 event?.preventDefault();
76 const next = draft.trim();
77 if (next.length === 0) return;
78 onSubmit(next);
79 };
80
81 return (
82 <div className="relative">
83 <form
84 onSubmit={submit}
85 className="flex items-center gap-2 border-b border-border bg-background px-2 py-1.5"
86 >
87 <div className="flex items-center gap-0.5" role="group" aria-label="Navigation">
88 <Tooltip>
89 <TooltipTrigger
90 render={
91 <Button
92 variant="ghost"
93 size="icon-xs"
94 onClick={canGoBack ? onBack : NOOP}
95 disabled={!canGoBack}
96 aria-label="Back"
97 type="button"
98 />
99 }
100 >
101 <ArrowLeft />
102 </TooltipTrigger>
103 <TooltipPopup>Back</TooltipPopup>
104 </Tooltip>
105 <Tooltip>
106 <TooltipTrigger
107 render={
108 <Button
109 variant="ghost"
110 size="icon-xs"
111 onClick={canGoForward ? onForward : NOOP}
112 disabled={!canGoForward}
113 aria-label="Forward"
114 type="button"
115 />
116 }
117 >
118 <ArrowRight />
119 </TooltipTrigger>
120 <TooltipPopup>Forward</TooltipPopup>
121 </Tooltip>
122 <Tooltip>
123 <TooltipTrigger
124 render={
125 <Button
126 variant="ghost"
127 size="icon-xs"
128 onClick={refreshDisabled ? NOOP : onRefresh}
129 disabled={refreshDisabled}
130 aria-label={loading ? "Stop" : "Refresh"}
131 type="button"
132 />
133 }
134 >
135 <RotateCw className={cn(loading && "animate-spin")} />
136 </TooltipTrigger>
137 <TooltipPopup>{loading ? "Loading…" : "Refresh"}</TooltipPopup>
138 </Tooltip>
139 </div>
140
141 <InputGroup className="flex-1">
142 <InputGroupAddon align="inline-start">
143 <Globe className="size-3.5 text-muted-foreground" aria-hidden />
144 </InputGroupAddon>
145 <InputGroupInput
146 ref={inputRef}
147 value={draft}
148 onChange={(event) => setDraft(event.target.value)}
149 onKeyDown={(event) => {
150 if (event.key === "Enter") submit(event);
151 if (event.key === "Escape") {
152 event.preventDefault();
153 setDraft(url);
154 inputRef.current?.blur();
155 }
156 }}
157 placeholder="Search or enter URL"
158 spellCheck={false}
159 disabled={inputDisabled}
160 data-preview-url-input
161 size="sm"
162 />
163 </InputGroup>
164
165 {onOpenInBrowser ? (
166 <Tooltip>
167 <TooltipTrigger
168 render={
169 <Button
170 variant="ghost"
171 size="icon-xs"
172 onClick={onOpenInBrowser}
173 aria-label="Open in system browser"
174 type="button"
175 />
176 }
177 >
178 <ExternalLink />
179 </TooltipTrigger>
180 <TooltipPopup>Open in system browser</TooltipPopup>
181 </Tooltip>
182 ) : null}
183 {trailingActions}
184 </form>
185 {loadProgress > 0 ? (
186 <div
187 aria-hidden
188 className="pointer-events-none absolute bottom-0 left-0 z-10 h-0.5 rounded-r-full bg-primary transition-all duration-150 ease-out"
189 style={{
190 width: `${loadProgress}%`,
191 boxShadow: "0 0 6px 1px var(--color-ring)",
192 }}
193 />
194 ) : null}
195 </div>
196 );
197}
apps/web/src/components/preview/PreviewEmptyState.tsx created+59
......@@ -0,0 +1,59 @@
1import type { EnvironmentId } from "@t3tools/contracts";
2import { Globe } from "lucide-react";
3
4import { Empty, EmptyDescription, EmptyMedia, EmptyTitle } from "~/components/ui/empty";
5
6import { PreviewLocalServerCard } from "./PreviewLocalServerCard";
7import { useDiscoveredLocalServers } from "./useDiscoveredLocalServers";
8
9interface Props {
10 environmentId: EnvironmentId;
11 configuredUrls?: ReadonlyArray<string> | undefined;
12 recentlySeenUrls?: ReadonlyArray<string> | undefined;
13 onOpenUrl: (url: string) => void;
14}
15
16export function PreviewEmptyState({
17 environmentId,
18 configuredUrls,
19 recentlySeenUrls,
20 onOpenUrl,
21}: Props) {
22 const servers = useDiscoveredLocalServers({
23 environmentId,
24 configuredUrls,
25 recentlySeenUrls,
26 });
27
28 if (servers.length === 0) {
29 return (
30 <Empty>
31 <EmptyMedia variant="icon">
32 <Globe className="size-4.5 text-muted-foreground" />
33 </EmptyMedia>
34 <EmptyTitle>No preview yet</EmptyTitle>
35 <EmptyDescription>
36 Type a URL above, or run a dev script. Listening localhost ports will show up here
37 automatically.
38 </EmptyDescription>
39 </Empty>
40 );
41 }
42
43 return (
44 <div className="flex h-full min-h-0 flex-col overflow-y-auto">
45 <h2 className="px-4 pt-4 pb-1.5 text-[11px] font-medium uppercase tracking-wide text-muted-foreground/80">
46 Local
47 </h2>
48 <div className="flex flex-col gap-1.5 px-3 pb-4">
49 {servers.map((server) => (
50 <PreviewLocalServerCard
51 key={`${server.host}:${server.port}`}
52 server={server}
53 onOpen={() => onOpenUrl(server.url)}
54 />
55 ))}
56 </div>
57 </div>
58 );
59}
apps/web/src/components/preview/PreviewLocalServerCard.tsx created+58
......@@ -0,0 +1,58 @@
1import { cn } from "~/lib/utils";
2
3import { BrowserMockup } from "./BrowserMockup";
4import type { PreviewableServer } from "./useDiscoveredLocalServers";
5
6interface Props {
7 server: PreviewableServer;
8 onOpen: () => void;
9}
10
11export function PreviewLocalServerCard({ server, onOpen }: Props) {
12 const subtitle = describeServer(server);
13 return (
14 <button
15 type="button"
16 onClick={onOpen}
17 className={cn(
18 "group flex w-full items-center gap-3 rounded-xl border border-border/70 bg-card px-3 py-2.5 text-left transition-colors",
19 "hover:border-border hover:bg-accent/40",
20 "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
21 )}
22 >
23 <BrowserMockup className="size-7 shrink-0" />
24 <div className="flex min-w-0 flex-1 flex-col">
25 <span className="truncate text-sm font-medium text-foreground">
26 {server.host}:{server.port}
27 </span>
28 <span className="truncate text-xs text-muted-foreground">{subtitle}</span>
29 </div>
30 {server.listening ? <PulsingDot /> : <DimDot />}
31 </button>
32 );
33}
34
35function describeServer(server: PreviewableServer): string {
36 if (server.processName) return server.processName;
37 if (server.listening) return "Listening";
38 if (server.source === "configured") return "Configured";
39 return "Recently seen";
40}
41
42function PulsingDot() {
43 return (
44 <span aria-label="Listening" className="relative inline-flex size-2 shrink-0">
45 <span className="absolute inset-0 animate-ping rounded-full bg-success opacity-60" />
46 <span className="relative inline-flex size-2 rounded-full bg-success" />
47 </span>
48 );
49}
50
51function DimDot() {
52 return (
53 <span
54 aria-label="Not currently listening"
55 className="size-2 shrink-0 rounded-full bg-muted-foreground/40"
56 />
57 );
58}
apps/web/src/components/preview/PreviewMoreMenu.tsx created+121
......@@ -0,0 +1,121 @@
1"use client";
2
3import { Minus, MoreVertical, Plus as PlusIcon, RotateCcw } from "lucide-react";
4
5import { Button } from "~/components/ui/button";
6import { Menu, MenuItem, MenuPopup, MenuSeparator, MenuTrigger } from "~/components/ui/menu";
7import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
8
9import { previewBridge } from "./previewBridge";
10
11interface Props {
12 /** Active preview tab id. Tab-targeting actions are disabled without it. */
13 tabId: string | null;
14 /**
15 * True only after the desktop bridge has registered a `webContentsId` for
16 * the active tab. Tab-targeting actions throw on the desktop side until
17 * then; we disable those items so the menu doesn't fire silent no-ops.
18 */
19 hasWebContents: boolean;
20 /** Current zoom factor as a number (1.0 = 100%). */
21 zoomFactor: number;
22}
23
24/**
25 * Three-dot menu in the chrome row. Wires Hard reload, DevTools, zoom
26 * controls, and storage-clearing actions. Only mounted by `PreviewView`
27 * when the desktop bridge is present, so we can call it unconditionally.
28 */
29export function PreviewMoreMenu({ tabId, hasWebContents, zoomFactor }: Props) {
30 if (!previewBridge) return null;
31 const bridge = previewBridge;
32 const tabDisabled = !tabId || !hasWebContents;
33 const callTab = (op: (tabId: string) => Promise<void>) => () => {
34 if (!tabId) return;
35 void op(tabId).catch(() => undefined);
36 };
37
38 const zoomLabel = `${Math.round(zoomFactor * 100)}%`;
39
40 return (
41 <Menu>
42 <Tooltip>
43 <TooltipTrigger
44 render={
45 <MenuTrigger
46 render={
47 <Button variant="ghost" size="icon-xs" type="button" aria-label="Preview menu" />
48 }
49 />
50 }
51 >
52 <MoreVertical />
53 </TooltipTrigger>
54 <TooltipPopup>More</TooltipPopup>
55 </Tooltip>
56 <MenuPopup align="end" sideOffset={6} className="min-w-56">
57 <MenuItem onClick={callTab(bridge.hardReload)} disabled={tabDisabled}>
58 Hard reload
59 </MenuItem>
60 <MenuItem onClick={callTab(bridge.openDevTools)} disabled={tabDisabled}>
61 Open DevTools
62 </MenuItem>
63 <MenuSeparator />
64 {/*
65 Zoom row: label + inline control cluster. `closeOnClick=false`
66 keeps the menu open while the user clicks the +/− buttons.
67 */}
68 <MenuItem
69 closeOnClick={false}
70 onClick={(event: React.MouseEvent) => event.preventDefault()}
71 className="justify-between"
72 disabled={tabDisabled}
73 >
74 <span>Zoom</span>
75 <span className="flex items-center gap-1">
76 <Button
77 variant="outline"
78 size="icon-xs"
79 type="button"
80 onClick={callTab(bridge.zoomOut)}
81 aria-label="Zoom out"
82 disabled={tabDisabled}
83 >
84 <Minus />
85 </Button>
86 <span className="min-w-12 text-center text-xs tabular-nums text-muted-foreground">
87 {zoomLabel}
88 </span>
89 <Button
90 variant="outline"
91 size="icon-xs"
92 type="button"
93 onClick={callTab(bridge.zoomIn)}
94 aria-label="Zoom in"
95 disabled={tabDisabled}
96 >
97 <PlusIcon />
98 </Button>
99 <Button
100 variant="ghost"
101 size="icon-xs"
102 type="button"
103 onClick={callTab(bridge.resetZoom)}
104 aria-label="Reset zoom"
105 disabled={tabDisabled}
106 >
107 <RotateCcw />
108 </Button>
109 </span>
110 </MenuItem>
111 <MenuSeparator />
112 <MenuItem onClick={() => void bridge.clearCookies().catch(() => undefined)}>
113 Clear cookies
114 </MenuItem>
115 <MenuItem onClick={() => void bridge.clearCache().catch(() => undefined)}>
116 Clear cache
117 </MenuItem>
118 </MenuPopup>
119 </Menu>
120 );
121}
apps/web/src/components/preview/PreviewPanel.tsx created+35
......@@ -0,0 +1,35 @@
1"use client";
2
3import type { ScopedThreadRef } from "@t3tools/contracts";
4
5import { isPreviewSupportedInRuntime } from "~/previewStateStore";
6
7import { PreviewPanelShell, type PreviewPanelMode } from "./PreviewPanelShell";
8import { PreviewView } from "./PreviewView";
9
10interface Props {
11 mode: PreviewPanelMode;
12 threadRef: ScopedThreadRef;
13 configuredUrls?: ReadonlyArray<string> | undefined;
14 visible: boolean;
15}
16
17export function PreviewPanel({ mode, threadRef, configuredUrls, visible }: Props) {
18 if (!isPreviewSupportedInRuntime()) {
19 return (
20 <PreviewPanelShell mode={mode}>
21 <div className="flex h-full flex-col items-center justify-center gap-3 p-8 text-center">
22 <p className="max-w-sm text-sm text-muted-foreground">
23 Preview is only available in the T3 Code desktop app.
24 </p>
25 </div>
26 </PreviewPanelShell>
27 );
28 }
29
30 return (
31 <PreviewPanelShell mode={mode}>
32 <PreviewView threadRef={threadRef} configuredUrls={configuredUrls} visible={visible} />
33 </PreviewPanelShell>
34 );
35}
apps/web/src/components/preview/PreviewPanelShell.tsx created+77
......@@ -0,0 +1,77 @@
1import { type ReactNode, useEffect, useState } from "react";
2
3import { isElectron } from "~/env";
4import { useResizableWidth } from "~/hooks/useResizableWidth";
5import { cn } from "~/lib/utils";
6
7import { RightPanelResizeHandle } from "./RightPanelResizeHandle";
8
9export type PreviewPanelMode = "inline" | "sheet" | "sidebar";
10
11const PREVIEW_PANEL_WIDTH_STORAGE_KEY = "t3code:preview-panel-width";
12const PREVIEW_PANEL_MIN_WIDTH = 360;
13/** Hard ceiling so a wide monitor can't yield a panel that swallows the chat. */
14const PREVIEW_PANEL_MAX_WIDTH_PX = 1400;
15/** Fraction of the viewport allowed; the panel is min(this · vw, MAX_PX). */
16const PREVIEW_PANEL_MAX_WIDTH_FRACTION = 0.7;
17const PREVIEW_PANEL_DEFAULT_WIDTH = 540;
18
19/**
20 * Shell for the preview panel. In inline mode the panel is user-resizable
21 * via a drag handle on the left edge; width persists per browser. In
22 * sheet/sidebar modes the parent owns the size.
23 */
24export function PreviewPanelShell(props: { mode: PreviewPanelMode; children: ReactNode }) {
25 const useDragRegion = isElectron && props.mode !== "sheet";
26 const isInline = props.mode === "inline";
27 const maxWidth = useViewportClampedMaxWidth();
28 const { width, handlers } = useResizableWidth({
29 storageKey: PREVIEW_PANEL_WIDTH_STORAGE_KEY,
30 defaultWidth: PREVIEW_PANEL_DEFAULT_WIDTH,
31 minWidth: PREVIEW_PANEL_MIN_WIDTH,
32 maxWidth,
33 edge: "left",
34 });
35
36 return (
37 <div
38 className={cn(
39 "relative flex h-full min-w-0 flex-col bg-background",
40 isInline ? "shrink-0 border-l border-border" : "w-full",
41 )}
42 style={isInline ? { width: `${width}px` } : undefined}
43 data-preview-panel-mode={props.mode}
44 >
45 {isInline ? <RightPanelResizeHandle handlers={handlers} /> : null}
46 {useDragRegion ? <div className="electron-drag-region h-0 w-full" aria-hidden /> : null}
47 {props.children}
48 </div>
49 );
50}
51
52/**
53 * Track viewport width to derive a sensible upper bound for the panel.
54 * Resize-aware so dragging the OS window narrower re-clamps the stored
55 * width on the next render (the hook's clamp picks this up automatically).
56 */
57function useViewportClampedMaxWidth(): number {
58 const [vw, setVw] = useState(() => (typeof window === "undefined" ? 1280 : window.innerWidth));
59 useEffect(() => {
60 if (typeof window === "undefined") return;
61 let frame = 0;
62 const onResize = () => {
63 // Coalesce rapid resize events into one rAF tick.
64 if (frame !== 0) return;
65 frame = window.requestAnimationFrame(() => {
66 frame = 0;
67 setVw(window.innerWidth);
68 });
69 };
70 window.addEventListener("resize", onResize);
71 return () => {
72 window.removeEventListener("resize", onResize);
73 if (frame !== 0) window.cancelAnimationFrame(frame);
74 };
75 }, []);
76 return Math.min(PREVIEW_PANEL_MAX_WIDTH_PX, Math.floor(vw * PREVIEW_PANEL_MAX_WIDTH_FRACTION));
77}
apps/web/src/components/preview/PreviewUnreachable.tsx created+90
......@@ -0,0 +1,90 @@
1import { useState } from "react";
2
3import { Button } from "~/components/ui/button";
4
5import { describePreviewError } from "./errorCodeMessages";
6
7interface Props {
8 url: string;
9 /** Chromium net error code, e.g. -105. */
10 code: number;
11 /** Stringified Chromium error, e.g. "ERR_NAME_NOT_RESOLVED". */
12 description: string;
13 onReload: () => void;
14}
15
16/** Theme-aware tailwind port of Chromium's "This site can't be reached" page. */
17export function PreviewUnreachable({ url, code, description, onReload }: Props) {
18 const [showDetails, setShowDetails] = useState(false);
19 const host = safeHost(url) ?? url;
20 const friendly = describePreviewError(code, description);
21 const errorLabel = description.length > 0 ? description : `ERR_${Math.abs(code) || "FAILED"}`;
22
23 return (
24 <div className="relative flex h-full min-h-0 w-full overflow-y-auto bg-background">
25 <div className="mx-auto flex w-full max-w-xl flex-1 flex-col px-8 py-12 sm:py-16">
26 <ErrorIcon className="mb-6 size-12 text-muted-foreground/70" />
27 <h1 className="mb-3 text-2xl font-semibold leading-tight text-foreground">
28 This site can&rsquo;t be reached
29 </h1>
30 <p className="text-sm leading-relaxed text-muted-foreground">
31 <span className="font-semibold text-foreground">{host}</span>: {friendly}.
32 </p>
33
34 {showDetails ? (
35 <div className="mt-6 rounded-lg border border-border bg-muted/40 p-4 text-sm">
36 <p className="mb-2 font-medium text-foreground">Try:</p>
37 <ul className="list-disc space-y-1 pl-5 text-muted-foreground">
38 <li>Checking your connection</li>
39 <li>Confirming the dev server is running</li>
40 <li>Checking the proxy and the firewall</li>
41 </ul>
42 </div>
43 ) : null}
44
45 <div className="mt-8 text-xs uppercase tracking-wide text-muted-foreground/70">
46 {errorLabel}
47 </div>
48
49 <div className="mt-auto flex items-center gap-2 pt-8">
50 <Button
51 type="button"
52 variant="outline"
53 size="sm"
54 onClick={() => setShowDetails((value) => !value)}
55 >
56 {showDetails ? "Hide details" : "Details"}
57 </Button>
58 <div className="flex-1" />
59 <Button type="button" size="sm" onClick={onReload}>
60 Reload
61 </Button>
62 </div>
63 </div>
64 </div>
65 );
66}
67
68function ErrorIcon({ className }: { className?: string }) {
69 return (
70 <svg
71 viewBox="0 0 64 64"
72 fill="none"
73 stroke="currentColor"
74 strokeWidth="2.5"
75 className={className}
76 >
77 <path d="M16 12 L48 12 L48 52 L16 52 Z" />
78 <path d="M22 22 L42 22 M22 30 L36 30 M22 38 L40 38" strokeLinecap="round" />
79 <path d="M52 8 L12 56" strokeLinecap="round" />
80 </svg>
81 );
82}
83
84function safeHost(url: string): string | null {
85 try {
86 return new URL(url).host;
87 } catch {
88 return null;
89 }
90}
apps/web/src/components/preview/PreviewView.tsx created+193
......@@ -0,0 +1,193 @@
1"use client";
2
3import { scopedThreadKey } from "@t3tools/client-runtime";
4import { type ScopedThreadRef } from "@t3tools/contracts";
5import { useCallback, useEffect, useState } from "react";
6
7import { ensureEnvironmentApi } from "~/environmentApi";
8import { ensureLocalApi } from "~/localApi";
9import { selectThreadPreviewState, usePreviewStateStore } from "~/previewStateStore";
10
11import { previewBridge } from "./previewBridge";
12import { subscribePreviewAction } from "./previewActionBus";
13import { PreviewChromeRow } from "./PreviewChromeRow";
14import { PreviewEmptyState } from "./PreviewEmptyState";
15import { PreviewMoreMenu } from "./PreviewMoreMenu";
16import { PreviewUnreachable } from "./PreviewUnreachable";
17import { PreviewWebview } from "./PreviewWebview";
18import { useLoadingProgress } from "./useLoadingProgress";
19import { usePreviewSession } from "./usePreviewSession";
20import { ZoomIndicator } from "./ZoomIndicator";
21
22interface Props {
23 threadRef: ScopedThreadRef;
24 configuredUrls?: ReadonlyArray<string> | undefined;
25 visible: boolean;
26}
27
28const localApi = typeof window === "undefined" ? null : ensureLocalApi();
29
30/**
31 * Single-tab preview surface: chrome row on top, one webview below, empty
32 * state when no session exists for the thread.
33 */
34export function PreviewView({ threadRef, configuredUrls, visible }: Props) {
35 const [focusUrlNonce, setFocusUrlNonce] = useState(0);
36 const previewState = usePreviewStateStore((state) =>
37 selectThreadPreviewState(state.byThreadKey, threadRef),
38 );
39 const rememberUrl = usePreviewStateStore((state) => state.rememberUrl);
40
41 usePreviewSession(threadRef);
42
43 const { snapshot, desktopOverlay } = previewState;
44 const tabId = snapshot?.tabId ?? null;
45 const navStatus = snapshot?.navStatus ?? { _tag: "Idle" as const };
46 const url = navStatus._tag === "Idle" ? "" : navStatus.url;
47 const loading = desktopOverlay?.loading ?? navStatus._tag === "Loading";
48 const canGoBack = desktopOverlay?.canGoBack ?? snapshot?.canGoBack ?? false;
49 const canGoForward = desktopOverlay?.canGoForward ?? snapshot?.canGoForward ?? false;
50 const refreshDisabled = navStatus._tag === "Idle";
51 const isUnreachable = navStatus._tag === "LoadFailed";
52 const loadProgress = useLoadingProgress(loading);
53
54 const handleSubmitUrl = useCallback(
55 async (next: string) => {
56 const api = ensureEnvironmentApi(threadRef.environmentId);
57 try {
58 if (tabId && previewBridge) {
59 // Drive the webview imperatively; `usePreviewBridge` mirrors the
60 // resolved URL back to the server so other clients stay in sync.
61 await previewBridge.navigate(tabId, next);
62 rememberUrl(threadRef, next);
63 } else {
64 const resolved = await api.preview.open({ threadId: threadRef.threadId, url: next });
65 const resolvedUrl = resolved.navStatus._tag === "Idle" ? next : resolved.navStatus.url;
66 rememberUrl(threadRef, resolvedUrl);
67 }
68 } catch {
69 // Server-side `failed` event renders the unreachable view.
70 }
71 },
72 [rememberUrl, tabId, threadRef],
73 );
74
75 const handleRefresh = useCallback(() => {
76 if (previewBridge && tabId) void previewBridge.refresh(tabId);
77 }, [tabId]);
78
79 const handleZoomIn = useCallback(() => {
80 if (previewBridge && tabId) void previewBridge.zoomIn(tabId);
81 }, [tabId]);
82
83 const handleZoomOut = useCallback(() => {
84 if (previewBridge && tabId) void previewBridge.zoomOut(tabId);
85 }, [tabId]);
86
87 const handleResetZoom = useCallback(() => {
88 if (previewBridge && tabId) void previewBridge.resetZoom(tabId);
89 }, [tabId]);
90
91 const handleBack = useCallback(() => {
92 if (previewBridge && tabId) void previewBridge.goBack(tabId);
93 }, [tabId]);
94
95 const handleForward = useCallback(() => {
96 if (previewBridge && tabId) void previewBridge.goForward(tabId);
97 }, [tabId]);
98
99 const handleOpenInBrowser = useCallback(() => {
100 if (!localApi || !url) return;
101 void localApi.shell.openExternal(url).catch(() => undefined);
102 }, [url]);
103
104 // Subscribe only while visible; `toggle-panel` is owned by ChatView's
105 // URL-aware handler regardless of whether the panel is currently mounted.
106 useEffect(() => {
107 if (!visible) return;
108 return subscribePreviewAction((action) => {
109 switch (action) {
110 case "refresh":
111 handleRefresh();
112 return;
113 case "focus-url":
114 setFocusUrlNonce((value) => value + 1);
115 return;
116 case "zoom-in":
117 handleZoomIn();
118 return;
119 case "zoom-out":
120 handleZoomOut();
121 return;
122 case "reset-zoom":
123 handleResetZoom();
124 return;
125 case "toggle-panel":
126 return;
127 }
128 });
129 }, [handleRefresh, handleResetZoom, handleZoomIn, handleZoomOut, visible]);
130
131 return (
132 <div
133 className="flex h-full min-h-0 flex-col bg-background"
134 data-thread-key={scopedThreadKey(threadRef)}
135 >
136 <PreviewChromeRow
137 url={url}
138 loading={loading}
139 loadProgress={loadProgress}
140 canGoBack={canGoBack}
141 canGoForward={canGoForward}
142 refreshDisabled={refreshDisabled}
143 focusUrlNonce={focusUrlNonce}
144 onBack={handleBack}
145 onForward={handleForward}
146 onRefresh={handleRefresh}
147 onSubmit={(next) => void handleSubmitUrl(next)}
148 onOpenInBrowser={tabId ? handleOpenInBrowser : undefined}
149 trailingActions={
150 previewBridge ? (
151 <PreviewMoreMenu
152 tabId={tabId}
153 hasWebContents={desktopOverlay !== null}
154 zoomFactor={desktopOverlay?.zoomFactor ?? 1}
155 />
156 ) : null
157 }
158 />
159
160 <div className="relative flex-1 overflow-hidden">
161 {tabId && snapshot ? (
162 <PreviewWebview
163 key={tabId}
164 threadRef={threadRef}
165 tabId={tabId}
166 initialUrl={url || null}
167 className="absolute inset-0 h-full w-full"
168 />
169 ) : (
170 <PreviewEmptyState
171 environmentId={threadRef.environmentId}
172 configuredUrls={configuredUrls}
173 recentlySeenUrls={previewState.recentlySeenUrls}
174 onOpenUrl={(next) => void handleSubmitUrl(next)}
175 />
176 )}
177 {snapshot && desktopOverlay ? (
178 <ZoomIndicator zoomFactor={desktopOverlay.zoomFactor} />
179 ) : null}
180 {isUnreachable && navStatus._tag === "LoadFailed" ? (
181 <div className="absolute inset-0 z-10 bg-background">
182 <PreviewUnreachable
183 url={navStatus.url}
184 code={navStatus.code}
185 description={navStatus.description}
186 onReload={handleRefresh}
187 />
188 </div>
189 ) : null}
190 </div>
191 </div>
192 );
193}
apps/web/src/components/preview/PreviewWebview.tsx created+116
......@@ -0,0 +1,116 @@
1"use client";
2
3import type { ScopedThreadRef } from "@t3tools/contracts";
4import { useEffect, useRef, useState } from "react";
5
6import { isElectron } from "~/env";
7
8import { previewBridge } from "./previewBridge";
9import { usePreviewBridge } from "./usePreviewBridge";
10
11interface Props {
12 threadRef: ScopedThreadRef;
13 tabId: string;
14 /**
15 * URL to load on first mount. Subsequent prop changes are ignored — once
16 * the webview is live, navigation flows exclusively through the bridge
17 * (`previewBridge.navigate`, `goBack`, `goForward`, `refresh`). Otherwise,
18 * a snapshot URL update from the server would re-set `<webview src>` and
19 * race with the `loadURL` we already issued via the bridge.
20 */
21 initialUrl: string | null;
22 className?: string;
23}
24
25interface ElectronWebview extends HTMLElement {
26 src: string;
27 partition: string;
28 allowpopups: boolean;
29 reload: () => void;
30 getWebContentsId: () => number;
31}
32
33declare global {
34 interface HTMLElementTagNameMap {
35 webview: ElectronWebview;
36 }
37}
38
39/**
40 * Hosts the Electron `<webview>` for a single preview tab. Returns null on
41 * web builds. The two-step handshake (createTab → wait for `dom-ready` →
42 * registerWebview) is necessary because `getWebContentsId()` only returns a
43 * valid id once the embedded contents have parsed; calling it synchronously
44 * after mount throws.
45 */
46export function PreviewWebview({ threadRef, tabId, initialUrl, className }: Props) {
47 const [config, setConfig] = useState<{ partition: string } | null>(null);
48 const webviewRef = useRef<ElectronWebview | null>(null);
49 // Capture once at mount; never re-derived from `initialUrl` props later.
50 const initialSrcRef = useRef<string>(initialUrl ?? "about:blank");
51 const bridge = previewBridge;
52
53 // Per-tab desktop lifecycle: createTab on mount, closeTab on unmount,
54 // mirror state into the store, and reflect navigation back to the server.
55 usePreviewBridge({ threadRef, tabId });
56
57 useEffect(() => {
58 if (!bridge) return;
59 let cancelled = false;
60 void bridge
61 .getBrowserPartition()
62 .then((partition) => {
63 if (cancelled) return;
64 setConfig({ partition });
65 })
66 .catch(() => undefined);
67 return () => {
68 cancelled = true;
69 };
70 }, [bridge]);
71
72 useEffect(() => {
73 if (!bridge || !config) return;
74 const webview = webviewRef.current;
75 if (!webview) return;
76
77 const onDomReady = () => {
78 try {
79 const id = webview.getWebContentsId();
80 if (Number.isFinite(id)) {
81 void bridge.registerWebview(tabId, id);
82 }
83 } catch {
84 // The next dom-ready (e.g. cross-document navigation) will retry.
85 }
86 };
87 webview.addEventListener("dom-ready", onDomReady as EventListener);
88 // Defensive: dom-ready may have fired between React commit and this
89 // effect running. Failures fall through to the listener above.
90 try {
91 const existing = webview.getWebContentsId();
92 if (Number.isFinite(existing)) {
93 void bridge.registerWebview(tabId, existing);
94 }
95 } catch {
96 // covered by dom-ready listener
97 }
98
99 return () => {
100 webview.removeEventListener("dom-ready", onDomReady as EventListener);
101 };
102 }, [bridge, config, tabId]);
103
104 if (!isElectron || !bridge || !config) return null;
105
106 return (
107 <webview
108 ref={webviewRef}
109 src={initialSrcRef.current}
110 partition={config.partition}
111 allowpopups
112 data-preview-tab={tabId}
113 className={className}
114 />
115 );
116}
apps/web/src/components/preview/RightPanelResizeHandle.tsx created+34
......@@ -0,0 +1,34 @@
1import type { ResizableWidthHandlers } from "~/hooks/useResizableWidth";
2import { cn } from "~/lib/utils";
3
4interface Props {
5 handlers: ResizableWidthHandlers;
6 className?: string;
7}
8
9/**
10 * Hit target for resizing a right-anchored panel via its left edge.
11 *
12 * - Sits on top of the panel's border with a 4px overlap on each side so the
13 * user can grab a few pixels off the edge without aiming.
14 * - Visual indicator is a 1px line that lights up on hover/active to mirror
15 * VS Code / Cursor.
16 */
17export function RightPanelResizeHandle({ handlers, className }: Props) {
18 return (
19 <div
20 role="separator"
21 aria-orientation="vertical"
22 className={cn(
23 "group absolute inset-y-0 -left-1 z-20 w-2 cursor-col-resize select-none",
24 className,
25 )}
26 {...handlers}
27 >
28 <span
29 aria-hidden
30 className="pointer-events-none absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-transparent transition-colors duration-150 group-hover:bg-border group-active:bg-primary/60"
31 />
32 </div>
33 );
34}
apps/web/src/components/preview/ZoomIndicator.tsx created+54
......@@ -0,0 +1,54 @@
1import { useEffect, useRef, useState } from "react";
2
3import { cn } from "~/lib/utils";
4
5const HIDE_AFTER_MS = 1500;
6const ZOOM_EPSILON = 0.001;
7
8interface Props {
9 /** Current zoom factor (1.0 = 100%); changes drive the transient indicator. */
10 zoomFactor: number;
11}
12
13/**
14 * Floating "X%" pill that surfaces in the top-right of the webview area
15 * whenever the zoom factor changes, then fades out after a short pause.
16 *
17 * Suppressed for the first render's value so we don't flash 100% on mount.
18 */
19export function ZoomIndicator({ zoomFactor }: Props) {
20 const [visible, setVisible] = useState(false);
21 const lastFactorRef = useRef(zoomFactor);
22 const timerRef = useRef<number | null>(null);
23
24 useEffect(() => {
25 if (Math.abs(lastFactorRef.current - zoomFactor) < ZOOM_EPSILON) return;
26 lastFactorRef.current = zoomFactor;
27 setVisible(true);
28 if (timerRef.current !== null) window.clearTimeout(timerRef.current);
29 timerRef.current = window.setTimeout(() => {
30 setVisible(false);
31 timerRef.current = null;
32 }, HIDE_AFTER_MS);
33 return () => {
34 if (timerRef.current !== null) {
35 window.clearTimeout(timerRef.current);
36 timerRef.current = null;
37 }
38 };
39 }, [zoomFactor]);
40
41 const percent = `${Math.round(zoomFactor * 100)}%`;
42
43 return (
44 <div
45 aria-hidden={!visible}
46 className={cn(
47 "pointer-events-none absolute top-3 right-3 z-20 select-none rounded-full border border-border/70 bg-popover/95 px-2.5 py-1 text-xs font-medium text-foreground shadow-md/10 backdrop-blur transition-all duration-200 ease-out",
48 visible ? "opacity-100 translate-y-0" : "opacity-0 -translate-y-1",
49 )}
50 >
51 {percent}
52 </div>
53 );
54}
apps/web/src/components/preview/errorCodeMessages.ts created+12
......@@ -0,0 +1,12 @@
1import { PREVIEW_ERROR_CODE_MESSAGES } from "./previewConstants";
2
3/**
4 * Resolve a friendly description for a Chromium / network error. Falls back
5 * to the description string passed in when the code isn't in our table.
6 */
7export function describePreviewError(code: number, description: string): string {
8 const friendly = PREVIEW_ERROR_CODE_MESSAGES[description];
9 if (friendly) return friendly;
10 if (description.length > 0) return description;
11 return `Network error (${code})`;
12}
apps/web/src/components/preview/openTerminalLinkInPreview.ts created+70
......@@ -0,0 +1,70 @@
1import type { EnvironmentApi, LocalApi, ScopedThreadRef } from "@t3tools/contracts";
2import { isPreviewableUrl } from "@t3tools/shared/preview";
3
4import { isPreviewSupportedInRuntime } from "~/previewStateStore";
5import { useRightPanelStore } from "~/rightPanelStore";
6
7interface OpenTerminalLinkInPreviewInput {
8 readonly url: string;
9 readonly position: { x: number; y: number };
10 readonly threadRef: ScopedThreadRef;
11 readonly api: EnvironmentApi;
12 readonly localApi: LocalApi;
13 /** Called whenever the URL ultimately needs to open in the system browser. */
14 readonly fallbackToBrowser: () => void;
15}
16
17/**
18 * Handles a terminal-link click that resolves to a URL.
19 *
20 * - For non-loopback / unsupported runtimes, defers to the system browser.
21 * - For previewable URLs in the desktop build, presents a context menu to
22 * choose between the in-app preview and the system browser.
23 *
24 * Failures fall back to the system browser so a stuck context-menu doesn't
25 * leave the user without a way to open the link.
26 */
27export async function openTerminalLinkInPreview(
28 input: OpenTerminalLinkInPreviewInput,
29): Promise<void> {
30 const supportsPreview =
31 isPreviewableUrl(input.url) &&
32 isPreviewSupportedInRuntime() &&
33 input.threadRef.threadId.length > 0;
34
35 if (!supportsPreview) {
36 input.fallbackToBrowser();
37 return;
38 }
39
40 let choice: "open-in-preview" | "open-in-browser" | null;
41 try {
42 choice = await input.localApi.contextMenu.show(
43 [
44 { id: "open-in-preview", label: "Open in preview" },
45 { id: "open-in-browser", label: "Open in browser" },
46 ],
47 input.position,
48 );
49 } catch {
50 input.fallbackToBrowser();
51 return;
52 }
53
54 if (choice === "open-in-preview") {
55 try {
56 await input.api.preview.open({
57 threadId: input.threadRef.threadId,
58 url: input.url,
59 });
60 useRightPanelStore.getState().open(input.threadRef, "preview");
61 } catch {
62 input.fallbackToBrowser();
63 }
64 return;
65 }
66
67 if (choice === "open-in-browser") {
68 input.fallbackToBrowser();
69 }
70}
apps/web/src/components/preview/previewActionBus.ts created+31
......@@ -0,0 +1,31 @@
1"use client";
2
3/**
4 * Typed window-event bus for preview-panel actions. Lets the global
5 * keybinding handler in `routes/_chat.tsx` reach `ChatView`'s URL-aware
6 * arbitration without prop drilling or shared refs.
7 */
8export type PreviewAction =
9 | "toggle-panel"
10 | "refresh"
11 | "focus-url"
12 | "zoom-in"
13 | "zoom-out"
14 | "reset-zoom";
15
16const EVENT_NAME = "t3code:preview-action";
17
18export function dispatchPreviewAction(action: PreviewAction): void {
19 if (typeof window === "undefined") return;
20 window.dispatchEvent(new CustomEvent<PreviewAction>(EVENT_NAME, { detail: action }));
21}
22
23export function subscribePreviewAction(listener: (action: PreviewAction) => void): () => void {
24 if (typeof window === "undefined") return () => {};
25 const handler = (event: Event) => {
26 const detail = (event as CustomEvent<PreviewAction>).detail;
27 if (typeof detail === "string") listener(detail);
28 };
29 window.addEventListener(EVENT_NAME, handler);
30 return () => window.removeEventListener(EVENT_NAME, handler);
31}
apps/web/src/components/preview/previewBridge.ts created+9
......@@ -0,0 +1,9 @@
1/**
2 * Module-level handle to the desktop preview bridge.
3 *
4 * Resolved once at import time so React hooks don't pay for repeated
5 * `window.desktopBridge?.preview` lookups on every render. `null` on the web
6 * build where there's no Electron host.
7 */
8export const previewBridge =
9 typeof window === "undefined" ? null : (window.desktopBridge?.preview ?? null);
apps/web/src/components/preview/previewConstants.ts created+21
......@@ -0,0 +1,21 @@
1/** Cap for the per-thread "recently seen" URL list shown in the empty state. */
2export const PREVIEW_RECENT_URL_LIMIT = 10;
3
4/**
5 * Common Chromium error codes mapped to a short human label. Used by the
6 * unreachable view to drop the raw `ERR_*` code in favour of friendlier copy.
7 */
8export const PREVIEW_ERROR_CODE_MESSAGES: Readonly<Record<string, string>> = Object.freeze({
9 ERR_NAME_NOT_RESOLVED: "DNS address could not be found",
10 ERR_NAME_RESOLUTION_FAILED: "DNS address could not be found",
11 ERR_CONNECTION_REFUSED: "Connection refused",
12 ERR_CONNECTION_RESET: "Connection was reset",
13 ERR_CONNECTION_CLOSED: "Connection was closed",
14 ERR_CONNECTION_TIMED_OUT: "Connection timed out",
15 ERR_INTERNET_DISCONNECTED: "No internet connection",
16 ERR_TIMED_OUT: "Connection timed out",
17 ERR_CERT_AUTHORITY_INVALID: "Certificate authority is not trusted",
18 ERR_CERT_COMMON_NAME_INVALID: "Certificate hostname mismatch",
19 ERR_CERT_DATE_INVALID: "Certificate is expired or not yet valid",
20 ERR_TOO_MANY_REDIRECTS: "Too many redirects",
21});
apps/web/src/components/preview/useDiscoveredLocalServers.test.ts created+116
......@@ -0,0 +1,116 @@
1import type { DiscoveredLocalServer } from "@t3tools/contracts";
2import { describe, expect, it } from "vitest";
3
4import { mergeServers, type PreviewableServer } from "./useDiscoveredLocalServers";
5
6const scannerServer = (overrides: Partial<DiscoveredLocalServer>): DiscoveredLocalServer => ({
7 host: "localhost",
8 port: 5173,
9 url: "http://localhost:5173",
10 processName: "vite",
11 pid: 1234,
12 ...overrides,
13});
14
15describe("mergeServers", () => {
16 it("returns scanner-only entries unchanged", () => {
17 const result = mergeServers({
18 scanner: [scannerServer({})],
19 configuredUrls: [],
20 recentlySeenUrls: [],
21 });
22 expect(result).toHaveLength(1);
23 expect(result[0]).toMatchObject({
24 host: "localhost",
25 port: 5173,
26 source: "scanner",
27 listening: true,
28 processName: "vite",
29 });
30 });
31
32 it("enriches a configured entry with live process metadata when scanner sees it", () => {
33 const result = mergeServers({
34 scanner: [scannerServer({ port: 5173, processName: "node", pid: 9999 })],
35 configuredUrls: ["http://localhost:5173"],
36 recentlySeenUrls: [],
37 });
38 expect(result).toHaveLength(1);
39 expect(result[0]).toMatchObject({
40 port: 5173,
41 source: "configured",
42 listening: true,
43 processName: "node",
44 pid: 9999,
45 });
46 });
47
48 it("keeps configured entries that the scanner doesn't see, with listening=false", () => {
49 const result = mergeServers({
50 scanner: [],
51 configuredUrls: ["http://localhost:5173"],
52 recentlySeenUrls: [],
53 });
54 expect(result).toHaveLength(1);
55 expect(result[0]).toMatchObject({
56 source: "configured",
57 listening: false,
58 });
59 });
60
61 it("dedupes recently-seen URLs against scanner+configured entries", () => {
62 const result = mergeServers({
63 scanner: [scannerServer({ port: 5173 })],
64 configuredUrls: [],
65 recentlySeenUrls: ["http://localhost:5173/", "http://localhost:8080/"],
66 });
67 expect(result.map((s) => s.port)).toEqual([5173, 8080]);
68 expect(result.find((s) => s.port === 5173)?.source).toBe("scanner");
69 expect(result.find((s) => s.port === 8080)?.source).toBe("recent");
70 });
71
72 it("ignores non-loopback URLs in configured/recent inputs", () => {
73 const result = mergeServers({
74 scanner: [],
75 configuredUrls: ["https://example.com", "ws://localhost:5173"],
76 recentlySeenUrls: ["https://api.example.com"],
77 });
78 expect(result).toHaveLength(0);
79 });
80
81 it("sorts: configured before scanner before recent, then by port", () => {
82 const result = mergeServers({
83 scanner: [scannerServer({ port: 8080 }), scannerServer({ port: 3000 })],
84 configuredUrls: ["http://localhost:5173"],
85 recentlySeenUrls: ["http://localhost:9000/", "http://localhost:4321/"],
86 });
87 expect(result.map((s) => `${s.source}:${s.port}`)).toEqual([
88 "configured:5173",
89 "scanner:3000",
90 "scanner:8080",
91 "recent:4321",
92 "recent:9000",
93 ]);
94 });
95
96 it("dedupes by lowercased host", () => {
97 const result = mergeServers({
98 scanner: [scannerServer({ host: "Localhost", port: 5173 })],
99 configuredUrls: ["http://localhost:5173"],
100 recentlySeenUrls: [],
101 });
102 expect(result).toHaveLength(1);
103 });
104});
105
106describe("PreviewableServer interface", () => {
107 it("preserves listening flag through enrichment", () => {
108 const result = mergeServers({
109 scanner: [scannerServer({})],
110 configuredUrls: ["http://localhost:5173"],
111 recentlySeenUrls: [],
112 });
113 const merged: PreviewableServer | undefined = result[0];
114 expect(merged?.listening).toBe(true);
115 });
116});
apps/web/src/components/preview/useDiscoveredLocalServers.ts created+141
......@@ -0,0 +1,141 @@
1import type { DiscoveredLocalServer } from "@t3tools/contracts";
2import { isLoopbackHost } from "@t3tools/shared/preview";
3import { useEffect, useMemo, useState } from "react";
4
5import { ensureEnvironmentApi } from "~/environmentApi";
6import type { EnvironmentId } from "@t3tools/contracts";
7
8export interface PreviewableServer extends DiscoveredLocalServer {
9 source: "scanner" | "configured" | "recent";
10 /**
11 * True when the port scanner currently sees this server listening. A
12 * `configured` entry can also be `listening` when the scan enriched it.
13 */
14 listening: boolean;
15}
16
17interface UseDiscoveredLocalServersInput {
18 environmentId: EnvironmentId;
19 configuredUrls?: ReadonlyArray<string> | undefined;
20 recentlySeenUrls?: ReadonlyArray<string> | undefined;
21}
22
23/**
24 * Subscribe to live localhost port scans, merge in configured /
25 * recently-seen URLs, and return a stable sorted list. Retains the scanner
26 * while mounted.
27 */
28export function useDiscoveredLocalServers(
29 input: UseDiscoveredLocalServersInput,
30): ReadonlyArray<PreviewableServer> {
31 const [scannerSnapshot, setScannerSnapshot] = useState<ReadonlyArray<DiscoveredLocalServer>>([]);
32
33 useEffect(() => {
34 const api = ensureEnvironmentApi(input.environmentId);
35 setScannerSnapshot([]);
36 const unsubscribe = api.preview.subscribePorts((next) => {
37 setScannerSnapshot(next.servers);
38 });
39 return unsubscribe;
40 }, [input.environmentId]);
41
42 return useMemo(
43 () =>
44 mergeServers({
45 scanner: scannerSnapshot,
46 configuredUrls: input.configuredUrls ?? [],
47 recentlySeenUrls: input.recentlySeenUrls ?? [],
48 }),
49 [scannerSnapshot, input.configuredUrls, input.recentlySeenUrls],
50 );
51}
52
53export function mergeServers(input: {
54 scanner: ReadonlyArray<DiscoveredLocalServer>;
55 configuredUrls: ReadonlyArray<string>;
56 recentlySeenUrls: ReadonlyArray<string>;
57}): ReadonlyArray<PreviewableServer> {
58 const seen = new Map<string, PreviewableServer>();
59
60 for (const url of input.configuredUrls) {
61 const parsed = parseLocalUrl(url);
62 if (!parsed) continue;
63 const key = canonicalKey(parsed.host, parsed.port);
64 if (seen.has(key)) continue;
65 seen.set(key, {
66 host: parsed.host,
67 port: parsed.port,
68 url: parsed.url,
69 processName: null,
70 pid: null,
71 source: "configured",
72 listening: false,
73 });
74 }
75
76 for (const server of input.scanner) {
77 const key = canonicalKey(server.host, server.port);
78 const existing = seen.get(key);
79 if (existing) {
80 // Enrich a configured entry with live process metadata; flip
81 // `listening` so it pulses green like a scanner-discovered entry.
82 seen.set(key, {
83 ...existing,
84 processName: server.processName ?? existing.processName,
85 pid: server.pid ?? existing.pid,
86 listening: true,
87 });
88 continue;
89 }
90 seen.set(key, { ...server, source: "scanner", listening: true });
91 }
92
93 for (const url of input.recentlySeenUrls) {
94 const parsed = parseLocalUrl(url);
95 if (!parsed) continue;
96 const key = canonicalKey(parsed.host, parsed.port);
97 if (seen.has(key)) continue;
98 seen.set(key, {
99 host: parsed.host,
100 port: parsed.port,
101 url: parsed.url,
102 processName: null,
103 pid: null,
104 source: "recent",
105 listening: false,
106 });
107 }
108
109 return Array.from(seen.values()).toSorted((a, b) => {
110 const sourceOrder: Record<PreviewableServer["source"], number> = {
111 configured: 0,
112 scanner: 1,
113 recent: 2,
114 };
115 if (sourceOrder[a.source] !== sourceOrder[b.source]) {
116 return sourceOrder[a.source] - sourceOrder[b.source];
117 }
118 return a.port - b.port;
119 });
120}
121
122function canonicalKey(host: string, port: number): string {
123 return `${host.toLowerCase()}:${port}`;
124}
125
126function parseLocalUrl(raw: string): { host: string; port: number; url: string } | null {
127 try {
128 const parsed = new URL(raw);
129 if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
130 if (!isLoopbackHost(parsed.hostname)) return null;
131 const port = parsed.port
132 ? Number.parseInt(parsed.port, 10)
133 : parsed.protocol === "http:"
134 ? 80
135 : 443;
136 if (!Number.isFinite(port) || port <= 0) return null;
137 return { host: parsed.hostname, port, url: parsed.href };
138 } catch {
139 return null;
140 }
141}
apps/web/src/components/preview/useLoadingProgress.ts created+45
......@@ -0,0 +1,45 @@
1import { useEffect, useRef, useState } from "react";
2
3const TICK_INTERVAL_MS = 120;
4const FADE_OUT_DELAY_MS = 220;
5const SEED_PERCENT = 4;
6const ASYMPTOTE_PERCENT = 90;
7const APPROACH_FACTOR = 0.08;
8const MIN_INCREMENT = 0.5;
9
10/**
11 * Indeterminate progress simulator for the preview chrome's loading bar.
12 * Animates 0 → 90% asymptotically while `loading` is true, snaps to 100%
13 * on release, then resets after a short pause.
14 *
15 * Uses a ref to thread the latest progress through interval ticks without
16 * needing `loading` to retrigger the effect, which sidesteps the stale-
17 * closure pitfalls of reading `progress` directly.
18 */
19export function useLoadingProgress(loading: boolean): number {
20 const [progress, setProgress] = useState(0);
21 const progressRef = useRef(0);
22 progressRef.current = progress;
23
24 useEffect(() => {
25 if (!loading) {
26 if (progressRef.current === 0) return;
27 setProgress(100);
28 const timer = window.setTimeout(() => setProgress(0), FADE_OUT_DELAY_MS);
29 return () => window.clearTimeout(timer);
30 }
31
32 setProgress((value) => (value > 0 && value < 95 ? value : SEED_PERCENT));
33 const interval = window.setInterval(() => {
34 const current = progressRef.current;
35 if (current >= ASYMPTOTE_PERCENT) return;
36 const remaining = ASYMPTOTE_PERCENT - current;
37 const increment = Math.max(MIN_INCREMENT, remaining * APPROACH_FACTOR);
38 setProgress(Math.min(ASYMPTOTE_PERCENT, current + increment));
39 }, TICK_INTERVAL_MS);
40
41 return () => window.clearInterval(interval);
42 }, [loading]);
43
44 return progress;
45}
apps/web/src/components/preview/usePreviewBridge.ts created+137
......@@ -0,0 +1,137 @@
1"use client";
2
3import type {
4 DesktopPreviewTabState,
5 PreviewReportStatusInput,
6 ScopedThreadRef,
7 ThreadId,
8} from "@t3tools/contracts";
9import { useEffect, useRef } from "react";
10
11import { ensureEnvironmentApi } from "~/environmentApi";
12import { type DesktopPreviewOverlay, usePreviewStateStore } from "~/previewStateStore";
13
14import { previewBridge } from "./previewBridge";
15
16/**
17 * Owns the desktop tab lifecycle, mirrors low-latency button state into the
18 * store, and reflects bridge navigation events back to the server.
19 *
20 * Tab create/close is anchored to the panel mount, NOT to the snapshot, so
21 * snapshot transitions (`opened` → `closed` → `opened`) cannot tear down and
22 * recreate the tab in the wrong order relative to in-flight desktop IPCs.
23 */
24export function usePreviewBridge(input: { threadRef: ScopedThreadRef; tabId: string }): void {
25 const { threadRef, tabId } = input;
26 const applyDesktopState = usePreviewStateStore((state) => state.applyDesktopState);
27 const bridge = previewBridge;
28
29 useEffect(() => {
30 if (!bridge) return;
31 void bridge.createTab(tabId);
32 return () => {
33 void bridge.closeTab(tabId);
34 };
35 }, [bridge, tabId]);
36
37 // One bridge subscription does both jobs (mirror state + forward to
38 // server) so the desktop bridge keeps a single listener entry per tab.
39 const lastReportedUrl = useRef<string | null>(null);
40 const lastReportedKind = useRef<DesktopPreviewTabState["navStatus"]["kind"] | null>(null);
41 useEffect(() => {
42 if (!bridge || typeof window === "undefined") return;
43 const api = ensureEnvironmentApi(threadRef.environmentId);
44 lastReportedUrl.current = null;
45 lastReportedKind.current = null;
46 const unsubscribe = bridge.onStateChange((changedTabId, state) => {
47 if (changedTabId !== tabId) return;
48 applyDesktopState(threadRef, projectDesktopState(state));
49 const reported = buildReportInput({
50 threadId: threadRef.threadId,
51 tabId,
52 state,
53 lastReportedUrl: lastReportedUrl.current,
54 lastReportedKind: lastReportedKind.current,
55 });
56 if (!reported) return;
57 lastReportedUrl.current = reported.lastReportedUrl;
58 lastReportedKind.current = reported.lastReportedKind;
59 void api.preview.reportStatus(reported.input).catch(() => undefined);
60 });
61 return unsubscribe;
62 }, [applyDesktopState, bridge, tabId, threadRef]);
63}
64
65function projectDesktopState(state: DesktopPreviewTabState): DesktopPreviewOverlay {
66 return {
67 canGoBack: state.canGoBack,
68 canGoForward: state.canGoForward,
69 loading: state.navStatus.kind === "Loading",
70 zoomFactor: state.zoomFactor,
71 };
72}
73
74/**
75 * Decide whether a state change warrants an RPC to the server, and shape
76 * the report payload.
77 *
78 * - Idle never reports — the tab is post-close or pre-load and the server
79 * already knows the canonical state from `open` / `closed`.
80 * - We dedupe on (kind, url): consecutive Loading→Loading→Loading for the
81 * same URL collapses to a single RPC, ditto Success.
82 * - LoadFailed always reports (the server uses it to emit `failed`).
83 */
84function buildReportInput(args: {
85 readonly threadId: ThreadId;
86 readonly tabId: string;
87 readonly state: DesktopPreviewTabState;
88 readonly lastReportedUrl: string | null;
89 readonly lastReportedKind: DesktopPreviewTabState["navStatus"]["kind"] | null;
90}): {
91 readonly input: PreviewReportStatusInput;
92 readonly lastReportedUrl: string;
93 readonly lastReportedKind: DesktopPreviewTabState["navStatus"]["kind"];
94} | null {
95 const { threadId, tabId, state, lastReportedUrl, lastReportedKind } = args;
96 const status = state.navStatus;
97 if (status.kind === "Idle") return null;
98
99 // Skip if we've already reported the same kind+url. LoadFailed always
100 // reports (rapid duplicate failures are unusual and worth surfacing).
101 const sameAsLast =
102 status.kind !== "LoadFailed" &&
103 status.kind === lastReportedKind &&
104 status.url === lastReportedUrl;
105 if (sameAsLast) return null;
106
107 const base = {
108 threadId,
109 tabId,
110 canGoBack: state.canGoBack,
111 canGoForward: state.canGoForward,
112 };
113 if (status.kind === "LoadFailed") {
114 return {
115 input: {
116 ...base,
117 navStatus: {
118 _tag: "LoadFailed",
119 url: status.url,
120 title: status.title,
121 code: status.code,
122 description: status.description,
123 },
124 },
125 lastReportedUrl: status.url,
126 lastReportedKind: "LoadFailed",
127 };
128 }
129 return {
130 input: {
131 ...base,
132 navStatus: { _tag: status.kind, url: status.url, title: status.title },
133 },
134 lastReportedUrl: status.url,
135 lastReportedKind: status.kind,
136 };
137}
apps/web/src/components/preview/usePreviewSession.ts created+67
......@@ -0,0 +1,67 @@
1"use client";
2
3import { scopedThreadKey } from "@t3tools/client-runtime";
4import type { ScopedThreadRef } from "@t3tools/contracts";
5import { useEffect } from "react";
6
7import { ensureEnvironmentApi } from "~/environmentApi";
8import { usePreviewStateStore } from "~/previewStateStore";
9
10/**
11 * Subscribes to the server's per-thread preview events and replays the
12 * latest snapshot on mount.
13 *
14 * Reconnect-recovery: when the local renderer remembers a snapshot but the
15 * server has none (server restarted while we were alive), re-issue
16 * `preview.open` so subsequent events land on a real session.
17 */
18export function usePreviewSession(threadRef: ScopedThreadRef): void {
19 const applyServerSnapshot = usePreviewStateStore((state) => state.applyServerSnapshot);
20 const applyServerEvent = usePreviewStateStore((state) => state.applyServerEvent);
21
22 useEffect(() => {
23 if (typeof window === "undefined") return;
24 const api = ensureEnvironmentApi(threadRef.environmentId);
25 const threadIdValue = threadRef.threadId;
26 let cancelled = false;
27
28 void api.preview
29 .list({ threadId: threadIdValue })
30 .then((result) => {
31 if (cancelled) return;
32 // Pick the most recent session. Server returns sessions sorted by
33 // `updatedAt` ascending, so the last one is freshest.
34 const serverSnapshot = result.sessions.at(-1) ?? null;
35 if (serverSnapshot) {
36 applyServerSnapshot(threadRef, serverSnapshot);
37 return;
38 }
39 // Server has no sessions — try to recover what the renderer
40 // remembers from before the disconnect.
41 const localSnapshot =
42 usePreviewStateStore.getState().byThreadKey[scopedThreadKey(threadRef)]?.snapshot;
43 const recoverableUrl =
44 localSnapshot && localSnapshot.navStatus._tag !== "Idle"
45 ? localSnapshot.navStatus.url
46 : null;
47 if (recoverableUrl) {
48 void api.preview
49 .open({ threadId: threadIdValue, url: recoverableUrl })
50 .catch(() => undefined);
51 } else {
52 applyServerSnapshot(threadRef, null);
53 }
54 })
55 .catch(() => undefined);
56
57 const unsubscribe = api.preview.onEvent((event) => {
58 if (event.threadId !== threadIdValue) return;
59 applyServerEvent(threadRef, event);
60 });
61
62 return () => {
63 cancelled = true;
64 unsubscribe();
65 };
66 }, [applyServerEvent, applyServerSnapshot, threadRef]);
67}
apps/web/src/environmentApi.ts+10
......@@ -58,6 +58,16 @@ export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi {
5858 subscribeThread: (input, callback, options) =>
5959 rpcClient.orchestration.subscribeThread(input, callback, options),
6060 },
61 preview: {
62 open: (input) => rpcClient.preview.open(input as never),
63 navigate: (input) => rpcClient.preview.navigate(input as never),
64 refresh: (input) => rpcClient.preview.refresh(input as never),
65 close: (input) => rpcClient.preview.close(input as never),
66 list: (input) => rpcClient.preview.list(input as never),
67 reportStatus: (input) => rpcClient.preview.reportStatus(input as never),
68 onEvent: (callback) => rpcClient.preview.onEvent(callback),
69 subscribePorts: (callback, options) => rpcClient.preview.subscribePorts(callback, options),
70 },
6171 };
6272}
6373
apps/web/src/hooks/useResizableWidth.ts created+176
......@@ -0,0 +1,176 @@
1import * as Schema from "effect/Schema";
2import {
3 type PointerEvent as ReactPointerEvent,
4 useCallback,
5 useEffect,
6 useRef,
7 useState,
8} from "react";
9
10import { getLocalStorageItem, setLocalStorageItem } from "./useLocalStorage";
11
12const WidthSchema = Schema.Finite;
13
14export interface UseResizableWidthOptions {
15 /** localStorage key the persisted width is stored under. */
16 readonly storageKey: string;
17 readonly defaultWidth: number;
18 readonly minWidth: number;
19 readonly maxWidth: number;
20 /**
21 * Which edge of the host element carries the drag handle:
22 * - "left" → panel grows leftward (right-anchored panels)
23 * - "right" → panel grows rightward (left-anchored panels)
24 */
25 readonly edge: "left" | "right";
26}
27
28export interface ResizableWidthHandlers {
29 readonly onPointerDown: (event: ReactPointerEvent<HTMLElement>) => void;
30 readonly onPointerMove: (event: ReactPointerEvent<HTMLElement>) => void;
31 readonly onPointerUp: (event: ReactPointerEvent<HTMLElement>) => void;
32 readonly onPointerCancel: (event: ReactPointerEvent<HTMLElement>) => void;
33}
34
35/**
36 * Width state for a side-anchored panel resized via a drag handle on the
37 * specified edge. Width is read from localStorage on mount and persisted on
38 * drag-end (not on every rAF tick — would otherwise be ~60 writes/sec).
39 *
40 * The hook updates an internal `width` state during drag (so the panel
41 * follows the cursor live) and only commits to localStorage when the user
42 * lifts the pointer.
43 */
44export function useResizableWidth(options: UseResizableWidthOptions): {
45 readonly width: number;
46 readonly handlers: ResizableWidthHandlers;
47} {
48 const { storageKey, defaultWidth, minWidth, maxWidth, edge } = options;
49
50 const clamp = useCallback(
51 (value: number): number => {
52 if (!Number.isFinite(value)) return defaultWidth;
53 return Math.max(minWidth, Math.min(maxWidth, value));
54 },
55 [defaultWidth, maxWidth, minWidth],
56 );
57
58 // No cross-tab subscription: panel width is per-window state.
59 const [width, setWidth] = useState<number>(() => {
60 if (typeof window === "undefined") return defaultWidth;
61 try {
62 const stored = getLocalStorageItem(storageKey, WidthSchema);
63 return clamp(stored ?? defaultWidth);
64 } catch {
65 return defaultWidth;
66 }
67 });
68
69 // Re-clamp if min/max change at runtime (e.g. window resize narrows max).
70 useEffect(() => {
71 setWidth((current) => clamp(current));
72 }, [clamp]);
73
74 const dragStateRef = useRef<{
75 pointerId: number;
76 startX: number;
77 startWidth: number;
78 pending: number;
79 rafId: number | null;
80 target: HTMLElement;
81 } | null>(null);
82
83 const releasePointer = useCallback((pointerId: number) => {
84 const state = dragStateRef.current;
85 if (!state) return;
86 if (state.rafId !== null) {
87 cancelAnimationFrame(state.rafId);
88 }
89 try {
90 if (state.target.hasPointerCapture(pointerId)) {
91 state.target.releasePointerCapture(pointerId);
92 }
93 } catch {
94 // pointer may already be released; harmless.
95 }
96 document.body.style.removeProperty("cursor");
97 document.body.style.removeProperty("user-select");
98 dragStateRef.current = null;
99 }, []);
100
101 const onPointerDown = useCallback(
102 (event: ReactPointerEvent<HTMLElement>) => {
103 if (event.button !== 0) return;
104 event.preventDefault();
105 event.stopPropagation();
106 const target = event.currentTarget;
107 try {
108 target.setPointerCapture(event.pointerId);
109 } catch {
110 return;
111 }
112 document.body.style.cursor = "col-resize";
113 document.body.style.userSelect = "none";
114 dragStateRef.current = {
115 pointerId: event.pointerId,
116 startX: event.clientX,
117 startWidth: width,
118 pending: width,
119 rafId: null,
120 target,
121 };
122 },
123 [width],
124 );
125
126 const onPointerMove = useCallback(
127 (event: ReactPointerEvent<HTMLElement>) => {
128 const state = dragStateRef.current;
129 if (!state || state.pointerId !== event.pointerId) return;
130 event.preventDefault();
131 const delta = edge === "left" ? state.startX - event.clientX : event.clientX - state.startX;
132 state.pending = clamp(state.startWidth + delta);
133 if (state.rafId !== null) return;
134 state.rafId = requestAnimationFrame(() => {
135 const active = dragStateRef.current;
136 if (!active) return;
137 active.rafId = null;
138 setWidth(active.pending);
139 });
140 },
141 [clamp, edge],
142 );
143
144 const onPointerUp = useCallback(
145 (event: ReactPointerEvent<HTMLElement>) => {
146 const state = dragStateRef.current;
147 if (!state || state.pointerId !== event.pointerId) return;
148 const finalWidth = clamp(state.pending);
149 releasePointer(event.pointerId);
150 // Commit once at drag-end to avoid 60Hz localStorage writes.
151 try {
152 setLocalStorageItem(storageKey, finalWidth, WidthSchema);
153 } catch {
154 // localStorage may be full / disabled; the in-memory state still wins.
155 }
156 setWidth(finalWidth);
157 },
158 [clamp, releasePointer, storageKey],
159 );
160
161 const onPointerCancel = useCallback(
162 (event: ReactPointerEvent<HTMLElement>) => {
163 const state = dragStateRef.current;
164 if (!state || state.pointerId !== event.pointerId) return;
165 // Don't persist a cancelled drag; revert to the start width.
166 releasePointer(event.pointerId);
167 setWidth(state.startWidth);
168 },
169 [releasePointer],
170 );
171
172 return {
173 width,
174 handlers: { onPointerDown, onPointerMove, onPointerUp, onPointerCancel },
175 };
176}
apps/web/src/keybindings.ts+28
......@@ -30,6 +30,8 @@ export interface ShortcutModifierStateLike {
3030export interface ShortcutMatchContext {
3131 terminalFocus: boolean;
3232 terminalOpen: boolean;
33 previewFocus: boolean;
34 previewOpen: boolean;
3335 [key: string]: boolean;
3436}
3537
......@@ -112,6 +114,8 @@ function resolveContext(options: ShortcutMatchOptions | undefined): ShortcutMatc
112114 return {
113115 terminalFocus: false,
114116 terminalOpen: false,
117 previewFocus: false,
118 previewOpen: false,
115119 ...options?.context,
116120 };
117121}
......@@ -379,6 +383,30 @@ export function isDiffToggleShortcut(
379383 return matchesCommandShortcut(event, keybindings, "diff.toggle", options);
380384}
381385
386export function isPreviewToggleShortcut(
387 event: ShortcutEventLike,
388 keybindings: ResolvedKeybindingsConfig,
389 options?: ShortcutMatchOptions,
390): boolean {
391 return matchesCommandShortcut(event, keybindings, "preview.toggle", options);
392}
393
394export function isPreviewRefreshShortcut(
395 event: ShortcutEventLike,
396 keybindings: ResolvedKeybindingsConfig,
397 options?: ShortcutMatchOptions,
398): boolean {
399 return matchesCommandShortcut(event, keybindings, "preview.refresh", options);
400}
401
402export function isPreviewFocusUrlShortcut(
403 event: ShortcutEventLike,
404 keybindings: ResolvedKeybindingsConfig,
405 options?: ShortcutMatchOptions,
406): boolean {
407 return matchesCommandShortcut(event, keybindings, "preview.focusUrl", options);
408}
409
382410export function isChatNewShortcut(
383411 event: ShortcutEventLike,
384412 keybindings: ResolvedKeybindingsConfig,
apps/web/src/lib/favicon.ts created+20
......@@ -0,0 +1,20 @@
1/**
2 * Favicon helpers for the preview tab strip.
3 *
4 * Uses Google's s2 favicon endpoint (same approach as ami's tab strip).
5 * Callers should always render a `<Globe />` fallback when the returned URL
6 * fails to load via an `onError` handler.
7 */
8const FAVICON_PROVIDER = "https://www.google.com/s2/favicons";
9
10export function faviconUrlForOrigin(rawUrl: string | null | undefined, size = 32): string | null {
11 if (!rawUrl) return null;
12 try {
13 const url = new URL(rawUrl);
14 if (!url.host) return null;
15 if (url.protocol !== "http:" && url.protocol !== "https:") return null;
16 return `${FAVICON_PROVIDER}?domain=${encodeURIComponent(url.host)}&sz=${size}`;
17 } catch {
18 return null;
19 }
20}
apps/web/src/lib/previewFocus.ts created+15
......@@ -0,0 +1,15 @@
1/**
2 * Returns true when the user's keyboard focus is somewhere inside the
3 * preview panel (URL bar, chrome buttons, or — once detected via Electron
4 * `<webview>` focus events — the embedded page).
5 *
6 * Used by the global keybinding handler to gate `preview.refresh` and
7 * `preview.focusUrl` to only fire while the preview owns focus.
8 */
9export function isPreviewFocused(): boolean {
10 const activeElement = document.activeElement;
11 if (!(activeElement instanceof HTMLElement)) return false;
12 if (!activeElement.isConnected) return false;
13 if (activeElement.tagName.toLowerCase() === "webview") return true;
14 return activeElement.closest("[data-preview-panel-mode]") !== null;
15}
apps/web/src/main.tsx+4
......@@ -18,6 +18,10 @@ import { getRouter } from "./router";
1818import { APP_DISPLAY_NAME } from "./branding";
1919import { syncDocumentWindowControlsOverlayClass } from "./lib/windowControlsOverlay";
2020
21if (import.meta.env.DEV) {
22 void import("react-grab");
23}
24
2125// Electron loads the app from a file-backed shell, so hash history avoids path resolution issues.
2226const history = isElectron ? createHashHistory() : createBrowserHistory();
2327
apps/web/src/previewStateStore.test.ts created+227
......@@ -0,0 +1,227 @@
1import { scopeThreadRef } from "@t3tools/client-runtime";
2import { type EnvironmentId, type PreviewSessionSnapshot, ThreadId } from "@t3tools/contracts";
3import { beforeEach, describe, expect, it } from "vitest";
4
5import { __testing, selectThreadPreviewState, usePreviewStateStore } from "./previewStateStore";
6
7const environmentId = "env-1" as EnvironmentId;
8const ref = scopeThreadRef(environmentId, ThreadId.make("thread-1"));
9
10const makeSnapshot = (overrides: Partial<PreviewSessionSnapshot> = {}): PreviewSessionSnapshot => ({
11 threadId: "thread-1",
12 tabId: "tab_a",
13 navStatus: { _tag: "Loading", url: "http://localhost:5173/", title: "" },
14 canGoBack: false,
15 canGoForward: false,
16 updatedAt: "2026-01-01T00:00:00.000Z",
17 ...overrides,
18});
19
20beforeEach(() => {
21 usePreviewStateStore.setState({ byThreadKey: {} });
22});
23
24describe("previewStateStore (single-tab)", () => {
25 it("opened event seeds the snapshot and remembers the URL", () => {
26 const snapshot = makeSnapshot();
27 usePreviewStateStore.getState().applyServerEvent(ref, {
28 type: "opened",
29 threadId: "thread-1",
30 tabId: snapshot.tabId,
31 createdAt: snapshot.updatedAt,
32 snapshot,
33 });
34 const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref);
35 expect(state.snapshot?.tabId).toBe(snapshot.tabId);
36 expect(state.recentlySeenUrls).toContain("http://localhost:5173/");
37 });
38
39 it("a second `opened` for a different tab replaces the rendered snapshot", () => {
40 const a = makeSnapshot({ tabId: "tab_a" });
41 const b = makeSnapshot({ tabId: "tab_b" });
42 const store = usePreviewStateStore.getState();
43 store.applyServerEvent(ref, {
44 type: "opened",
45 threadId: "thread-1",
46 tabId: a.tabId,
47 createdAt: a.updatedAt,
48 snapshot: a,
49 });
50 store.applyServerEvent(ref, {
51 type: "opened",
52 threadId: "thread-1",
53 tabId: b.tabId,
54 createdAt: b.updatedAt,
55 snapshot: b,
56 });
57 const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref);
58 expect(state.snapshot?.tabId).toBe(b.tabId);
59 });
60
61 it("navigated event updates the snapshot URL", () => {
62 const snapshot = makeSnapshot();
63 const store = usePreviewStateStore.getState();
64 store.applyServerEvent(ref, {
65 type: "opened",
66 threadId: "thread-1",
67 tabId: snapshot.tabId,
68 createdAt: snapshot.updatedAt,
69 snapshot,
70 });
71 store.applyServerEvent(ref, {
72 type: "navigated",
73 threadId: "thread-1",
74 tabId: snapshot.tabId,
75 createdAt: "2026-01-01T00:00:01.000Z",
76 snapshot: {
77 ...snapshot,
78 navStatus: { _tag: "Success", url: "http://localhost:5173/about", title: "About" },
79 },
80 });
81 const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref);
82 expect(state.snapshot?.navStatus._tag).toBe("Success");
83 if (state.snapshot?.navStatus._tag === "Success") {
84 expect(state.snapshot.navStatus.url).toBe("http://localhost:5173/about");
85 }
86 });
87
88 it("failed event flips the snapshot to LoadFailed when tabId matches", () => {
89 const snapshot = makeSnapshot();
90 const store = usePreviewStateStore.getState();
91 store.applyServerEvent(ref, {
92 type: "opened",
93 threadId: "thread-1",
94 tabId: snapshot.tabId,
95 createdAt: snapshot.updatedAt,
96 snapshot,
97 });
98 store.applyServerEvent(ref, {
99 type: "failed",
100 threadId: "thread-1",
101 tabId: snapshot.tabId,
102 createdAt: "2026-01-01T00:00:01.000Z",
103 url: "http://localhost:5173/",
104 title: "",
105 code: -105,
106 description: "ERR_NAME_NOT_RESOLVED",
107 });
108 const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref);
109 expect(state.snapshot?.navStatus._tag).toBe("LoadFailed");
110 });
111
112 it("failed event for a non-active tab is ignored", () => {
113 const snapshot = makeSnapshot({ tabId: "tab_a" });
114 const store = usePreviewStateStore.getState();
115 store.applyServerEvent(ref, {
116 type: "opened",
117 threadId: "thread-1",
118 tabId: snapshot.tabId,
119 createdAt: snapshot.updatedAt,
120 snapshot,
121 });
122 store.applyServerEvent(ref, {
123 type: "failed",
124 threadId: "thread-1",
125 tabId: "tab_b",
126 createdAt: "2026-01-01T00:00:01.000Z",
127 url: "http://localhost:9999/",
128 title: "",
129 code: -105,
130 description: "ERR_NAME_NOT_RESOLVED",
131 });
132 const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref);
133 expect(state.snapshot?.navStatus._tag).toBe("Loading");
134 });
135
136 it("closed event clears snapshot but retains recently-seen URLs", () => {
137 const snapshot = makeSnapshot();
138 const store = usePreviewStateStore.getState();
139 store.applyServerEvent(ref, {
140 type: "opened",
141 threadId: "thread-1",
142 tabId: snapshot.tabId,
143 createdAt: snapshot.updatedAt,
144 snapshot,
145 });
146 store.applyServerEvent(ref, {
147 type: "closed",
148 threadId: "thread-1",
149 tabId: snapshot.tabId,
150 createdAt: "2026-01-01T00:00:01.000Z",
151 });
152 const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref);
153 expect(state.snapshot).toBeNull();
154 expect(state.recentlySeenUrls).toContain("http://localhost:5173/");
155 });
156
157 it("closed event for a different tab is a no-op", () => {
158 const snapshot = makeSnapshot({ tabId: "tab_a" });
159 const store = usePreviewStateStore.getState();
160 store.applyServerEvent(ref, {
161 type: "opened",
162 threadId: "thread-1",
163 tabId: snapshot.tabId,
164 createdAt: snapshot.updatedAt,
165 snapshot,
166 });
167 store.applyServerEvent(ref, {
168 type: "closed",
169 threadId: "thread-1",
170 tabId: "tab_b",
171 createdAt: "2026-01-01T00:00:01.000Z",
172 });
173 const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref);
174 expect(state.snapshot?.tabId).toBe(snapshot.tabId);
175 });
176
177 it("desktopOverlay updates independently of snapshot", () => {
178 const snapshot = makeSnapshot();
179 const store = usePreviewStateStore.getState();
180 store.applyServerEvent(ref, {
181 type: "opened",
182 threadId: "thread-1",
183 tabId: snapshot.tabId,
184 createdAt: snapshot.updatedAt,
185 snapshot,
186 });
187 store.applyDesktopState(ref, {
188 canGoBack: true,
189 canGoForward: false,
190 loading: false,
191 zoomFactor: 1,
192 });
193 const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref);
194 expect(state.desktopOverlay?.canGoBack).toBe(true);
195 expect(state.snapshot?.canGoBack).toBe(false);
196 });
197
198 it("applyServerSnapshot null clears snapshot for a thread that had one", () => {
199 const snapshot = makeSnapshot();
200 const store = usePreviewStateStore.getState();
201 store.applyServerSnapshot(ref, snapshot);
202 store.applyServerSnapshot(ref, null);
203 const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref);
204 expect(state.snapshot).toBeNull();
205 });
206
207 it("rememberUrl dedupes and caps at limit", () => {
208 const store = usePreviewStateStore.getState();
209 for (let i = 0; i < __testing.RECENT_URL_LIMIT + 5; i += 1) {
210 store.rememberUrl(ref, `http://localhost:${5000 + i}/`);
211 }
212 const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref);
213 expect(state.recentlySeenUrls.length).toBeLessThanOrEqual(__testing.RECENT_URL_LIMIT);
214 expect(state.recentlySeenUrls[0]).toBe(
215 `http://localhost:${5000 + __testing.RECENT_URL_LIMIT + 4}/`,
216 );
217 });
218
219 it("removeThread strips the entry", () => {
220 const snapshot = makeSnapshot();
221 const store = usePreviewStateStore.getState();
222 store.applyServerSnapshot(ref, snapshot);
223 store.removeThread(ref);
224 const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref);
225 expect(state).toEqual(__testing.EMPTY_THREAD_PREVIEW_STATE);
226 });
227});
apps/web/src/previewStateStore.ts created+188
......@@ -0,0 +1,188 @@
1/**
2 * Per-thread preview UI state.
3 *
4 * Single-tab model: one snapshot per thread, mirrored two ways:
5 * - `snapshot` is the server-authoritative URL/title/load-status, replayed
6 * on WS reconnect so the panel survives backend restarts.
7 * - `desktopOverlay` is low-latency state from the local <webview>
8 * (canGoBack/canGoForward/visible/zoom/loading), used by the chrome row's
9 * button enablement.
10 *
11 * The schema-level `tabId` exists because the server still keys sessions by
12 * `(threadId, tabId)`; the client just always tracks one and ignores the rest.
13 */
14import { scopedThreadKey } from "@t3tools/client-runtime";
15import {
16 type PreviewEvent,
17 type PreviewSessionSnapshot,
18 type ScopedThreadRef,
19} from "@t3tools/contracts";
20import { create } from "zustand";
21
22import { PREVIEW_RECENT_URL_LIMIT } from "./components/preview/previewConstants";
23
24export interface DesktopPreviewOverlay {
25 canGoBack: boolean;
26 canGoForward: boolean;
27 loading: boolean;
28 zoomFactor: number;
29}
30
31export interface ThreadPreviewState {
32 snapshot: PreviewSessionSnapshot | null;
33 /** Bridge state takes precedence over `snapshot` for nav button enablement. */
34 desktopOverlay: DesktopPreviewOverlay | null;
35 /** Recently-visited URLs surfaced in the empty state. */
36 recentlySeenUrls: string[];
37}
38
39const EMPTY_THREAD_PREVIEW_STATE: ThreadPreviewState = Object.freeze({
40 snapshot: null,
41 desktopOverlay: null,
42 recentlySeenUrls: [] as string[],
43});
44
45interface PreviewStateStoreState {
46 byThreadKey: Record<string, ThreadPreviewState>;
47 applyServerEvent: (ref: ScopedThreadRef, event: PreviewEvent) => void;
48 applyServerSnapshot: (ref: ScopedThreadRef, snapshot: PreviewSessionSnapshot | null) => void;
49 applyDesktopState: (ref: ScopedThreadRef, overlay: DesktopPreviewOverlay | null) => void;
50 rememberUrl: (ref: ScopedThreadRef, url: string) => void;
51 removeThread: (ref: ScopedThreadRef) => void;
52}
53
54const ensureState = (
55 byThreadKey: Record<string, ThreadPreviewState>,
56 threadKey: string,
57): ThreadPreviewState => byThreadKey[threadKey] ?? EMPTY_THREAD_PREVIEW_STATE;
58
59const updateThread = (
60 state: PreviewStateStoreState,
61 threadKey: string,
62 updater: (current: ThreadPreviewState) => ThreadPreviewState,
63): PreviewStateStoreState["byThreadKey"] => {
64 const current = ensureState(state.byThreadKey, threadKey);
65 const next = updater(current);
66 if (next === current) return state.byThreadKey;
67 return { ...state.byThreadKey, [threadKey]: next };
68};
69
70const removeThreadKey = (
71 byThreadKey: Record<string, ThreadPreviewState>,
72 threadKey: string,
73): Record<string, ThreadPreviewState> => {
74 if (!(threadKey in byThreadKey)) return byThreadKey;
75 const { [threadKey]: _removed, ...rest } = byThreadKey;
76 return rest;
77};
78
79const dedupeRecentUrls = (existing: string[], url: string): string[] => {
80 const next = [url, ...existing.filter((entry) => entry !== url)];
81 return next.slice(0, PREVIEW_RECENT_URL_LIMIT);
82};
83
84export const usePreviewStateStore = create<PreviewStateStoreState>()((set) => ({
85 byThreadKey: {},
86 applyServerEvent: (ref, event) =>
87 set((state) => {
88 const threadKey = scopedThreadKey(ref);
89 let nextByThread = state.byThreadKey;
90 switch (event.type) {
91 case "opened":
92 case "navigated":
93 nextByThread = updateThread(state, threadKey, (current) => {
94 const snapshot = event.snapshot;
95 const recentlySeenUrls =
96 snapshot.navStatus._tag === "Idle"
97 ? current.recentlySeenUrls
98 : dedupeRecentUrls(current.recentlySeenUrls, snapshot.navStatus.url);
99 return { ...current, snapshot, recentlySeenUrls };
100 });
101 break;
102 case "failed":
103 nextByThread = updateThread(state, threadKey, (current) => {
104 if (!current.snapshot || current.snapshot.tabId !== event.tabId) return current;
105 return {
106 ...current,
107 snapshot: {
108 ...current.snapshot,
109 navStatus: {
110 _tag: "LoadFailed",
111 url: event.url,
112 title: event.title,
113 code: event.code,
114 description: event.description,
115 },
116 updatedAt: event.createdAt,
117 },
118 };
119 });
120 break;
121 case "closed":
122 nextByThread = updateThread(state, threadKey, (current) => {
123 // Only clear if the closed tab is the one we were tracking; the
124 // server may have multiple tabs per thread but we only render one.
125 if (current.snapshot && current.snapshot.tabId !== event.tabId) return current;
126 return { ...current, snapshot: null, desktopOverlay: null };
127 });
128 break;
129 }
130 return { byThreadKey: nextByThread };
131 }),
132 applyServerSnapshot: (ref, snapshot) =>
133 set((state) => {
134 const threadKey = scopedThreadKey(ref);
135 const nextByThread = updateThread(state, threadKey, (current) => {
136 if (!snapshot && current.snapshot === null) return current;
137 const recentlySeenUrls =
138 snapshot && snapshot.navStatus._tag !== "Idle"
139 ? dedupeRecentUrls(current.recentlySeenUrls, snapshot.navStatus.url)
140 : current.recentlySeenUrls;
141 return { ...current, snapshot, recentlySeenUrls };
142 });
143 return { byThreadKey: nextByThread };
144 }),
145 applyDesktopState: (ref, overlay) =>
146 set((state) => {
147 const threadKey = scopedThreadKey(ref);
148 const nextByThread = updateThread(state, threadKey, (current) => ({
149 ...current,
150 desktopOverlay: overlay,
151 }));
152 return { byThreadKey: nextByThread };
153 }),
154 rememberUrl: (ref, url) =>
155 set((state) => {
156 if (url.trim().length === 0) return state;
157 const threadKey = scopedThreadKey(ref);
158 const nextByThread = updateThread(state, threadKey, (current) => ({
159 ...current,
160 recentlySeenUrls: dedupeRecentUrls(current.recentlySeenUrls, url),
161 }));
162 return { byThreadKey: nextByThread };
163 }),
164 removeThread: (ref) =>
165 set((state) => {
166 const threadKey = scopedThreadKey(ref);
167 if (!(threadKey in state.byThreadKey)) return state;
168 return { byThreadKey: removeThreadKey(state.byThreadKey, threadKey) };
169 }),
170}));
171
172export function selectThreadPreviewState(
173 byThreadKey: Record<string, ThreadPreviewState>,
174 ref: ScopedThreadRef | null | undefined,
175): ThreadPreviewState {
176 if (!ref) return EMPTY_THREAD_PREVIEW_STATE;
177 return ensureState(byThreadKey, scopedThreadKey(ref));
178}
179
180export function isPreviewSupportedInRuntime(): boolean {
181 if (typeof window === "undefined") return false;
182 return Boolean(window.desktopBridge?.preview);
183}
184
185export const __testing = {
186 EMPTY_THREAD_PREVIEW_STATE,
187 RECENT_URL_LIMIT: PREVIEW_RECENT_URL_LIMIT,
188};
apps/web/src/rightPanelStore.test.ts created+70
......@@ -0,0 +1,70 @@
1import { scopeThreadRef } from "@t3tools/client-runtime";
2import { type EnvironmentId, ThreadId } from "@t3tools/contracts";
3import { beforeEach, describe, expect, it } from "vitest";
4
5import {
6 selectActiveRightPanel,
7 selectActiveRightPanelKindWithUrl,
8 useRightPanelStore,
9} from "./rightPanelStore";
10
11const refA = scopeThreadRef("env-1" as EnvironmentId, ThreadId.make("thread-A"));
12const refB = scopeThreadRef("env-1" as EnvironmentId, ThreadId.make("thread-B"));
13
14beforeEach(() => {
15 useRightPanelStore.setState({ byThreadKey: {} });
16});
17
18describe("rightPanelStore", () => {
19 it("open sets the active panel for a thread", () => {
20 useRightPanelStore.getState().open(refA, "preview");
21 expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("preview");
22 expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refB)).toBeNull();
23 });
24
25 it("opening a different kind replaces the previous one", () => {
26 useRightPanelStore.getState().open(refA, "plan");
27 useRightPanelStore.getState().open(refA, "preview");
28 expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("preview");
29 });
30
31 it("close clears the active panel", () => {
32 useRightPanelStore.getState().open(refA, "plan");
33 useRightPanelStore.getState().close(refA);
34 expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBeNull();
35 });
36
37 it("toggle opens then closes the same kind", () => {
38 useRightPanelStore.getState().toggle(refA, "preview");
39 expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("preview");
40 useRightPanelStore.getState().toggle(refA, "preview");
41 expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBeNull();
42 });
43
44 it("toggle to a different kind switches active", () => {
45 useRightPanelStore.getState().toggle(refA, "preview");
46 useRightPanelStore.getState().toggle(refA, "plan");
47 expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("plan");
48 });
49
50 it("?diff=1 always wins over persisted state", () => {
51 useRightPanelStore.getState().open(refA, "preview");
52 expect(
53 selectActiveRightPanelKindWithUrl(useRightPanelStore.getState().byThreadKey, refA, true),
54 ).toBe("diff");
55 expect(
56 selectActiveRightPanelKindWithUrl(useRightPanelStore.getState().byThreadKey, refA, false),
57 ).toBe("preview");
58 });
59
60 it("removeThread clears persisted state", () => {
61 useRightPanelStore.getState().open(refA, "plan");
62 useRightPanelStore.getState().removeThread(refA);
63 expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBeNull();
64 });
65
66 it("close on never-opened thread is a no-op", () => {
67 useRightPanelStore.getState().close(refA);
68 expect(useRightPanelStore.getState().byThreadKey).toEqual({});
69 });
70});
apps/web/src/rightPanelStore.ts created+114
......@@ -0,0 +1,114 @@
1/**
2 * Per-thread arbiter for the right-side panel.
3 *
4 * Three tenants share the same slot: the plan sidebar, the diff panel, and
5 * the preview panel. Only one is open at a time per thread; the choice is
6 * remembered across thread switches.
7 *
8 * The diff panel still uses `?diff=1` as URL truth for deep-linking — when
9 * that param is present the diff tenant wins regardless of what's persisted
10 * here. See `selectActiveRightPanelKindWithUrl` for the resolution rule.
11 */
12import { scopedThreadKey } from "@t3tools/client-runtime";
13import type { ScopedThreadRef } from "@t3tools/contracts";
14import { create } from "zustand";
15import { createJSONStorage, persist } from "zustand/middleware";
16
17import { resolveStorage } from "./lib/storage";
18
19export const RIGHT_PANEL_KINDS = ["plan", "diff", "preview"] as const;
20export type RightPanelKind = (typeof RIGHT_PANEL_KINDS)[number];
21
22const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v1";
23
24interface ThreadRightPanelState {
25 active: RightPanelKind | null;
26}
27
28interface RightPanelStoreState {
29 byThreadKey: Record<string, ThreadRightPanelState>;
30 open: (ref: ScopedThreadRef, kind: RightPanelKind) => void;
31 close: (ref: ScopedThreadRef) => void;
32 toggle: (ref: ScopedThreadRef, kind: RightPanelKind) => void;
33 removeThread: (ref: ScopedThreadRef) => void;
34}
35
36const updateThread = (
37 byThreadKey: Record<string, ThreadRightPanelState>,
38 threadKey: string,
39 next: ThreadRightPanelState,
40): Record<string, ThreadRightPanelState> => {
41 const current = byThreadKey[threadKey];
42 if (current && current.active === next.active) return byThreadKey;
43 if (next.active === null) {
44 if (!current) return byThreadKey;
45 const { [threadKey]: _removed, ...rest } = byThreadKey;
46 return rest;
47 }
48 return { ...byThreadKey, [threadKey]: next };
49};
50
51export const useRightPanelStore = create<RightPanelStoreState>()(
52 persist(
53 (set) => ({
54 byThreadKey: {},
55 open: (ref, kind) =>
56 set((state) => ({
57 byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), { active: kind }),
58 })),
59 close: (ref) =>
60 set((state) => ({
61 byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), { active: null }),
62 })),
63 toggle: (ref, kind) =>
64 set((state) => {
65 const threadKey = scopedThreadKey(ref);
66 const current = state.byThreadKey[threadKey]?.active ?? null;
67 const next: RightPanelKind | null = current === kind ? null : kind;
68 return {
69 byThreadKey: updateThread(state.byThreadKey, threadKey, { active: next }),
70 };
71 }),
72 removeThread: (ref) =>
73 set((state) => {
74 const threadKey = scopedThreadKey(ref);
75 if (!(threadKey in state.byThreadKey)) return state;
76 const { [threadKey]: _removed, ...rest } = state.byThreadKey;
77 return { byThreadKey: rest };
78 }),
79 }),
80 {
81 name: RIGHT_PANEL_STORAGE_KEY,
82 version: 1,
83 storage: createJSONStorage(() =>
84 resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined),
85 ),
86 partialize: (state) => ({ byThreadKey: state.byThreadKey }),
87 },
88 ),
89);
90
91export function selectActiveRightPanel(
92 byThreadKey: Record<string, ThreadRightPanelState>,
93 ref: ScopedThreadRef | null | undefined,
94): RightPanelKind | null {
95 if (!ref) return null;
96 return byThreadKey[scopedThreadKey(ref)]?.active ?? null;
97}
98
99/**
100 * Resolves the active right panel taking the `?diff=1` URL truth into
101 * account. When `diff=1` the diff panel always wins.
102 *
103 * Prefer using `useSyncDiffSearchToRightPanel` (in ChatView) to mirror the
104 * URL into the store and consume `selectActiveRightPanel` directly — this
105 * helper exists for callers that don't want to install the sync effect.
106 */
107export function selectActiveRightPanelKindWithUrl(
108 byThreadKey: Record<string, ThreadRightPanelState>,
109 ref: ScopedThreadRef | null | undefined,
110 diffSearchActive: boolean,
111): RightPanelKind | null {
112 if (diffSearchActive) return "diff";
113 return selectActiveRightPanel(byThreadKey, ref);
114}
apps/web/src/routes/_chat.tsx+61
......@@ -2,16 +2,21 @@ import { Outlet, createFileRoute, redirect } from "@tanstack/react-router";
22import { useEffect } from "react";
33
44import { useCommandPaletteStore } from "../commandPaletteStore";
5import { dispatchPreviewAction } from "../components/preview/previewActionBus";
56import { useHandleNewThread } from "../hooks/useHandleNewThread";
67import {
78 startNewLocalThreadFromContext,
89 startNewThreadFromContext,
910} from "../lib/chatThreadActions";
11import { isPreviewFocused } from "../lib/previewFocus";
1012import { isTerminalFocused } from "../lib/terminalFocus";
1113import { resolveShortcutCommand } from "../keybindings";
1214import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";
15import { isPreviewSupportedInRuntime } from "../previewStateStore";
16import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore";
1317import { useThreadSelectionStore } from "../threadSelectionStore";
1418import { resolveSidebarNewThreadEnvMode } from "~/components/Sidebar.logic";
19import { stackedThreadToast, toastManager } from "~/components/ui/toast";
1520import { useSettings } from "~/hooks/useSettings";
1621import { useServerKeybindings } from "~/rpc/serverState";
1722
......@@ -26,6 +31,14 @@ function ChatRouteGlobalShortcuts() {
2631 ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen
2732 : false,
2833 );
34 // The `previewOpen` shortcut-context flag here uses the store-only value;
35 // the URL-aware arbitration lives inside ChatView's `onTogglePreview`,
36 // which we invoke via the action bus to avoid duplicating the rule.
37 const previewOpen = useRightPanelStore((state) =>
38 routeThreadRef
39 ? selectActiveRightPanel(state.byThreadKey, routeThreadRef) === "preview"
40 : false,
41 );
2942 const appSettings = useSettings();
3043
3144 useEffect(() => {
......@@ -35,6 +48,8 @@ function ChatRouteGlobalShortcuts() {
3548 context: {
3649 terminalFocus: isTerminalFocused(),
3750 terminalOpen,
51 previewFocus: isPreviewFocused(),
52 previewOpen,
3853 },
3954 });
4055
......@@ -75,6 +90,50 @@ function ChatRouteGlobalShortcuts() {
7590 }),
7691 handleNewThread,
7792 });
93 return;
94 }
95
96 if (command === "preview.toggle") {
97 event.preventDefault();
98 event.stopPropagation();
99 if (!routeThreadRef) return;
100 if (!isPreviewSupportedInRuntime()) {
101 toastManager.add(
102 stackedThreadToast({
103 type: "info",
104 title: "Preview is desktop-only",
105 description: "Open T3 Code in the desktop app to use the in-app preview.",
106 }),
107 );
108 return;
109 }
110 dispatchPreviewAction("toggle-panel");
111 return;
112 }
113
114 // The remaining preview commands only fire when the panel is the
115 // currently-focused tenant. The `when: previewFocus` rule already
116 // gates this, but defend against the keybinding being misconfigured.
117 if (
118 command === "preview.refresh" ||
119 command === "preview.focusUrl" ||
120 command === "preview.zoomIn" ||
121 command === "preview.zoomOut" ||
122 command === "preview.resetZoom"
123 ) {
124 event.preventDefault();
125 event.stopPropagation();
126 const action =
127 command === "preview.refresh"
128 ? "refresh"
129 : command === "preview.focusUrl"
130 ? "focus-url"
131 : command === "preview.zoomIn"
132 ? "zoom-in"
133 : command === "preview.zoomOut"
134 ? "zoom-out"
135 : "reset-zoom";
136 dispatchPreviewAction(action);
78137 }
79138 };
80139
......@@ -89,6 +148,8 @@ function ChatRouteGlobalShortcuts() {
89148 handleNewThread,
90149 keybindings,
91150 defaultProjectRef,
151 previewOpen,
152 routeThreadRef,
92153 selectedThreadKeysSize,
93154 terminalOpen,
94155 appSettings.defaultThreadEnvMode,
docs/user/keybindings.md+14
......@@ -23,6 +23,12 @@ See the full schema for more details: [`packages/contracts/src/keybindings.ts`](
2323 { "key": "mod+d", "command": "terminal.split", "when": "terminalFocus" },
2424 { "key": "mod+n", "command": "terminal.new", "when": "terminalFocus" },
2525 { "key": "mod+w", "command": "terminal.close", "when": "terminalFocus" },
26 { "key": "mod+shift+j", "command": "preview.toggle" },
27 { "key": "mod+r", "command": "preview.refresh", "when": "previewFocus" },
28 { "key": "mod+l", "command": "preview.focusUrl", "when": "previewFocus" },
29 { "key": "mod+=", "command": "preview.zoomIn", "when": "previewFocus" },
30 { "key": "mod+-", "command": "preview.zoomOut", "when": "previewFocus" },
31 { "key": "mod+0", "command": "preview.resetZoom", "when": "previewFocus" },
2632 { "key": "mod+k", "command": "commandPalette.toggle", "when": "!terminalFocus" },
2733 { "key": "mod+n", "command": "chat.new", "when": "!terminalFocus" },
2834 { "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
5157- `terminal.split`: split terminal (in focused terminal context by default)
5258- `terminal.new`: create new terminal (in focused terminal context by default)
5359- `terminal.close`: close/kill the focused terminal (in focused terminal context by default)
60- `preview.toggle`: open/close the in-app browser preview panel (desktop app only)
61- `preview.refresh`: reload the active preview tab (in focused preview context by default)
62- `preview.focusUrl`: focus the URL input of the preview panel (in focused preview context by default)
63- `preview.zoomIn`: zoom the preview viewport in one step (in focused preview context by default)
64- `preview.zoomOut`: zoom the preview viewport out one step (in focused preview context by default)
65- `preview.resetZoom`: reset the preview zoom to 100% (in focused preview context by default)
5466- `commandPalette.toggle`: open or close the global command palette
5567- `chat.new`: create a new chat thread preserving the active thread's branch/worktree state
5668- `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:
8092
8193- `terminalFocus`
8294- `terminalOpen`
95- `previewFocus`
96- `previewOpen`
8397
8498Supported operators:
8599
packages/client-runtime/src/wsRpcClient.ts+31
......@@ -79,6 +79,16 @@ export interface WsRpcClient {
7979 readonly onEvent: RpcStreamMethod<typeof WS_METHODS.subscribeTerminalEvents>;
8080 readonly onMetadata: RpcStreamMethod<typeof WS_METHODS.subscribeTerminalMetadata>;
8181 };
82 readonly preview: {
83 readonly open: RpcUnaryMethod<typeof WS_METHODS.previewOpen>;
84 readonly navigate: RpcUnaryMethod<typeof WS_METHODS.previewNavigate>;
85 readonly refresh: RpcUnaryMethod<typeof WS_METHODS.previewRefresh>;
86 readonly close: RpcUnaryMethod<typeof WS_METHODS.previewClose>;
87 readonly list: RpcUnaryMethod<typeof WS_METHODS.previewList>;
88 readonly reportStatus: RpcUnaryMethod<typeof WS_METHODS.previewReportStatus>;
89 readonly onEvent: RpcStreamMethod<typeof WS_METHODS.subscribePreviewEvents>;
90 readonly subscribePorts: RpcStreamMethod<typeof WS_METHODS.subscribeDiscoveredLocalServers>;
91 };
8292 readonly projects: {
8393 readonly searchEntries: RpcUnaryMethod<typeof WS_METHODS.projectsSearchEntries>;
8494 readonly writeFile: RpcUnaryMethod<typeof WS_METHODS.projectsWriteFile>;
......@@ -212,6 +222,27 @@ export function createWsRpcClient(
212222 subscriptionOptions(options, WS_METHODS.subscribeTerminalMetadata),
213223 ),
214224 },
225 preview: {
226 open: (input) => transport.request((client) => client[WS_METHODS.previewOpen](input)),
227 navigate: (input) => transport.request((client) => client[WS_METHODS.previewNavigate](input)),
228 refresh: (input) => transport.request((client) => client[WS_METHODS.previewRefresh](input)),
229 close: (input) => transport.request((client) => client[WS_METHODS.previewClose](input)),
230 list: (input) => transport.request((client) => client[WS_METHODS.previewList](input)),
231 reportStatus: (input) =>
232 transport.request((client) => client[WS_METHODS.previewReportStatus](input)),
233 onEvent: (listener, options) =>
234 transport.subscribe(
235 (client) => client[WS_METHODS.subscribePreviewEvents]({}),
236 listener,
237 options,
238 ),
239 subscribePorts: (listener, options) =>
240 transport.subscribe(
241 (client) => client[WS_METHODS.subscribeDiscoveredLocalServers]({}),
242 listener,
243 options,
244 ),
245 },
215246 projects: {
216247 searchEntries: (input) =>
217248 transport.request((client) => client[WS_METHODS.projectsSearchEntries](input)),
packages/contracts/src/index.ts+1
......@@ -22,4 +22,5 @@ export * from "./editor.ts";
2222export * from "./project.ts";
2323export * from "./filesystem.ts";
2424export * from "./review.ts";
25export * from "./preview.ts";
2526export * from "./rpc.ts";
packages/contracts/src/ipc.ts+80
......@@ -54,6 +54,18 @@ import type {
5454} from "./terminal.ts";
5555import type { ServerRemoveKeybindingInput, ServerUpsertKeybindingInput } from "./server.ts";
5656import * as Schema from "effect/Schema";
57import type {
58 DiscoveredLocalServerList,
59 PreviewCloseInput,
60 PreviewEvent,
61 PreviewListInput,
62 PreviewListResult,
63 PreviewNavigateInput,
64 PreviewOpenInput,
65 PreviewRefreshInput,
66 PreviewReportStatusInput,
67 PreviewSessionSnapshot,
68} from "./preview.ts";
5769import type {
5870 ClientOrchestrationCommand,
5971 OrchestrationGetFullThreadDiffInput,
......@@ -402,6 +414,33 @@ export const DesktopCloudAuthFetchResultSchema = Schema.Struct({
402414});
403415export type DesktopCloudAuthFetchResult = typeof DesktopCloudAuthFetchResultSchema.Type;
404416
417/**
418 * Renderer-facing snapshot of a desktop preview tab. Mirrors the main-process
419 * PreviewTabState shape but uses serialisable primitives only.
420 */
421export type DesktopPreviewNavStatus =
422 | { kind: "Idle" }
423 | { kind: "Loading"; url: string; title: string }
424 | { kind: "Success"; url: string; title: string }
425 | {
426 kind: "LoadFailed";
427 url: string;
428 title: string;
429 code: number;
430 description: string;
431 };
432
433export interface DesktopPreviewTabState {
434 tabId: string;
435 webContentsId: number | null;
436 navStatus: DesktopPreviewNavStatus;
437 canGoBack: boolean;
438 canGoForward: boolean;
439 /** Current zoom factor (1.0 = 100%). */
440 zoomFactor: number;
441 updatedAt: string;
442}
443
405444export interface DesktopBridge {
406445 getAppBranding: () => DesktopAppBranding | null;
407446 getLocalEnvironmentBootstrap: () => DesktopEnvironmentBootstrap | null;
......@@ -460,6 +499,34 @@ export interface DesktopBridge {
460499 downloadUpdate: () => Promise<DesktopUpdateActionResult>;
461500 installUpdate: () => Promise<DesktopUpdateActionResult>;
462501 onUpdateState: (listener: (state: DesktopUpdateState) => void) => () => void;
502 /**
503 * Desktop-only preview surface. Present iff the renderer is hosted by the
504 * Electron desktop build; web builds have `preview === undefined`.
505 */
506 preview?: DesktopPreviewBridge;
507}
508
509export interface DesktopPreviewBridge {
510 createTab: (tabId: string) => Promise<void>;
511 closeTab: (tabId: string) => Promise<void>;
512 registerWebview: (tabId: string, webContentsId: number) => Promise<void>;
513 navigate: (tabId: string, url: string) => Promise<void>;
514 goBack: (tabId: string) => Promise<void>;
515 goForward: (tabId: string) => Promise<void>;
516 refresh: (tabId: string) => Promise<void>;
517 zoomIn: (tabId: string) => Promise<void>;
518 zoomOut: (tabId: string) => Promise<void>;
519 resetZoom: (tabId: string) => Promise<void>;
520 /** Reload bypassing the HTTP cache. */
521 hardReload: (tabId: string) => Promise<void>;
522 /** Open the guest webview's DevTools (detached). */
523 openDevTools: (tabId: string) => Promise<void>;
524 /** Drop cookies + storage data for the preview partition (all tabs). */
525 clearCookies: () => Promise<void>;
526 /** Drop the HTTP cache for the preview partition (all tabs). */
527 clearCache: () => Promise<void>;
528 getBrowserPartition: () => Promise<string>;
529 onStateChange: (listener: (tabId: string, state: DesktopPreviewTabState) => void) => () => void;
463530}
464531
465532/**
......@@ -619,4 +686,17 @@ export interface EnvironmentApi {
619686 },
620687 ) => () => void;
621688 };
689 preview: {
690 open: (input: typeof PreviewOpenInput.Encoded) => Promise<PreviewSessionSnapshot>;
691 navigate: (input: typeof PreviewNavigateInput.Encoded) => Promise<PreviewSessionSnapshot>;
692 refresh: (input: typeof PreviewRefreshInput.Encoded) => Promise<void>;
693 close: (input: typeof PreviewCloseInput.Encoded) => Promise<void>;
694 list: (input: typeof PreviewListInput.Encoded) => Promise<PreviewListResult>;
695 reportStatus: (input: typeof PreviewReportStatusInput.Encoded) => Promise<void>;
696 onEvent: (callback: (event: PreviewEvent) => void) => () => void;
697 subscribePorts: (
698 callback: (servers: DiscoveredLocalServerList) => void,
699 options?: { onResubscribe?: () => void },
700 ) => () => void;
701 };
622702}
packages/contracts/src/keybindings.ts+6
......@@ -53,6 +53,12 @@ const STATIC_KEYBINDING_COMMANDS = [
5353 "terminal.new",
5454 "terminal.close",
5555 "diff.toggle",
56 "preview.toggle",
57 "preview.refresh",
58 "preview.focusUrl",
59 "preview.zoomIn",
60 "preview.zoomOut",
61 "preview.resetZoom",
5662 "commandPalette.toggle",
5763 "chat.new",
5864 "chat.newLocal",
packages/contracts/src/orchestration.ts+11
......@@ -194,6 +194,17 @@ export const ProjectScript = Schema.Struct({
194194 command: TrimmedNonEmptyString,
195195 icon: ProjectScriptIcon,
196196 runOnWorktreeCreate: Schema.Boolean,
197 /**
198 * URL to open in the in-app browser preview when this script runs (or
199 * when the user explicitly requests a preview). Optional; only honored on
200 * the desktop build.
201 */
202 previewUrl: Schema.optional(TrimmedNonEmptyString),
203 /**
204 * When true, automatically open the preview panel pointed at `previewUrl`
205 * the moment this script starts. Ignored without `previewUrl` or on web.
206 */
207 autoOpenPreview: Schema.optional(Schema.Boolean),
197208});
198209export type ProjectScript = typeof ProjectScript.Type;
199210
packages/contracts/src/preview.test.ts created+162
......@@ -0,0 +1,162 @@
1import { Schema } from "effect";
2import { describe, expect, it } from "vitest";
3
4import {
5 DiscoveredLocalServer,
6 PreviewEvent,
7 PreviewNavStatus,
8 PreviewSessionSnapshot,
9} from "./preview.ts";
10
11const decodePreviewEvent = Schema.decodeUnknownSync(PreviewEvent);
12const decodeSnapshot = Schema.decodeUnknownSync(PreviewSessionSnapshot);
13const decodeNavStatus = Schema.decodeUnknownSync(PreviewNavStatus);
14const decodeServer = Schema.decodeUnknownSync(DiscoveredLocalServer);
15
16describe("PreviewNavStatus", () => {
17 it("decodes Idle", () => {
18 expect(decodeNavStatus({ _tag: "Idle" })).toEqual({ _tag: "Idle" });
19 });
20
21 it("decodes Loading with title", () => {
22 expect(decodeNavStatus({ _tag: "Loading", url: "http://localhost:5173/", title: "" })).toEqual({
23 _tag: "Loading",
24 url: "http://localhost:5173/",
25 title: "",
26 });
27 });
28
29 it("decodes LoadFailed with code/description", () => {
30 expect(
31 decodeNavStatus({
32 _tag: "LoadFailed",
33 url: "https://example.com/",
34 title: "Example",
35 code: -105,
36 description: "ERR_NAME_NOT_RESOLVED",
37 }),
38 ).toEqual({
39 _tag: "LoadFailed",
40 url: "https://example.com/",
41 title: "Example",
42 code: -105,
43 description: "ERR_NAME_NOT_RESOLVED",
44 });
45 });
46
47 it("rejects empty url", () => {
48 expect(() => decodeNavStatus({ _tag: "Loading", url: "", title: "" })).toThrow();
49 });
50});
51
52describe("PreviewSessionSnapshot", () => {
53 it("round-trips a Success snapshot", () => {
54 const snapshot = decodeSnapshot({
55 threadId: "thread-1",
56 tabId: "preview-thread-1",
57 navStatus: {
58 _tag: "Success",
59 url: "http://localhost:5173/",
60 title: "Vite App",
61 },
62 canGoBack: false,
63 canGoForward: false,
64 updatedAt: "2026-01-01T00:00:00.000Z",
65 });
66 expect(snapshot.tabId).toBe("preview-thread-1");
67 expect(snapshot.navStatus._tag).toBe("Success");
68 });
69});
70
71describe("PreviewEvent", () => {
72 it("decodes opened", () => {
73 const event = decodePreviewEvent({
74 type: "opened",
75 threadId: "t",
76 tabId: "preview-t",
77 createdAt: "2026-01-01T00:00:00.000Z",
78 snapshot: {
79 threadId: "t",
80 tabId: "preview-t",
81 navStatus: { _tag: "Idle" },
82 canGoBack: false,
83 canGoForward: false,
84 updatedAt: "2026-01-01T00:00:00.000Z",
85 },
86 });
87 expect(event.type).toBe("opened");
88 });
89
90 it("decodes failed with code/description", () => {
91 const event = decodePreviewEvent({
92 type: "failed",
93 threadId: "t",
94 tabId: "preview-t",
95 createdAt: "2026-01-01T00:00:00.000Z",
96 url: "https://example.com/",
97 title: "",
98 code: -105,
99 description: "ERR_NAME_NOT_RESOLVED",
100 });
101 expect(event.type).toBe("failed");
102 if (event.type === "failed") {
103 expect(event.code).toBe(-105);
104 }
105 });
106
107 it("decodes closed without snapshot", () => {
108 const event = decodePreviewEvent({
109 type: "closed",
110 threadId: "t",
111 tabId: "preview-t",
112 createdAt: "2026-01-01T00:00:00.000Z",
113 });
114 expect(event.type).toBe("closed");
115 });
116});
117
118describe("DiscoveredLocalServer", () => {
119 it("decodes a server with process metadata", () => {
120 const server = decodeServer({
121 host: "localhost",
122 port: 5173,
123 url: "http://localhost:5173",
124 processName: "node",
125 pid: 12345,
126 });
127 expect(server.port).toBe(5173);
128 expect(server.processName).toBe("node");
129 });
130
131 it("decodes a server without process metadata", () => {
132 const server = decodeServer({
133 host: "localhost",
134 port: 3000,
135 url: "http://localhost:3000",
136 processName: null,
137 pid: null,
138 });
139 expect(server.processName).toBeNull();
140 });
141
142 it("rejects invalid ports", () => {
143 expect(() =>
144 decodeServer({
145 host: "localhost",
146 port: 0,
147 url: "http://localhost:0",
148 processName: null,
149 pid: null,
150 }),
151 ).toThrow();
152 expect(() =>
153 decodeServer({
154 host: "localhost",
155 port: 70000,
156 url: "http://localhost:70000",
157 processName: null,
158 pid: null,
159 }),
160 ).toThrow();
161 });
162});
packages/contracts/src/preview.ts created+181
......@@ -0,0 +1,181 @@
1/**
2 * Preview - Schemas for the in-app browser preview surface.
3 *
4 * The preview is desktop-only (Chromium <webview>); the server tracks per-thread
5 * tab metadata so it survives client reconnects and multi-window. The desktop
6 * renderer mediates: it owns the actual <webview> and reports navigation back to
7 * the server via these RPCs, the server fans events to all subscribers.
8 *
9 * @module Preview
10 */
11import { Schema } from "effect";
12import { ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts";
13
14const Url = TrimmedNonEmptyString.check(Schema.isMaxLength(2048));
15const Title = Schema.String.check(Schema.isMaxLength(512));
16
17export const PreviewTabId = TrimmedNonEmptyString.check(Schema.isMaxLength(128));
18export type PreviewTabId = typeof PreviewTabId.Type;
19
20export const PreviewNavStatus = Schema.Union([
21 Schema.TaggedStruct("Idle", {}),
22 Schema.TaggedStruct("Loading", {
23 url: Url,
24 title: Title,
25 }),
26 Schema.TaggedStruct("Success", {
27 url: Url,
28 title: Title,
29 }),
30 Schema.TaggedStruct("LoadFailed", {
31 url: Url,
32 title: Title,
33 code: Schema.Int,
34 description: Schema.String,
35 }),
36]);
37export type PreviewNavStatus = typeof PreviewNavStatus.Type;
38
39export const PreviewSessionSnapshot = Schema.Struct({
40 threadId: TrimmedNonEmptyString,
41 tabId: PreviewTabId,
42 navStatus: PreviewNavStatus,
43 canGoBack: Schema.Boolean,
44 canGoForward: Schema.Boolean,
45 updatedAt: Schema.String,
46});
47export type PreviewSessionSnapshot = typeof PreviewSessionSnapshot.Type;
48
49export const PreviewOpenInput = Schema.Struct({
50 threadId: ThreadId,
51 /** Omit to create an empty (Idle) tab the user can type into. */
52 url: Schema.optional(Url),
53});
54export type PreviewOpenInput = typeof PreviewOpenInput.Type;
55
56export const PreviewNavigateInput = Schema.Struct({
57 threadId: ThreadId,
58 tabId: PreviewTabId,
59 url: Url,
60 resolvedTitle: Schema.optional(Title),
61});
62export type PreviewNavigateInput = typeof PreviewNavigateInput.Type;
63
64export const PreviewReportStatusInput = Schema.Struct({
65 threadId: ThreadId,
66 tabId: PreviewTabId,
67 navStatus: PreviewNavStatus,
68 canGoBack: Schema.Boolean,
69 canGoForward: Schema.Boolean,
70});
71export type PreviewReportStatusInput = typeof PreviewReportStatusInput.Type;
72
73export const PreviewRefreshInput = Schema.Struct({
74 threadId: ThreadId,
75 tabId: PreviewTabId,
76});
77export type PreviewRefreshInput = typeof PreviewRefreshInput.Type;
78
79export const PreviewCloseInput = Schema.Struct({
80 threadId: ThreadId,
81 tabId: Schema.optional(PreviewTabId),
82});
83export type PreviewCloseInput = typeof PreviewCloseInput.Type;
84
85export const PreviewListInput = Schema.Struct({
86 threadId: ThreadId,
87});
88export type PreviewListInput = typeof PreviewListInput.Type;
89
90export const PreviewListResult = Schema.Struct({
91 sessions: Schema.Array(PreviewSessionSnapshot),
92});
93export type PreviewListResult = typeof PreviewListResult.Type;
94
95const PreviewEventBaseSchema = Schema.Struct({
96 threadId: TrimmedNonEmptyString,
97 tabId: PreviewTabId,
98 createdAt: Schema.String,
99});
100
101const PreviewOpenedEvent = Schema.Struct({
102 ...PreviewEventBaseSchema.fields,
103 type: Schema.Literal("opened"),
104 snapshot: PreviewSessionSnapshot,
105});
106
107const PreviewNavigatedEvent = Schema.Struct({
108 ...PreviewEventBaseSchema.fields,
109 type: Schema.Literal("navigated"),
110 snapshot: PreviewSessionSnapshot,
111});
112
113const PreviewFailedEvent = Schema.Struct({
114 ...PreviewEventBaseSchema.fields,
115 type: Schema.Literal("failed"),
116 url: Url,
117 title: Title,
118 code: Schema.Int,
119 description: Schema.String,
120});
121
122const PreviewClosedEvent = Schema.Struct({
123 ...PreviewEventBaseSchema.fields,
124 type: Schema.Literal("closed"),
125});
126
127export const PreviewEvent = Schema.Union([
128 PreviewOpenedEvent,
129 PreviewNavigatedEvent,
130 PreviewFailedEvent,
131 PreviewClosedEvent,
132]);
133export type PreviewEvent = typeof PreviewEvent.Type;
134
135/**
136 * A localhost server detected by the port scanner. Used to populate the
137 * "Local" recommendations in the empty-state of the preview panel.
138 */
139export const DiscoveredLocalServer = Schema.Struct({
140 host: TrimmedNonEmptyString,
141 port: Schema.Int.check(Schema.isGreaterThan(0)).check(Schema.isLessThan(65536)),
142 url: Url,
143 processName: Schema.NullOr(TrimmedNonEmptyString),
144 pid: Schema.NullOr(Schema.Int.check(Schema.isGreaterThan(0))),
145});
146export type DiscoveredLocalServer = typeof DiscoveredLocalServer.Type;
147
148export const DiscoveredLocalServerList = Schema.Struct({
149 servers: Schema.Array(DiscoveredLocalServer),
150 scannedAt: Schema.String,
151});
152export type DiscoveredLocalServerList = typeof DiscoveredLocalServerList.Type;
153
154export class PreviewSessionLookupError extends Schema.TaggedErrorClass<PreviewSessionLookupError>()(
155 "PreviewSessionLookupError",
156 {
157 threadId: Schema.String,
158 tabId: Schema.String,
159 },
160) {
161 override get message() {
162 return `Unknown preview session: thread=${this.threadId}, tab=${this.tabId}`;
163 }
164}
165
166export class PreviewInvalidUrlError extends Schema.TaggedErrorClass<PreviewInvalidUrlError>()(
167 "PreviewInvalidUrlError",
168 {
169 rawUrl: Schema.String,
170 detail: Schema.optional(Schema.String),
171 },
172) {
173 override get message() {
174 return this.detail
175 ? `Invalid preview URL: ${this.rawUrl} (${this.detail})`
176 : `Invalid preview URL: ${this.rawUrl}`;
177 }
178}
179
180export const PreviewError = Schema.Union([PreviewSessionLookupError, PreviewInvalidUrlError]);
181export type PreviewError = typeof PreviewError.Type;
packages/contracts/src/rpc.ts+78
......@@ -85,6 +85,19 @@ import {
8585 TerminalSessionSnapshot,
8686 TerminalWriteInput,
8787} from "./terminal.ts";
88import {
89 DiscoveredLocalServerList,
90 PreviewCloseInput,
91 PreviewError,
92 PreviewEvent,
93 PreviewListInput,
94 PreviewListResult,
95 PreviewNavigateInput,
96 PreviewOpenInput,
97 PreviewRefreshInput,
98 PreviewReportStatusInput,
99 PreviewSessionSnapshot,
100} from "./preview.ts";
88101import {
89102 ServerConfigStreamEvent,
90103 ServerConfig,
......@@ -157,6 +170,14 @@ export const WS_METHODS = {
157170 terminalRestart: "terminal.restart",
158171 terminalClose: "terminal.close",
159172
173 // Preview methods
174 previewOpen: "preview.open",
175 previewNavigate: "preview.navigate",
176 previewRefresh: "preview.refresh",
177 previewClose: "preview.close",
178 previewList: "preview.list",
179 previewReportStatus: "preview.reportStatus",
180
160181 // Server meta
161182 serverGetConfig: "server.getConfig",
162183 serverRefreshProviders: "server.refreshProviders",
......@@ -184,6 +205,8 @@ export const WS_METHODS = {
184205 subscribeVcsStatus: "subscribeVcsStatus",
185206 subscribeTerminalEvents: "subscribeTerminalEvents",
186207 subscribeTerminalMetadata: "subscribeTerminalMetadata",
208 subscribePreviewEvents: "subscribePreviewEvents",
209 subscribeDiscoveredLocalServers: "subscribeDiscoveredLocalServers",
187210 subscribeServerConfig: "subscribeServerConfig",
188211 subscribeServerLifecycle: "subscribeServerLifecycle",
189212 subscribeAuthAccess: "subscribeAuthAccess",
......@@ -454,6 +477,53 @@ export const WsTerminalCloseRpc = Rpc.make(WS_METHODS.terminalClose, {
454477 error: Schema.Union([TerminalError, EnvironmentAuthorizationError]),
455478});
456479
480export const WsPreviewOpenRpc = Rpc.make(WS_METHODS.previewOpen, {
481 payload: PreviewOpenInput,
482 success: PreviewSessionSnapshot,
483 error: PreviewError,
484});
485
486export const WsPreviewNavigateRpc = Rpc.make(WS_METHODS.previewNavigate, {
487 payload: PreviewNavigateInput,
488 success: PreviewSessionSnapshot,
489 error: PreviewError,
490});
491
492export const WsPreviewRefreshRpc = Rpc.make(WS_METHODS.previewRefresh, {
493 payload: PreviewRefreshInput,
494 error: PreviewError,
495});
496
497export const WsPreviewCloseRpc = Rpc.make(WS_METHODS.previewClose, {
498 payload: PreviewCloseInput,
499 error: PreviewError,
500});
501
502export const WsPreviewListRpc = Rpc.make(WS_METHODS.previewList, {
503 payload: PreviewListInput,
504 success: PreviewListResult,
505});
506
507export const WsPreviewReportStatusRpc = Rpc.make(WS_METHODS.previewReportStatus, {
508 payload: PreviewReportStatusInput,
509 error: PreviewError,
510});
511
512export const WsSubscribePreviewEventsRpc = Rpc.make(WS_METHODS.subscribePreviewEvents, {
513 payload: Schema.Struct({}),
514 success: PreviewEvent,
515 stream: true,
516});
517
518export const WsSubscribeDiscoveredLocalServersRpc = Rpc.make(
519 WS_METHODS.subscribeDiscoveredLocalServers,
520 {
521 payload: Schema.Struct({}),
522 success: DiscoveredLocalServerList,
523 stream: true,
524 },
525);
526
457527export const WsOrchestrationDispatchCommandRpc = Rpc.make(
458528 ORCHESTRATION_WS_METHODS.dispatchCommand,
459529 {
......@@ -589,6 +659,14 @@ export const WsRpcGroup = RpcGroup.make(
589659 WsTerminalCloseRpc,
590660 WsSubscribeTerminalEventsRpc,
591661 WsSubscribeTerminalMetadataRpc,
662 WsPreviewOpenRpc,
663 WsPreviewNavigateRpc,
664 WsPreviewRefreshRpc,
665 WsPreviewCloseRpc,
666 WsPreviewListRpc,
667 WsPreviewReportStatusRpc,
668 WsSubscribePreviewEventsRpc,
669 WsSubscribeDiscoveredLocalServersRpc,
592670 WsSubscribeServerConfigRpc,
593671 WsSubscribeServerLifecycleRpc,
594672 WsSubscribeAuthAccessRpc,
packages/shared/package.json+4
......@@ -150,6 +150,10 @@
150150 "./relayClient": {
151151 "types": "./src/relayClient.ts",
152152 "import": "./src/relayClient.ts"
153 },
154 "./preview": {
155 "types": "./src/preview.ts",
156 "import": "./src/preview.ts"
153157 }
154158 },
155159 "scripts": {
packages/shared/src/keybindings.ts+7
......@@ -24,6 +24,13 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [
2424 { key: "mod+n", command: "terminal.new", when: "terminalFocus" },
2525 { key: "mod+w", command: "terminal.close", when: "terminalFocus" },
2626 { key: "mod+d", command: "diff.toggle", when: "!terminalFocus" },
27 { key: "mod+shift+j", command: "preview.toggle" },
28 { key: "mod+r", command: "preview.refresh", when: "previewFocus" },
29 { key: "mod+l", command: "preview.focusUrl", when: "previewFocus" },
30 { key: "mod+=", command: "preview.zoomIn", when: "previewFocus" },
31 { key: "mod++", command: "preview.zoomIn", when: "previewFocus" },
32 { key: "mod+-", command: "preview.zoomOut", when: "previewFocus" },
33 { key: "mod+0", command: "preview.resetZoom", when: "previewFocus" },
2734 { key: "mod+k", command: "commandPalette.toggle", when: "!terminalFocus" },
2835 { key: "mod+n", command: "chat.new", when: "!terminalFocus" },
2936 { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" },
packages/shared/src/preview.test.ts created+75
......@@ -0,0 +1,75 @@
1import { describe, expect, it } from "vitest";
2
3import {
4 isLoopbackHost,
5 isPreviewableUrl,
6 newPreviewTabId,
7 normalizePreviewUrl,
8 PreviewUrlNormalizationError,
9} from "./preview.ts";
10
11describe("newPreviewTabId", () => {
12 it("returns a unique tab id every call", () => {
13 const a = newPreviewTabId();
14 const b = newPreviewTabId();
15 expect(a).not.toBe(b);
16 expect(a.startsWith("tab_")).toBe(true);
17 });
18});
19
20describe("isLoopbackHost", () => {
21 it.each(["localhost", "127.0.0.1", "0.0.0.0", "::1", "[::1]"])("%s is loopback", (host) => {
22 expect(isLoopbackHost(host)).toBe(true);
23 });
24
25 it.each(["example.com", "192.168.1.10", "10.0.0.1", ""])("%s is not loopback", (host) => {
26 expect(isLoopbackHost(host)).toBe(false);
27 });
28});
29
30describe("isPreviewableUrl", () => {
31 it.each([
32 "http://localhost:5173",
33 "http://127.0.0.1:3000/path",
34 "http://0.0.0.0:8080",
35 "http://[::1]:5173",
36 ])("%s is previewable", (url) => {
37 expect(isPreviewableUrl(url)).toBe(true);
38 });
39
40 it.each(["https://example.com", "ws://localhost:5173", "file:///etc/passwd", "not-a-url", ""])(
41 "%s is not previewable",
42 (url) => {
43 expect(isPreviewableUrl(url)).toBe(false);
44 },
45 );
46});
47
48describe("normalizePreviewUrl", () => {
49 it("treats bare loopback hosts as http", () => {
50 expect(normalizePreviewUrl("localhost:5173")).toBe("http://localhost:5173/");
51 expect(normalizePreviewUrl("127.0.0.1:3000")).toBe("http://127.0.0.1:3000/");
52 });
53
54 it("treats bare public hosts as https", () => {
55 expect(normalizePreviewUrl("example.com")).toBe("https://example.com/");
56 });
57
58 it("respects explicit schemes", () => {
59 expect(normalizePreviewUrl("https://localhost:5173")).toBe("https://localhost:5173/");
60 expect(normalizePreviewUrl("http://example.com/path?q=1")).toBe("http://example.com/path?q=1");
61 });
62
63 it("rejects empty input", () => {
64 expect(() => normalizePreviewUrl(" ")).toThrow(PreviewUrlNormalizationError);
65 });
66
67 it("rejects unsupported protocols", () => {
68 expect(() => normalizePreviewUrl("ftp://example.com")).toThrow(PreviewUrlNormalizationError);
69 expect(() => normalizePreviewUrl("file:///etc/passwd")).toThrow(PreviewUrlNormalizationError);
70 });
71
72 it("rejects unparseable junk", () => {
73 expect(() => normalizePreviewUrl("http://")).toThrow(PreviewUrlNormalizationError);
74 });
75});
packages/shared/src/preview.ts created+92
......@@ -0,0 +1,92 @@
1/**
2 * Pure URL helpers shared between the preview server, desktop main process,
3 * and web renderer. Centralising these guarantees the four call sites agree
4 * on what counts as "loopback" and how to normalise a free-form URL string.
5 */
6
7const TAB_ID_PREFIX = "tab_";
8
9/**
10 * Generate a fresh preview tab id. Lives in shared (not contracts) because
11 * the contracts package is schema-only — runtime helpers belong here.
12 */
13export function newPreviewTabId(): string {
14 if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
15 return `${TAB_ID_PREFIX}${crypto.randomUUID()}`;
16 }
17 return `${TAB_ID_PREFIX}${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`;
18}
19
20const LOOPBACK_HOSTS: ReadonlySet<string> = new Set(["localhost", "127.0.0.1", "0.0.0.0", "::1"]);
21
22/** Internal — used by `lsof` parsing where the host string is wire-formatted. */
23export const LSOF_LOCAL_HOST_TOKENS: ReadonlySet<string> = new Set([
24 ...LOOPBACK_HOSTS,
25 "*",
26 "[::]",
27 "[::1]",
28]);
29
30const LOOPBACK_PREFIX_PATTERN = /^(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1?\])(?::|\/|$)/i;
31
32export function isLoopbackHost(host: string): boolean {
33 if (LOOPBACK_HOSTS.has(host)) return true;
34 if (host === "[::1]") return true;
35 return false;
36}
37
38/** True when a raw URL string looks like a loopback dev URL we can preview. */
39export function isPreviewableUrl(rawUrl: string): boolean {
40 try {
41 const parsed = new URL(rawUrl);
42 if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;
43 return isLoopbackHost(parsed.hostname);
44 } catch {
45 return false;
46 }
47}
48
49export class PreviewUrlNormalizationError extends Error {
50 readonly rawUrl: string;
51 readonly detail: string;
52 constructor(rawUrl: string, detail: string) {
53 super(`Invalid preview URL: ${rawUrl} (${detail})`);
54 this.name = "PreviewUrlNormalizationError";
55 this.rawUrl = rawUrl;
56 this.detail = detail;
57 }
58}
59
60/**
61 * Normalise a free-form URL string into a fully-qualified `http(s)://` URL.
62 *
63 * - Bare loopback hosts (`localhost`, `localhost:5173`) become `http://...`.
64 * - Bare public hosts (`example.com`) become `https://...`.
65 * - Already-qualified URLs are validated and returned as `URL.href`.
66 *
67 * Throws `PreviewUrlNormalizationError` for empty, unparseable, or
68 * unsupported-protocol inputs.
69 */
70export function normalizePreviewUrl(rawUrl: string): string {
71 const trimmed = rawUrl.trim();
72 if (trimmed.length === 0) {
73 throw new PreviewUrlNormalizationError(rawUrl, "empty");
74 }
75 const useHttp = LOOPBACK_PREFIX_PATTERN.test(trimmed);
76 const candidate = trimmed.includes("://")
77 ? trimmed
78 : `${useHttp ? "http" : "https"}://${trimmed}`;
79 let parsed: URL;
80 try {
81 parsed = new URL(candidate);
82 } catch (cause) {
83 throw new PreviewUrlNormalizationError(
84 rawUrl,
85 cause instanceof Error ? cause.message : "unparseable",
86 );
87 }
88 if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
89 throw new PreviewUrlNormalizationError(rawUrl, `unsupported protocol ${parsed.protocol}`);
90 }
91 return parsed.href;
92}