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(...@@ -143,7 +143,7 @@ export function selectActiveRightPanel(
143- Remove the local `planSidebarOpen` state (`:688`).143- Remove the local `planSidebarOpen` state (`:688`).
144- Replace `setPlanSidebarOpen(true)` callsites with `rightPanelStore.open(activeThreadRef, "plan")`.144- Replace `setPlanSidebarOpen(true)` callsites with `rightPanelStore.open(activeThreadRef, "plan")`.
145- Replace `closePlanSidebar` with `rightPanelStore.close(activeThreadRef)`.145- 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
148### Render arbitration148### Render arbitration
149149
...@@ -176,49 +176,49 @@ const useSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY);...@@ -176,49 +176,49 @@ const useSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY);
176176
177### NEW files177### NEW files
178178
179| File | Purpose |179| File | Purpose |
180|---|---|180| ------------------------------------------------------------ | ----------------------------------------------------------------------- |
181| `packages/contracts/src/preview.ts` | Effect/Schema schemas: inputs, snapshot, events, errors |181| `packages/contracts/src/preview.ts` | Effect/Schema schemas: inputs, snapshot, events, errors |
182| `packages/contracts/src/preview.test.ts` | Schema round‑trip tests |182| `packages/contracts/src/preview.test.ts` | Schema round‑trip tests |
183| `apps/server/src/preview/Services/Manager.ts` | `PreviewManager` Service tag + interface |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 |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 |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) |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) |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`) |188| `apps/web/src/previewStateStore.ts` | Per‑thread zustand store (mirrors `terminalStateStore.ts`) |
189| `apps/web/src/previewStateStore.test.ts` | Reducer tests |189| `apps/web/src/previewStateStore.test.ts` | Reducer tests |
190| `apps/web/src/rightPanelStore.ts` | Right‑panel arbiter |190| `apps/web/src/rightPanelStore.ts` | Right‑panel arbiter |
191| `apps/web/src/rightPanelStore.test.ts` | Arbiter tests |191| `apps/web/src/rightPanelStore.test.ts` | Arbiter tests |
192| `apps/web/src/components/preview/PreviewPanel.tsx` | Right‑panel entry (wraps `PreviewPanelShell`) |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"`) |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>` |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 |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 |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 |197| `apps/web/src/components/preview/PreviewUnsupportedToast.ts` | `"Preview is only available in the desktop app"` toast |
198198
199### MODIFIED files199### MODIFIED files
200200
201| File | Change |201| File | Change |
202|---|---|202| --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
203| `packages/contracts/src/keybindings.ts` | Add `preview.toggle`, `preview.refresh`, `preview.focusUrl` to `STATIC_KEYBINDING_COMMANDS`; add `previewFocus`, `previewOpen` to context keys |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 |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 |205| `packages/contracts/src/server.ts` | Extend `EnvironmentApi` with `preview` namespace |
206| `packages/contracts/src/index.ts` | Re‑export new types |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`) |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` |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` |209| `apps/server/src/orchestration/runtimeLayer.ts` (or equivalent) | Provide `PreviewManager.Default` |
210| `apps/web/src/environmentApi.ts` | Wire `preview` slot in `createEnvironmentApi` |210| `apps/web/src/environmentApi.ts` | Wire `preview` slot in `createEnvironmentApi` |
211| `apps/web/src/keybindings.ts` | Add `isPreviewToggleShortcut`, `isPreviewRefreshShortcut` helpers |211| `apps/web/src/keybindings.ts` | Add `isPreviewToggleShortcut`, `isPreviewRefreshShortcut` helpers |
212| `apps/web/src/routes/_chat.tsx` | Handle `preview.toggle` in global shortcut handler |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` |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 |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 |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 |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) |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 |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 |219| `apps/desktop/src/main.ts` | Register `preview:*` IPC handlers; instantiate `previewViewManager`; wire `mainWindow` injection |
220| `apps/desktop/src/preload.ts` | Expose `desktopBridge.preview.*` |220| `apps/desktop/src/preload.ts` | Expose `desktopBridge.preview.*` |
221| `KEYBINDINGS.md` | Document new commands and `previewFocus`/`previewOpen` `when` keys |221| `KEYBINDINGS.md` | Document new commands and `previewFocus`/`previewOpen` `when` keys |
222222
223### NOT changed223### NOT changed
224224
...@@ -335,10 +335,7 @@ export class PreviewInvalidUrlError extends Schema.TaggedErrorClass<PreviewInval...@@ -335,10 +335,7 @@ export class PreviewInvalidUrlError extends Schema.TaggedErrorClass<PreviewInval
335 }335 }
336}336}
337337
338export const PreviewError = Schema.Union([338export const PreviewError = Schema.Union([PreviewSessionLookupError, PreviewInvalidUrlError]);
339 PreviewSessionLookupError,
340 PreviewInvalidUrlError,
341]);
342export type PreviewError = typeof PreviewError.Type;339export type PreviewError = typeof PreviewError.Type;
343```340```
344341
...@@ -360,9 +357,9 @@ const STATIC_KEYBINDING_COMMANDS = [...@@ -360,9 +357,9 @@ const STATIC_KEYBINDING_COMMANDS = [
360 "terminal.new",357 "terminal.new",
361 "terminal.close",358 "terminal.close",
362 "diff.toggle",359 "diff.toggle",
363 "preview.toggle", // NEW360 "preview.toggle", // NEW
364 "preview.refresh", // NEW361 "preview.refresh", // NEW
365 "preview.focusUrl", // NEW362 "preview.focusUrl", // NEW
366 "commandPalette.toggle",363 "commandPalette.toggle",
367 "chat.new",364 "chat.new",
368 "chat.newLocal",365 "chat.newLocal",
...@@ -399,28 +396,21 @@ import {...@@ -399,28 +396,21 @@ import {
399} from "@t3tools/contracts";396} from "@t3tools/contracts";
400397
401export interface PreviewManagerShape {398export interface PreviewManagerShape {
402 readonly open: (399 readonly open: (input: PreviewOpenInput) => Effect.Effect<PreviewSessionSnapshot, PreviewError>;
403 input: PreviewOpenInput,
404 ) => Effect.Effect<PreviewSessionSnapshot, PreviewError>;
405 readonly navigate: (400 readonly navigate: (
406 input: PreviewNavigateInput,401 input: PreviewNavigateInput,
407 ) => Effect.Effect<PreviewSessionSnapshot, PreviewError>;402 ) => Effect.Effect<PreviewSessionSnapshot, PreviewError>;
408 readonly refresh: (403 readonly refresh: (input: PreviewRefreshInput) => Effect.Effect<void, PreviewError>;
409 input: PreviewRefreshInput,
410 ) => Effect.Effect<void, PreviewError>;
411 readonly close: (input: PreviewCloseInput) => Effect.Effect<void, PreviewError>;404 readonly close: (input: PreviewCloseInput) => Effect.Effect<void, PreviewError>;
412 readonly list: (405 readonly list: (threadId: string) => Effect.Effect<ReadonlyArray<PreviewSessionSnapshot>>;
413 threadId: string,
414 ) => Effect.Effect<ReadonlyArray<PreviewSessionSnapshot>>;
415 readonly subscribe: (406 readonly subscribe: (
416 listener: (event: PreviewEvent) => Effect.Effect<void>,407 listener: (event: PreviewEvent) => Effect.Effect<void>,
417 ) => Effect.Effect<() => void>;408 ) => Effect.Effect<() => void>;
418}409}
419410
420export class PreviewManager extends Context.Service<411export class PreviewManager extends Context.Service<PreviewManager, PreviewManagerShape>()(
421 PreviewManager,412 "t3/preview/Services/Manager/PreviewManager",
422 PreviewManagerShape413) {}
423>()("t3/preview/Services/Manager/PreviewManager") {}
424```414```
425415
426### `apps/server/src/preview/Layers/Manager.ts`416### `apps/server/src/preview/Layers/Manager.ts`
...@@ -435,15 +425,12 @@ const normalizeUrl = (input: string) =>...@@ -435,15 +425,12 @@ const normalizeUrl = (input: string) =>
435 const trimmed = input.trim();425 const trimmed = input.trim();
436 if (!trimmed) throw new Error("empty");426 if (!trimmed) throw new Error("empty");
437 // localhost stays http unless explicitly https427 // localhost stays http unless explicitly https
438 const useHttp =428 const useHttp = /^(localhost|127\.0\.0\.1|0\.0\.0\.0)(:|\/|$)/.test(trimmed);
439 /^(localhost|127\.0\.0\.1|0\.0\.0\.0)(:|\/|$)/.test(trimmed);
440 const parsed = urlParseLax(trimmed, { https: !useHttp });429 const parsed = urlParseLax(trimmed, { https: !useHttp });
441 if (!parsed?.href) throw new Error("unparseable");430 if (!parsed?.href) throw new Error("unparseable");
442 return parsed.href;431 return parsed.href;
443 }).pipe(432 }).pipe(
444 Effect.catchAll((cause) =>433 Effect.catchAll((cause) => Effect.fail(new PreviewInvalidUrlError({ rawUrl: input, cause }))),
445 Effect.fail(new PreviewInvalidUrlError({ rawUrl: input, cause })),
446 ),
447 );434 );
448```435```
449436
...@@ -513,14 +500,19 @@ export class PreviewViewManager {...@@ -513,14 +500,19 @@ export class PreviewViewManager {
513 this.mainWindow = window;500 this.mainWindow = window;
514 }501 }
515502
516 getPreloadPath(): string { return this.preloadPath; }503 getPreloadPath(): string {
517 getBrowserPartition(): string { return PREVIEW_PARTITION; }504 return this.preloadPath;
505 }
506 getBrowserPartition(): string {
507 return PREVIEW_PARTITION;
508 }
518509
519 getBrowserSession(): Session {510 getBrowserSession(): Session {
520 if (this.browserSession) return this.browserSession;511 if (this.browserSession) return this.browserSession;
521 const sess = session.fromPartition(PREVIEW_PARTITION);512 const sess = session.fromPartition(PREVIEW_PARTITION);
522 // strip electron/t3code from UA so dev preview doesn't trip bot detection513 // strip electron/t3code from UA so dev preview doesn't trip bot detection
523 const ua = sess.getUserAgent()514 const ua = sess
515 .getUserAgent()
524 .replace(/Electron\/[\d.]+ /, "")516 .replace(/Electron\/[\d.]+ /, "")
525 .replace(/\s*t3code\/[\d.]+/, "");517 .replace(/\s*t3code\/[\d.]+/, "");
526 sess.setUserAgent(ua);518 sess.setUserAgent(ua);
...@@ -593,9 +585,15 @@ export class PreviewViewManager {...@@ -593,9 +585,15 @@ export class PreviewViewManager {
593 await wc.loadURL(url);585 await wc.loadURL(url);
594 }586 }
595587
596 goBack(tabId: string): void { this.requireWebContents(tabId).navigationHistory.goBack(); }588 goBack(tabId: string): void {
597 goForward(tabId: string): void { this.requireWebContents(tabId).navigationHistory.goForward(); }589 this.requireWebContents(tabId).navigationHistory.goBack();
598 refresh(tabId: string): void { this.requireWebContents(tabId).reload(); }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
600 onStateChange(listener: Listener): () => void {598 onStateChange(listener: Listener): () => void {
601 this.listeners.add(listener);599 this.listeners.add(listener);
...@@ -656,16 +654,17 @@ export class PreviewViewManager {...@@ -656,16 +654,17 @@ export class PreviewViewManager {
656 if (input.type !== "keyDown") return false;654 if (input.type !== "keyDown") return false;
657 // Mirror the t3code keybinding defaults that should always reach the main window.655 // Mirror the t3code keybinding defaults that should always reach the main window.
658 const SHORTCUTS = [656 const SHORTCUTS = [
659 { key: "j", meta: true, shift: true }, // preview.toggle657 { key: "j", meta: true, shift: true }, // preview.toggle
660 { key: "k", meta: true, shift: false }, // commandPalette.toggle658 { key: "k", meta: true, shift: false }, // commandPalette.toggle
661 { key: ",", meta: true, shift: false }, // settings659 { key: ",", meta: true, shift: false }, // settings
662 { key: "w", meta: true, shift: false }, // close660 { key: "w", meta: true, shift: false }, // close
663 // future: terminal.* if user wants them while preview focused661 // future: terminal.* if user wants them while preview focused
664 ];662 ];
665 return SHORTCUTS.some((s) =>663 return SHORTCUTS.some(
666 s.key.toLowerCase() === input.key.toLowerCase()664 (s) =>
667 && s.meta === input.meta665 s.key.toLowerCase() === input.key.toLowerCase() &&
668 && s.shift === input.shift,666 s.meta === input.meta &&
667 s.shift === input.shift,
669 );668 );
670 }669 }
671670
...@@ -704,9 +703,9 @@ export class PreviewViewManager {...@@ -704,9 +703,9 @@ export class PreviewViewManager {
704 const trimmed = input.trim();703 const trimmed = input.trim();
705 if (!trimmed) throw new PreviewInvalidUrlError(input);704 if (!trimmed) throw new PreviewInvalidUrlError(input);
706 const useHttp = /^(localhost|127\.0\.0\.1|0\.0\.0\.0)(:|\/|$)/.test(trimmed);705 const useHttp = /^(localhost|127\.0\.0\.1|0\.0\.0\.0)(:|\/|$)/.test(trimmed);
707 const parsed = new URL(trimmed.includes("://")706 const parsed = new URL(
708 ? trimmed707 trimmed.includes("://") ? trimmed : `${useHttp ? "http" : "https"}://${trimmed}`,
709 : `${useHttp ? "http" : "https"}://${trimmed}`);708 );
710 return parsed.href;709 return parsed.href;
711 }710 }
712}711}
...@@ -716,9 +715,15 @@ export class PreviewTabNotFoundError extends Error {...@@ -716,9 +715,15 @@ export class PreviewTabNotFoundError extends Error {
716 super(`Preview tab not found: ${tabId}`);715 super(`Preview tab not found: ${tabId}`);
717 }716 }
718}717}
719export class PreviewWebContentsNotFoundError extends Error { /* … */ }718export class PreviewWebContentsNotFoundError extends Error {
720export class PreviewWebviewNotInitializedError extends Error { /* … */ }719 /* … */
721export class PreviewInvalidUrlError extends Error { /* … */ }720}
721export class PreviewWebviewNotInitializedError extends Error {
722 /* … */
723}
724export class PreviewInvalidUrlError extends Error {
725 /* … */
726}
722727
723export const previewViewManager = new PreviewViewManager();728export const previewViewManager = new PreviewViewManager();
724```729```
...@@ -734,18 +739,20 @@ previewViewManager.setMainWindow(mainWindow);...@@ -734,18 +739,20 @@ previewViewManager.setMainWindow(mainWindow);
734Register IPC handlers (in `registerIpcHandlers()`):739Register IPC handlers (in `registerIpcHandlers()`):
735740
736```ts741```ts
737ipcMain.handle("preview:createTab", (_e, tabId: string) =>742ipcMain.handle("preview:createTab", (_e, tabId: string) => previewViewManager.createTab(tabId));
738 previewViewManager.createTab(tabId));743ipcMain.handle("preview:closeTab", (_e, tabId: string) => previewViewManager.closeTab(tabId));
739ipcMain.handle("preview:closeTab", (_e, tabId: string) =>
740 previewViewManager.closeTab(tabId));
741ipcMain.handle("preview:setVisibility", (_e, tabId: string, visible: boolean) =>744ipcMain.handle("preview:setVisibility", (_e, tabId: string, visible: boolean) =>
742 previewViewManager.setVisibility(tabId, visible));745 previewViewManager.setVisibility(tabId, visible),
746);
743ipcMain.handle("preview:registerWebview", (_e, tabId: string, wcId: number) =>747ipcMain.handle("preview:registerWebview", (_e, tabId: string, wcId: number) =>
744 previewViewManager.registerWebview(tabId, wcId));748 previewViewManager.registerWebview(tabId, wcId),
749);
745ipcMain.handle("preview:unregisterWebview", (_e, tabId: string) =>750ipcMain.handle("preview:unregisterWebview", (_e, tabId: string) =>
746 previewViewManager.unregisterWebview(tabId));751 previewViewManager.unregisterWebview(tabId),
752);
747ipcMain.handle("preview:navigate", (_e, tabId: string, url: string) =>753ipcMain.handle("preview:navigate", (_e, tabId: string, url: string) =>
748 previewViewManager.navigate(tabId, url));754 previewViewManager.navigate(tabId, url),
755);
749ipcMain.handle("preview:goBack", (_e, tabId: string) => previewViewManager.goBack(tabId));756ipcMain.handle("preview:goBack", (_e, tabId: string) => previewViewManager.goBack(tabId));
750ipcMain.handle("preview:goForward", (_e, tabId: string) => previewViewManager.goForward(tabId));757ipcMain.handle("preview:goForward", (_e, tabId: string) => previewViewManager.goForward(tabId));
751ipcMain.handle("preview:refresh", (_e, tabId: string) => previewViewManager.refresh(tabId));758ipcMain.handle("preview:refresh", (_e, tabId: string) => previewViewManager.refresh(tabId));
...@@ -776,16 +783,13 @@ contextBridge.exposeInMainWorld("desktopBridge", {...@@ -776,16 +783,13 @@ contextBridge.exposeInMainWorld("desktopBridge", {
776 ipcRenderer.invoke("preview:setVisibility", tabId, visible),783 ipcRenderer.invoke("preview:setVisibility", tabId, visible),
777 registerWebview: (tabId: string, wcId: number) =>784 registerWebview: (tabId: string, wcId: number) =>
778 ipcRenderer.invoke("preview:registerWebview", tabId, wcId),785 ipcRenderer.invoke("preview:registerWebview", tabId, wcId),
779 unregisterWebview: (tabId: string) =>786 unregisterWebview: (tabId: string) => ipcRenderer.invoke("preview:unregisterWebview", tabId),
780 ipcRenderer.invoke("preview:unregisterWebview", tabId),787 navigate: (tabId: string, url: string) => ipcRenderer.invoke("preview:navigate", tabId, url),
781 navigate: (tabId: string, url: string) =>
782 ipcRenderer.invoke("preview:navigate", tabId, url),
783 goBack: (tabId: string) => ipcRenderer.invoke("preview:goBack", tabId),788 goBack: (tabId: string) => ipcRenderer.invoke("preview:goBack", tabId),
784 goForward: (tabId: string) => ipcRenderer.invoke("preview:goForward", tabId),789 goForward: (tabId: string) => ipcRenderer.invoke("preview:goForward", tabId),
785 refresh: (tabId: string) => ipcRenderer.invoke("preview:refresh", tabId),790 refresh: (tabId: string) => ipcRenderer.invoke("preview:refresh", tabId),
786 getPreloadPath: (): Promise<string> => ipcRenderer.invoke("preview:getPreloadPath"),791 getPreloadPath: (): Promise<string> => ipcRenderer.invoke("preview:getPreloadPath"),
787 getBrowserPartition: (): Promise<string> =>792 getBrowserPartition: (): Promise<string> => ipcRenderer.invoke("preview:getBrowserPartition"),
788 ipcRenderer.invoke("preview:getBrowserPartition"),
789 onStateChange: (cb: (tabId: string, state: DesktopPreviewTabState) => void) => {793 onStateChange: (cb: (tabId: string, state: DesktopPreviewTabState) => void) => {
790 const listener = (_e: unknown, tabId: string, state: DesktopPreviewTabState) =>794 const listener = (_e: unknown, tabId: string, state: DesktopPreviewTabState) =>
791 cb(tabId, state);795 cb(tabId, state);
...@@ -1096,14 +1100,14 @@ Adding `preview.toggle`, `preview.refresh`, `preview.focusUrl` to `STATIC_KEYBIN...@@ -1096,14 +1100,14 @@ Adding `preview.toggle`, `preview.refresh`, `preview.focusUrl` to `STATIC_KEYBIN
10961100
1097Following t3code's vitest patterns (`bun run test`, never `bun test`):1101Following t3code's vitest patterns (`bun run test`, never `bun test`):
10981102
1099| Test file | What it covers |1103| Test file | What it covers |
1100|---|---|1104| -------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1101| `packages/contracts/src/preview.test.ts` | Schema encode/decode round-trips for inputs, snapshot, events; URL trimming; error tagged unions |1105| `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 |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 |
1103| `apps/web/src/previewStateStore.test.ts` | `applyServerEvent` reducer correctness; ring buffer cap; `closed` removes entry; `desktopOverlay` is independent of snapshot fields |1107| `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 |1108| `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 |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 |
1106| Existing `ThreadTerminalDrawer.test.ts` | Add a case: link activation with `kind: "url"` and `previewable: true` shows the context menu (mock `localApi.contextMenu.show`) |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
1108Manual smoke checklist (drop in `apps/desktop/test/smoke/`):1112Manual smoke checklist (drop in `apps/desktop/test/smoke/`):
11091113
...@@ -1120,15 +1124,15 @@ Manual smoke checklist (drop in `apps/desktop/test/smoke/`):...@@ -1120,15 +1124,15 @@ Manual smoke checklist (drop in `apps/desktop/test/smoke/`):
11201124
1121## 12. Risks and resolutions1125## 12. Risks and resolutions
11221126
1123| Risk | Resolution |1127| Risk | Resolution |
1124|---|---|1128| ---------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
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` |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` |
1126| `<webview>` keyboard capture eats `mod+shift+j` etc. | `before-input-event` forwarder in `PreviewViewManager` (mirror of ami's pattern, smaller shortcut list) |1130| `<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 |1131| 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` |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` |
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 |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 |
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 |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 |
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 |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
1133---1137---
11341138
...@@ -1155,38 +1159,43 @@ Any of these can land in follow-up PRs against the same `PreviewViewManager` + `...@@ -1155,38 +1159,43 @@ Any of these can land in follow-up PRs against the same `PreviewViewManager` + `
11551159
1156Every 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.1160Every 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 |1162| Need | Primitive | File ref | Notes |
1159|---|---|---|---|1163| ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
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 |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 |
1161| `cn` class merger | `cn(...inputs)` from `~/lib/utils` | `apps/web/src/lib/utils.ts:8` | Wraps `cx` + `tailwind-merge` |1165| `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 |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 |
1163| Buttons (URL field submit) | `Button` `variant="outline"` `size="sm"` | same | Matches the "ProjectScript primary action" density already in `BranchToolbar` |1167| 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 |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 |
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` |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` |
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` |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` |
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 |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 |
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:` |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:` |
1169| Status pulse dot (running) | `<span>` with `bg-success` and `animate-pulse` | tailwind built‑in | Theme‑aware via `--success` |1173| 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 |1174| 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" |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" |
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` |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` |
1173| Spinner (inline in URL field while loading) | `Spinner` | `apps/web/src/components/ui/spinner.tsx` | Wraps `Loader2Icon` from lucide |1177| 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 |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 |
1175| Keybinding shortcut label in tooltips | `shortcutLabelForCommand(keybindings, "preview.toggle")` | `apps/web/src/keybindings.ts` | Renders `⌘⇧J` etc. |1179| 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 |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
1178**Icons.** Use `lucide-react` everywhere (already in `package.json`). The exact icons we need:1182**Icons.** Use `lucide-react` everywhere (already in `package.json`). The exact icons we need:
11791183
1180```ts1184```ts
1181import {1185import {
1182 ArrowLeft, ArrowRight, // back / forward1186 ArrowLeft,
1183 RotateCw, Loader2, // refresh / spinning1187 ArrowRight, // back / forward
1184 Globe, // favicon fallback1188 RotateCw,
1185 X, // close tab1189 Loader2, // refresh / spinning
1186 Plus, // new tab1190 Globe, // favicon fallback
1187 Copy, ExternalLink, // open in browser, copy URL1191 X, // close tab
1188 Sidebar, Columns2, // right‑side toggles in tab strip (matches screenshot 2)1192 Plus, // new tab
1189 PanelRightOpen, PanelRightClose, // alt for above1193 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
1190} from "lucide-react";1199} from "lucide-react";
1191```1200```
11921201
...@@ -1200,11 +1209,11 @@ import {...@@ -1200,11 +1209,11 @@ import {
12001209
1201Three states render inside `PreviewView.tsx` based on the `PreviewSession` for the active thread:1210Three states render inside `PreviewView.tsx` based on the `PreviewSession` for the active thread:
12021211
1203| Session state | Component rendered |1212| Session state | Component rendered |
1204|---|---|1213| --------------------- | ----------------------------------------------------------------------- |
1205| no tab open | `<PreviewEmptyState />` — disabled chrome row + "Local" recommendations |1214| no tab open | `<PreviewEmptyState />` — disabled chrome row + "Local" recommendations |
1206| tab loading / loaded | `<PreviewWebview />` + chrome row + tab strip |1215| tab loading / loaded | `<PreviewWebview />` + chrome row + tab strip |
1207| tab navigation failed | `<PreviewUnreachable />` overlay above the webview |1216| tab navigation failed | `<PreviewUnreachable />` overlay above the webview |
12081217
1209### 15.1 Empty state (`PreviewEmptyState.tsx`) — matches screenshot 11218### 15.1 Empty state (`PreviewEmptyState.tsx`) — matches screenshot 1
12101219
...@@ -1259,9 +1268,7 @@ export function PreviewEmptyState({ threadId, onOpen }: Props) {...@@ -1259,9 +1268,7 @@ export function PreviewEmptyState({ threadId, onOpen }: Props) {
1259 return (1268 return (
1260 <div className="flex h-full min-h-0 flex-col">1269 <div className="flex h-full min-h-0 flex-col">
1261 <div className="px-6 pt-10 pb-4">1270 <div className="px-6 pt-10 pb-4">
1262 <h2 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">1271 <h2 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Local</h2>
1263 Local
1264 </h2>
1265 </div>1272 </div>
12661273
1267 {servers.length === 0 ? (1274 {servers.length === 0 ? (
...@@ -1308,10 +1315,7 @@ function LocalServerCard({...@@ -1308,10 +1315,7 @@ function LocalServerCard({
1308 {server.processName ?? `${server.host}:${server.port}`}1315 {server.processName ?? `${server.host}:${server.port}`}
1309 </span>1316 </span>
1310 </div>1317 </div>
1311 <span1318 <span className="size-2 shrink-0 rounded-full bg-success" aria-label="Listening">
1312 className="size-2 shrink-0 rounded-full bg-success"
1313 aria-label="Listening"
1314 >
1315 <span className="block size-2 rounded-full bg-success animate-ping opacity-60" />1319 <span className="block size-2 rounded-full bg-success animate-ping opacity-60" />
1316 </span>1320 </span>
1317 </Card>1321 </Card>
...@@ -1362,8 +1366,8 @@ The chrome row in the empty state — note the `disabled` props on every interac...@@ -1362,8 +1366,8 @@ The chrome row in the empty state — note the `disabled` props on every interac
1362 onForward={NOOP}1366 onForward={NOOP}
1363 onRefresh={NOOP}1367 onRefresh={NOOP}
1364 onSubmitUrl={(url) => api.preview.open({ threadId, url })}1368 onSubmitUrl={(url) => api.preview.open({ threadId, url })}
1365 inputDisabled={false} // we still want to allow paste-to-open1369 inputDisabled={false} // we still want to allow paste-to-open
1366 buttonsDisabled // back/fwd/refresh are visually disabled1370 buttonsDisabled // back/fwd/refresh are visually disabled
1367/>1371/>
1368```1372```
13691373
...@@ -1568,17 +1572,17 @@ When `navStatus._tag === "LoadFailed"`, render this instead of the failed `<webv...@@ -1568,17 +1572,17 @@ When `navStatus._tag === "LoadFailed"`, render this instead of the failed `<webv
15681572
1569Color mapping (Chromium → t3code theme):1573Color mapping (Chromium → t3code theme):
15701574
1571| Chromium | Tailwind/t3code |1575| Chromium | Tailwind/t3code |
1572|---|---|1576| ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1573| `--background-color: #fff` / `--google-gray-900` (dark) | `bg-background` |1577| `--background-color: #fff` / `--google-gray-900` (dark) | `bg-background` |
1574| `--text-color: --google-gray-700` / `--google-gray-500` (dark) | `text-muted-foreground` |1578| `--text-color: --google-gray-700` / `--google-gray-500` (dark) | `text-muted-foreground` |
1575| `--heading-color: --google-gray-900` / `--google-gray-500` (dark) | `text-foreground` |1579| `--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` |1580| `--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` |1581| `--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) |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) |
1579| `--secondary-button-*` | `Button variant="outline"` |1583| `--secondary-button-*` | `Button variant="outline"` |
1580| `--link-color: rgb(88,88,88)` / `--google-blue-300` (dark) | `text-primary underline-offset-4 hover:underline` |1584| `--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 |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
1583Skeleton:1587Skeleton:
15841588
...@@ -1592,7 +1596,7 @@ import { cn } from "~/lib/utils";...@@ -1592,7 +1596,7 @@ import { cn } from "~/lib/utils";
15921596
1593interface Props {1597interface Props {
1594 url: string;1598 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"
1596 description: string;1600 description: string;
1597 onReload: () => void;1601 onReload: () => void;
1598}1602}
...@@ -1601,7 +1605,13 @@ const ICON_GENERIC = (...@@ -1601,7 +1605,13 @@ const ICON_GENERIC = (
1601 // Replace with an inline SVG that matches the Chromium "icon-generic"1605 // Replace with an inline SVG that matches the Chromium "icon-generic"
1602 // (a stylized broken page). For v1 a Lucide MapPinOff is a fine stand-in —1606 // (a stylized broken page). For v1 a Lucide MapPinOff is a fine stand-in —
1603 // we want a visual that reads "destination unreachable".1607 // 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 >
1605 <path d="M16 12 L48 12 L48 52 L16 52 Z" />1615 <path d="M16 12 L48 12 L48 52 L16 52 Z" />
1606 <path d="M22 22 L42 22 M22 30 L36 30 M22 38 L40 38" strokeLinecap="round" />1616 <path d="M22 22 L42 22 M22 30 L36 30 M22 38 L40 38" strokeLinecap="round" />
1607 <path d="M52 8 L12 56" strokeLinecap="round" />1617 <path d="M52 8 L12 56" strokeLinecap="round" />
...@@ -1626,7 +1636,13 @@ export function PreviewUnreachable({ url, errorCode, description, onReload }: Pr...@@ -1626,7 +1636,13 @@ export function PreviewUnreachable({ url, errorCode, description, onReload }: Pr
1626 {/* Summary — uses dangerouslySetInnerHTML only for the bold host treatment */}1636 {/* Summary — uses dangerouslySetInnerHTML only for the bold host treatment */}
1627 <p className="text-sm leading-relaxed text-muted-foreground">1637 <p className="text-sm leading-relaxed text-muted-foreground">
1628 <strong className="font-semibold text-foreground">{host}</strong>1638 <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>{" "}
1630 could not be found.1646 could not be found.
1631 </p>1647 </p>
16321648
...@@ -1668,11 +1684,16 @@ export function PreviewUnreachable({ url, errorCode, description, onReload }: Pr...@@ -1668,11 +1684,16 @@ export function PreviewUnreachable({ url, errorCode, description, onReload }: Pr
1668}1684}
16691685
1670function safeHost(url: string): string | null {1686function 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 }
1672}1692}
1673```1693```
16741694
1675The component intentionally:1695The component intentionally:
1696
1676- Uses theme tokens only — looks correct in light + dark with no extra wiring.1697- Uses theme tokens only — looks correct in light + dark with no extra wiring.
1677- Drops Chromium's "Diagnose connection" / "Portal sign-in" buttons (irrelevant in our shell).1698- Drops Chromium's "Diagnose connection" / "Portal sign-in" buttons (irrelevant in our shell).
1678- Drops the dinosaur game (RIP).1699- Drops the dinosaur game (RIP).
...@@ -1683,15 +1704,17 @@ The component intentionally:...@@ -1683,15 +1704,17 @@ The component intentionally:
1683A 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`):1704A 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
1685```tsx1706```tsx
1686{loadProgress > 0 && (1707{
1687 <div1708 loadProgress > 0 && (
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"1709 <div
1689 style={{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"
1690 width: `${loadProgress}%`,1711 style={{
1691 boxShadow: "0 0 6px 1px var(--ring)",1712 width: `${loadProgress}%`,
1692 }}1713 boxShadow: "0 0 6px 1px var(--ring)",
1693 />1714 }}
1694)}1715 />
1716 );
1717}
1695```1718```
16961719
1697Progress is computed locally with `useLoadingProgress(isLoading)` — same hook signature as ami's, ~30 lines.1720Progress 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....@@ -1708,9 +1731,9 @@ The "Local" recommendations in §15.1 need a feed. New backend service.
1708import { Context, Effect } from "effect";1731import { Context, Effect } from "effect";
17091732
1710export interface DiscoveredLocalServer {1733export interface DiscoveredLocalServer {
1711 host: string; // "localhost"1734 host: string; // "localhost"
1712 port: number; // 51751735 port: number; // 5175
1713 url: string; // "http://localhost:5175"1736 url: string; // "http://localhost:5175"
1714 processName: string | null; // "node", "vite", "next-server"1737 processName: string | null; // "node", "vite", "next-server"
1715 pid: number | null;1738 pid: number | null;
1716}1739}
...@@ -1740,6 +1763,7 @@ Two strategies, picked at startup:...@@ -1740,6 +1763,7 @@ Two strategies, picked at startup:
17402. **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.17632. **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
1742Polling cadence:1765Polling cadence:
1766
1743- When `retain()` count is 0 → not polling.1767- When `retain()` count is 0 → not polling.
1744- When ≥1 → poll every 3s. Diff against last result; only emit `subscribe` callbacks when the set differs.1768- When ≥1 → poll every 3s. Diff against last result; only emit `subscribe` callbacks when the set differs.
1745- `retain()` returns a release fn that decrements the counter; goes idle automatically when the empty state is hidden.1769- `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...@@ -1762,20 +1786,20 @@ Deduped by URL string. Sort: configured > listening > recent. This yields the "s
17621786
1763Append these to the file list:1787Append these to the file list:
17641788
1765| File | Purpose |1789| File | Purpose |
1766|---|---|1790| -------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
1767| `apps/server/src/preview/Services/PortScanner.ts` | Service tag + interface |1791| `apps/server/src/preview/Services/PortScanner.ts` | Service tag + interface |
1768| `apps/server/src/preview/Layers/PortScanner.ts` | `lsof` strategy + TCP probe fallback + polling |1792| `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 |1793| `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 |1794| `apps/web/src/lib/favicon.ts` | `faviconUrlForOrigin(url, size)` helper |
1771| `apps/web/src/components/preview/PreviewTabStrip.tsx` | Tab strip (screenshot 2) |1795| `apps/web/src/components/preview/PreviewTabStrip.tsx` | Tab strip (screenshot 2) |
1772| `apps/web/src/components/preview/TabFavicon.tsx` | Favicon `<img>` w/ `<Globe>` fallback |1796| `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 |1797| `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 |1798| `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 |1799| `apps/web/src/components/preview/PreviewUnreachable.tsx` | 404.html rewritten in tailwind |
1776| `apps/web/src/components/preview/useDiscoveredLocalServers.ts` | Hook subscribing to `EnvironmentApi.preview.subscribePorts` + merging in `recentUrlsFromTerminal` and `ProjectScript.previewUrl` |1800| `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) |1801| `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 |1802| `apps/web/src/components/preview/errorCodeMessages.ts` | `ERR_*` → human-readable description map |
17791803
1780Update the contract additions in §4 to add:1804Update the contract additions in §4 to add:
17811805
apps/desktop/src/ipc/DesktopIpcHandlers.ts+4
...@@ -48,6 +48,7 @@ import {...@@ -48,6 +48,7 @@ import {
48 setTheme,48 setTheme,
49 showContextMenu,49 showContextMenu,
50} from "./methods/window.ts";50} from "./methods/window.ts";
51import { previewMethods } from "./methods/preview.ts";
5152
52export const installDesktopIpcHandlers = Effect.gen(function* () {53export const installDesktopIpcHandlers = Effect.gen(function* () {
53 const ipc = yield* DesktopIpc.DesktopIpc;54 const ipc = yield* DesktopIpc.DesktopIpc;
...@@ -92,4 +93,7 @@ export const installDesktopIpcHandlers = Effect.gen(function* () {...@@ -92,4 +93,7 @@ export const installDesktopIpcHandlers = Effect.gen(function* () {
92 yield* ipc.handle(downloadUpdate);93 yield* ipc.handle(downloadUpdate);
93 yield* ipc.handle(installUpdate);94 yield* ipc.handle(installUpdate);
94 yield* ipc.handle(checkForUpdate);95 yield* ipc.handle(checkForUpdate);
96 for (const previewMethod of previewMethods) {
97 yield* ipc.handle(previewMethod);
98 }
95}).pipe(Effect.withSpan("desktop.ipc.installHandlers"));99}).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...@@ -39,3 +39,19 @@ export const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mod
39export const SET_TAILSCALE_SERVE_ENABLED_CHANNEL = "desktop:set-tailscale-serve-enabled";39export const SET_TAILSCALE_SERVE_ENABLED_CHANNEL = "desktop:set-tailscale-serve-enabled";
40export const GET_ADVERTISED_ENDPOINTS_CHANNEL = "desktop:get-advertised-endpoints";40export const GET_ADVERTISED_ENDPOINTS_CHANNEL = "desktop:get-advertised-endpoints";
41export const SSH_PASSWORD_PROMPT_CANCELLED_RESULT = "ssh-password-prompt-cancelled";41export 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 @@...@@ -1,4 +1,4 @@
1import type { DesktopBridge } from "@t3tools/contracts";1import type { DesktopBridge, DesktopPreviewTabState } from "@t3tools/contracts";
2import { contextBridge, ipcRenderer } from "electron";2import { contextBridge, ipcRenderer } from "electron";
33
4import * as IpcChannels from "./ipc/channels.ts";4import * as IpcChannels from "./ipc/channels.ts";
...@@ -141,4 +141,37 @@ contextBridge.exposeInMainWorld("desktopBridge", {...@@ -141,4 +141,37 @@ contextBridge.exposeInMainWorld("desktopBridge", {
141 ipcRenderer.removeListener(IpcChannels.UPDATE_STATE_CHANNEL, wrappedListener);141 ipcRenderer.removeListener(IpcChannels.UPDATE_STATE_CHANNEL, wrappedListener);
142 };142 };
143 },143 },
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 },
144} satisfies DesktopBridge);177} 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";...@@ -11,6 +11,7 @@ import * as DesktopAssets from "../app/DesktopAssets.ts";
11import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";11import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
12import * as DesktopObservability from "../app/DesktopObservability.ts";12import * as DesktopObservability from "../app/DesktopObservability.ts";
13import * as DesktopState from "../app/DesktopState.ts";13import * as DesktopState from "../app/DesktopState.ts";
14import { previewViewManager } from "../preview-view-manager.ts";
14import * as ElectronMenu from "../electron/ElectronMenu.ts";15import * as ElectronMenu from "../electron/ElectronMenu.ts";
15import * as ElectronShell from "../electron/ElectronShell.ts";16import * as ElectronShell from "../electron/ElectronShell.ts";
16import * as ElectronTheme from "../electron/ElectronTheme.ts";17import * as ElectronTheme from "../electron/ElectronTheme.ts";
...@@ -192,9 +193,12 @@ const make = Effect.gen(function* () {...@@ -192,9 +193,12 @@ const make = Effect.gen(function* () {
192 contextIsolation: true,193 contextIsolation: true,
193 nodeIntegration: false,194 nodeIntegration: false,
194 sandbox: true,195 sandbox: true,
196 webviewTag: true,
195 },197 },
196 });198 });
197199
200 previewViewManager.setMainWindow(window);
201
198 window.webContents.on("context-menu", (event, params) => {202 window.webContents.on("context-menu", (event, params) => {
199 event.preventDefault();203 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 {...@@ -20,6 +20,7 @@ import {
20 type OrchestrationCommand,20 type OrchestrationCommand,
21 type OrchestrationEvent,21 type OrchestrationEvent,
22 ORCHESTRATION_WS_METHODS,22 ORCHESTRATION_WS_METHODS,
23 type PreviewEvent,
23 ProjectId,24 ProjectId,
24 ProviderDriverKind,25 ProviderDriverKind,
25 ProviderInstanceId,26 ProviderInstanceId,
...@@ -48,6 +49,7 @@ import * as Layer from "effect/Layer";...@@ -48,6 +49,7 @@ import * as Layer from "effect/Layer";
48import * as ManagedRuntime from "effect/ManagedRuntime";49import * as ManagedRuntime from "effect/ManagedRuntime";
49import * as Option from "effect/Option";50import * as Option from "effect/Option";
50import * as Path from "effect/Path";51import * as Path from "effect/Path";
52import * as PubSub from "effect/PubSub";
51import * as Stream from "effect/Stream";53import * as Stream from "effect/Stream";
52import { ChildProcessSpawner } from "effect/unstable/process";54import { ChildProcessSpawner } from "effect/unstable/process";
53import {55import {
...@@ -97,6 +99,8 @@ import { ServerLifecycleEvents, type ServerLifecycleEventsShape } from "./server...@@ -97,6 +99,8 @@ import { ServerLifecycleEvents, type ServerLifecycleEventsShape } from "./server
97import { ServerRuntimeStartup, type ServerRuntimeStartupShape } from "./serverRuntimeStartup.ts";99import { ServerRuntimeStartup, type ServerRuntimeStartupShape } from "./serverRuntimeStartup.ts";
98import { ServerSettingsService, type ServerSettingsShape } from "./serverSettings.ts";100import { ServerSettingsService, type ServerSettingsShape } from "./serverSettings.ts";
99import { TerminalManager, type TerminalManagerShape } from "./terminal/Services/Manager.ts";101import { TerminalManager, type TerminalManagerShape } from "./terminal/Services/Manager.ts";
102import { PreviewManager } from "./preview/Services/Manager.ts";
103import { PreviewPortScanner } from "./preview/Services/PortScanner.ts";
100import {104import {
101 BrowserTraceCollector,105 BrowserTraceCollector,
102 type BrowserTraceCollectorShape,106 type BrowserTraceCollectorShape,
...@@ -663,6 +667,27 @@ const buildAppUnderTest = (options?: {...@@ -663,6 +667,27 @@ const buildAppUnderTest = (options?: {
663 ...options?.layers?.terminalManager,667 ...options?.layers?.terminalManager,
664 }),668 }),
665 ),669 ),
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 ),
666 Layer.provide(691 Layer.provide(
667 Layer.mock(OrchestrationEngineService)({692 Layer.mock(OrchestrationEngineService)({
668 readEvents: () => Stream.empty,693 readEvents: () => Stream.empty,
apps/server/src/server.ts+5
...@@ -35,6 +35,8 @@ import * as GitLabCli from "./sourceControl/GitLabCli.ts";...@@ -35,6 +35,8 @@ import * as GitLabCli from "./sourceControl/GitLabCli.ts";
35import * as TextGeneration from "./textGeneration/TextGeneration.ts";35import * as TextGeneration from "./textGeneration/TextGeneration.ts";
36import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/ProviderInstanceRegistryHydration.ts";36import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/ProviderInstanceRegistryHydration.ts";
37import { TerminalManagerLive } from "./terminal/Layers/Manager.ts";37import { TerminalManagerLive } from "./terminal/Layers/Manager.ts";
38import { PreviewManagerLive } from "./preview/Layers/Manager.ts";
39import { PreviewPortScannerLive } from "./preview/Layers/PortScanner.ts";
38import * as GitManager from "./git/GitManager.ts";40import * as GitManager from "./git/GitManager.ts";
39import { KeybindingsLive } from "./keybindings.ts";41import { KeybindingsLive } from "./keybindings.ts";
40import { ServerRuntimeStartup, ServerRuntimeStartupLive } from "./serverRuntimeStartup.ts";42import { ServerRuntimeStartup, ServerRuntimeStartupLive } from "./serverRuntimeStartup.ts";
...@@ -233,6 +235,8 @@ const CheckpointingLayerLive = Layer.empty.pipe(...@@ -233,6 +235,8 @@ const CheckpointingLayerLive = Layer.empty.pipe(
233235
234const TerminalLayerLive = TerminalManagerLive.pipe(Layer.provide(PtyAdapterLive));236const TerminalLayerLive = TerminalManagerLive.pipe(Layer.provide(PtyAdapterLive));
235237
238const PreviewLayerLive = Layer.mergeAll(PreviewManagerLive, PreviewPortScannerLive);
239
236const WorkspaceEntriesLayerLive = WorkspaceEntriesLive.pipe(240const WorkspaceEntriesLayerLive = WorkspaceEntriesLive.pipe(
237 Layer.provide(WorkspacePathsLive),241 Layer.provide(WorkspacePathsLive),
238 Layer.provideMerge(VcsDriverRegistryLayerLive),242 Layer.provideMerge(VcsDriverRegistryLayerLive),
...@@ -275,6 +279,7 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(...@@ -275,6 +279,7 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(
275 Layer.provideMerge(VcsLayerLive),279 Layer.provideMerge(VcsLayerLive),
276 Layer.provideMerge(ProviderRuntimeLayerLive),280 Layer.provideMerge(ProviderRuntimeLayerLive),
277 Layer.provideMerge(TerminalLayerLive),281 Layer.provideMerge(TerminalLayerLive),
282 Layer.provideMerge(PreviewLayerLive),
278 Layer.provideMerge(PersistenceLayerLive),283 Layer.provideMerge(PersistenceLayerLive),
279 Layer.provideMerge(KeybindingsLive),284 Layer.provideMerge(KeybindingsLive),
280 Layer.provideMerge(ProviderRegistryLive),285 Layer.provideMerge(ProviderRegistryLive),
apps/server/src/ws.ts+62
...@@ -22,6 +22,7 @@ import {...@@ -22,6 +22,7 @@ import {
22 type AuthEnvironmentScope,22 type AuthEnvironmentScope,
23 AuthSessionId,23 AuthSessionId,
24 CommandId,24 CommandId,
25 type DiscoveredLocalServerList,
25 EventId,26 EventId,
26 type OrchestrationCommand,27 type OrchestrationCommand,
27 type GitActionProgressEvent,28 type GitActionProgressEvent,
...@@ -70,6 +71,8 @@ import { ServerLifecycleEvents } from "./serverLifecycleEvents.ts";...@@ -70,6 +71,8 @@ import { ServerLifecycleEvents } from "./serverLifecycleEvents.ts";
70import { ServerRuntimeStartup } from "./serverRuntimeStartup.ts";71import { ServerRuntimeStartup } from "./serverRuntimeStartup.ts";
71import { redactServerSettingsForClient, ServerSettingsService } from "./serverSettings.ts";72import { redactServerSettingsForClient, ServerSettingsService } from "./serverSettings.ts";
72import { TerminalManager } from "./terminal/Services/Manager.ts";73import { TerminalManager } from "./terminal/Services/Manager.ts";
74import { PreviewManager } from "./preview/Services/Manager.ts";
75import { PreviewPortScanner } from "./preview/Services/PortScanner.ts";
73import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries.ts";76import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries.ts";
74import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem.ts";77import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem.ts";
75import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePaths.ts";78import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePaths.ts";
...@@ -240,6 +243,8 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) =>...@@ -240,6 +243,8 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) =>
240 const vcsProvisioning = yield* VcsProvisioningService;243 const vcsProvisioning = yield* VcsProvisioningService;
241 const vcsStatusBroadcaster = yield* VcsStatusBroadcaster;244 const vcsStatusBroadcaster = yield* VcsStatusBroadcaster;
242 const terminalManager = yield* TerminalManager;245 const terminalManager = yield* TerminalManager;
246 const previewManager = yield* PreviewManager;
247 const previewPortScanner = yield* PreviewPortScanner;
243 const providerRegistry = yield* ProviderRegistry;248 const providerRegistry = yield* ProviderRegistry;
244 const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner;249 const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner;
245 const config = yield* ServerConfig;250 const config = yield* ServerConfig;
...@@ -1350,6 +1355,63 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) =>...@@ -1350,6 +1355,63 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) =>
1350 ),1355 ),
1351 { "rpc.aggregate": "terminal" },1356 { "rpc.aggregate": "terminal" },
1352 ),1357 ),
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 ),
1353 [WS_METHODS.subscribeServerConfig]: (_input) =>1415 [WS_METHODS.subscribeServerConfig]: (_input) =>
1354 observeRpcStreamEffect(1416 observeRpcStreamEffect(
1355 WS_METHODS.subscribeServerConfig,1417 WS_METHODS.subscribeServerConfig,
apps/web/package.json+1
...@@ -64,6 +64,7 @@...@@ -64,6 +64,7 @@
64 "babel-plugin-react-compiler": "1.0.0",64 "babel-plugin-react-compiler": "1.0.0",
65 "msw": "2.12.11",65 "msw": "2.12.11",
66 "playwright": "^1.58.2",66 "playwright": "^1.58.2",
67 "react-grab": "^0.1.32",
67 "tailwindcss": "^4.0.0",68 "tailwindcss": "^4.0.0",
68 "vite": "catalog:",69 "vite": "catalog:",
69 "vite-plus": "catalog:",70 "vite-plus": "catalog:",
apps/web/src/components/ChatView.browser.tsx+20
...@@ -268,6 +268,26 @@ function createMockEnvironmentApi(input: {...@@ -268,6 +268,26 @@ function createMockEnvironmentApi(input: {
268 subscribeThread: (() => () =>268 subscribeThread: (() => () =>
269 undefined) as EnvironmentApi["orchestration"]["subscribeThread"],269 undefined) as EnvironmentApi["orchestration"]["subscribeThread"],
270 },270 },
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"],
271 };291 };
272}292}
273293
apps/web/src/components/ChatView.tsx+130-24
...@@ -36,7 +36,7 @@ import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/proje...@@ -36,7 +36,7 @@ import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/proje
36import { truncate } from "@t3tools/shared/String";36import { truncate } from "@t3tools/shared/String";
37import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels";37import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels";
38import { Debouncer } from "@tanstack/react-pacer";38import { 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";
40import { useNavigate, useSearch } from "@tanstack/react-router";40import { useNavigate, useSearch } from "@tanstack/react-router";
41import { useShallow } from "zustand/react/shallow";41import { useShallow } from "zustand/react/shallow";
42import { useVcsStatus } from "~/lib/vcsStatusState";42import { useVcsStatus } from "~/lib/vcsStatusState";
...@@ -98,6 +98,14 @@ import { useCommandPaletteStore } from "../commandPaletteStore";...@@ -98,6 +98,14 @@ import { useCommandPaletteStore } from "../commandPaletteStore";
98import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git";98import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git";
99import { useMediaQuery } from "../hooks/useMediaQuery";99import { useMediaQuery } from "../hooks/useMediaQuery";
100import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout";100import { 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);
101import { BranchToolbar } from "./BranchToolbar";109import { BranchToolbar } from "./BranchToolbar";
102import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";110import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";
103import PlanSidebar from "./PlanSidebar";111import PlanSidebar from "./PlanSidebar";
...@@ -910,7 +918,6 @@ export default function ChatView(props: ChatViewProps) {...@@ -910,7 +918,6 @@ export default function ChatView(props: ChatViewProps) {
910 >({});918 >({});
911 const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] =919 const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] =
912 useState<Record<string, number>>({});920 useState<Record<string, number>>({});
913 const [planSidebarOpen, setPlanSidebarOpen] = useState(false);
914 const shouldUsePlanSidebarSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY);921 const shouldUsePlanSidebarSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY);
915 // Tracks whether the user explicitly dismissed the sidebar for the active turn.922 // Tracks whether the user explicitly dismissed the sidebar for the active turn.
916 const planSidebarDismissedForTurnRef = useRef<string | null>(null);923 const planSidebarDismissedForTurnRef = useRef<string | null>(null);
...@@ -1045,7 +1052,6 @@ export default function ChatView(props: ChatViewProps) {...@@ -1045,7 +1052,6 @@ export default function ChatView(props: ChatViewProps) {
1045 [activeThread],1052 [activeThread],
1046 );1053 );
1047 const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null;1054 const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null;
1048
1049 useEffect(() => {1055 useEffect(() => {
1050 if (!activeThreadRef) {1056 if (!activeThreadRef) {
1051 return;1057 return;
...@@ -1068,7 +1074,11 @@ export default function ChatView(props: ChatViewProps) {...@@ -1068,7 +1074,11 @@ export default function ChatView(props: ChatViewProps) {
1068 reconcileTerminalIds,1074 reconcileTerminalIds,
1069 terminalUiState.terminalIds,1075 terminalUiState.terminalIds,
1070 ]);1076 ]);
10711077 const activeRightPanelKind = useRightPanelStore((store) =>
1078 selectActiveRightPanelKindWithUrl(store.byThreadKey, activeThreadRef, diffOpen),
1079 );
1080 const planSidebarOpen = activeRightPanelKind === "plan";
1081 const previewPanelOpen = activeRightPanelKind === "preview" && isPreviewSupportedInRuntime();
1072 const existingOpenTerminalThreadKeys = useMemo(() => {1082 const existingOpenTerminalThreadKeys = useMemo(() => {
1073 const existingThreadKeys = new Set<string>([...serverThreadKeys, ...draftThreadKeys]);1083 const existingThreadKeys = new Set<string>([...serverThreadKeys, ...draftThreadKeys]);
1074 return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey));1084 return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey));
...@@ -1925,12 +1935,47 @@ export default function ChatView(props: ChatViewProps) {...@@ -1925,12 +1935,47 @@ export default function ChatView(props: ChatViewProps) {
1925 () => shortcutLabelForCommand(keybindings, "diff.toggle", nonTerminalShortcutLabelOptions),1935 () => shortcutLabelForCommand(keybindings, "diff.toggle", nonTerminalShortcutLabelOptions),
1926 [keybindings, nonTerminalShortcutLabelOptions],1936 [keybindings, nonTerminalShortcutLabelOptions],
1927 );1937 );
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]);
1928 const onToggleDiff = useCallback(() => {1967 const onToggleDiff = useCallback(() => {
1929 if (!isServerThread) {1968 if (!isServerThread) {
1930 return;1969 return;
1931 }1970 }
1932 if (!diffOpen) {1971 if (!diffOpen) {
1933 onDiffPanelOpen?.();1972 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 }
1934 }1979 }
1935 void navigate({1980 void navigate({
1936 to: "/$environmentId/$threadId",1981 to: "/$environmentId/$threadId",
...@@ -1944,7 +1989,25 @@ export default function ChatView(props: ChatViewProps) {...@@ -1944,7 +1989,25 @@ export default function ChatView(props: ChatViewProps) {
1944 return diffOpen ? { ...rest, diff: undefined } : { ...rest, diff: "1" };1989 return diffOpen ? { ...rest, diff: undefined } : { ...rest, diff: "1" };
1945 },1990 },
1946 });1991 });
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
1949 const envLocked = Boolean(2012 const envLocked = Boolean(
1950 activeThread &&2013 activeThread &&
...@@ -2223,6 +2286,19 @@ export default function ChatView(props: ChatViewProps) {...@@ -2223,6 +2286,19 @@ export default function ChatView(props: ChatViewProps) {
2223 terminalId: targetTerminalId,2286 terminalId: targetTerminalId,
2224 data: `${script.command}\r`,2287 data: `${script.command}\r`,
2225 });2288 });
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 }
2226 } catch (error) {2302 } catch (error) {
2227 setThreadError(2303 setThreadError(
2228 activeThreadId,2304 activeThreadId,
...@@ -2295,6 +2371,8 @@ export default function ChatView(props: ChatViewProps) {...@@ -2295,6 +2371,8 @@ export default function ChatView(props: ChatViewProps) {
2295 command: input.command,2371 command: input.command,
2296 icon: input.icon,2372 icon: input.icon,
2297 runOnWorktreeCreate: input.runOnWorktreeCreate,2373 runOnWorktreeCreate: input.runOnWorktreeCreate,
2374 ...(input.previewUrl ? { previewUrl: input.previewUrl } : {}),
2375 ...(input.autoOpenPreview ? { autoOpenPreview: input.autoOpenPreview } : {}),
2298 };2376 };
2299 const nextScripts = input.runOnWorktreeCreate2377 const nextScripts = input.runOnWorktreeCreate
2300 ? [2378 ? [
...@@ -2330,6 +2408,10 @@ export default function ChatView(props: ChatViewProps) {...@@ -2330,6 +2408,10 @@ export default function ChatView(props: ChatViewProps) {
2330 command: input.command,2408 command: input.command,
2331 icon: input.icon,2409 icon: input.icon,
2332 runOnWorktreeCreate: input.runOnWorktreeCreate,2410 runOnWorktreeCreate: input.runOnWorktreeCreate,
2411 ...(input.previewUrl ? { previewUrl: input.previewUrl } : { previewUrl: undefined }),
2412 ...(input.autoOpenPreview
2413 ? { autoOpenPreview: input.autoOpenPreview }
2414 : { autoOpenPreview: undefined }),
2333 };2415 };
2334 const nextScripts = activeProject.scripts.map((script) =>2416 const nextScripts = activeProject.scripts.map((script) =>
2335 script.id === scriptId2417 script.id === scriptId
...@@ -2424,21 +2506,25 @@ export default function ChatView(props: ChatViewProps) {...@@ -2424,21 +2506,25 @@ export default function ChatView(props: ChatViewProps) {
2424 handleInteractionModeChange(interactionMode === "plan" ? "default" : "plan");2506 handleInteractionModeChange(interactionMode === "plan" ? "default" : "plan");
2425 }, [handleInteractionModeChange, interactionMode]);2507 }, [handleInteractionModeChange, interactionMode]);
2426 const togglePlanSidebar = useCallback(() => {2508 const togglePlanSidebar = useCallback(() => {
2427 setPlanSidebarOpen((open) => {2509 if (!activeThreadRef) return;
2428 if (open) {2510 if (planSidebarOpen) {
2429 planSidebarDismissedForTurnRef.current =2511 planSidebarDismissedForTurnRef.current =
2430 activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__";2512 activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__";
2431 } else {2513 } else {
2432 planSidebarDismissedForTurnRef.current = null;2514 planSidebarDismissedForTurnRef.current = null;
2433 }2515 }
2434 return !open;2516 useRightPanelStore.getState().toggle(activeThreadRef, "plan");
2435 });2517 }, [activePlan?.turnId, activeThreadRef, planSidebarOpen, sidebarProposedPlan?.turnId]);
2436 }, [activePlan?.turnId, sidebarProposedPlan?.turnId]);
2437 const closePlanSidebar = useCallback(() => {2518 const closePlanSidebar = useCallback(() => {
2438 setPlanSidebarOpen(false);2519 if (!activeThreadRef) return;
2520 useRightPanelStore.getState().close(activeThreadRef);
2439 planSidebarDismissedForTurnRef.current =2521 planSidebarDismissedForTurnRef.current =
2440 activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__";2522 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
2443 const persistThreadSettingsForNextTurn = useCallback(2529 const persistThreadSettingsForNextTurn = useCallback(
2444 async (input: {2530 async (input: {
...@@ -2523,12 +2609,12 @@ export default function ChatView(props: ChatViewProps) {...@@ -2523,12 +2609,12 @@ export default function ChatView(props: ChatViewProps) {
2523 setShowScrollToBottom(false);2609 setShowScrollToBottom(false);
2524 if (planSidebarOpenOnNextThreadRef.current) {2610 if (planSidebarOpenOnNextThreadRef.current) {
2525 planSidebarOpenOnNextThreadRef.current = false;2611 planSidebarOpenOnNextThreadRef.current = false;
2526 setPlanSidebarOpen(true);2612 if (activeThreadRef) {
2527 } else {2613 useRightPanelStore.getState().open(activeThreadRef, "plan");
2528 planSidebarOpenOnNextThreadRef.current = false;2614 }
2529 setPlanSidebarOpen(false);
2530 }2615 }
2531 planSidebarDismissedForTurnRef.current = null;2616 planSidebarDismissedForTurnRef.current = null;
2617 // eslint-disable-next-line react-hooks/exhaustive-deps -- activeThreadRef is reset transitively
2532 }, [activeThread?.id]);2618 }, [activeThread?.id]);
25332619
2534 // Auto-open the plan sidebar when plan/todo steps arrive for the current turn.2620 // Auto-open the plan sidebar when plan/todo steps arrive for the current turn.
...@@ -2541,10 +2627,13 @@ export default function ChatView(props: ChatViewProps) {...@@ -2541,10 +2627,13 @@ export default function ChatView(props: ChatViewProps) {
2541 if (latestTurnId && activePlan.turnId !== latestTurnId) return;2627 if (latestTurnId && activePlan.turnId !== latestTurnId) return;
2542 const turnKey = activePlan.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__";2628 const turnKey = activePlan.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__";
2543 if (planSidebarDismissedForTurnRef.current === turnKey) return;2629 if (planSidebarDismissedForTurnRef.current === turnKey) return;
2544 setPlanSidebarOpen(true);2630 if (activeThreadRef) {
2631 useRightPanelStore.getState().open(activeThreadRef, "plan");
2632 }
2545 }, [2633 }, [
2546 activePlan,2634 activePlan,
2547 activeLatestTurn?.turnId,2635 activeLatestTurn?.turnId,
2636 activeThreadRef,
2548 autoOpenPlanSidebar,2637 autoOpenPlanSidebar,
2549 planSidebarOpen,2638 planSidebarOpen,
2550 sidebarProposedPlan?.turnId,2639 sidebarProposedPlan?.turnId,
...@@ -3462,7 +3551,9 @@ export default function ChatView(props: ChatViewProps) {...@@ -3462,7 +3551,9 @@ export default function ChatView(props: ChatViewProps) {
3462 // step-tracking activities that the sidebar will display.3551 // step-tracking activities that the sidebar will display.
3463 if (nextInteractionMode === "default" && autoOpenPlanSidebar) {3552 if (nextInteractionMode === "default" && autoOpenPlanSidebar) {
3464 planSidebarDismissedForTurnRef.current = null;3553 planSidebarDismissedForTurnRef.current = null;
3465 setPlanSidebarOpen(true);3554 if (activeThreadRef) {
3555 useRightPanelStore.getState().open(activeThreadRef, "plan");
3556 }
3466 }3557 }
3467 sendInFlightRef.current = false;3558 sendInFlightRef.current = false;
3468 } catch (err) {3559 } catch (err) {
...@@ -3828,6 +3919,9 @@ export default function ChatView(props: ChatViewProps) {...@@ -3828,6 +3919,9 @@ export default function ChatView(props: ChatViewProps) {
3828 terminalOpen={terminalUiState.terminalOpen}3919 terminalOpen={terminalUiState.terminalOpen}
3829 terminalToggleShortcutLabel={terminalToggleShortcutLabel}3920 terminalToggleShortcutLabel={terminalToggleShortcutLabel}
3830 diffToggleShortcutLabel={diffPanelShortcutLabel}3921 diffToggleShortcutLabel={diffPanelShortcutLabel}
3922 previewAvailable={isPreviewSupportedInRuntime()}
3923 previewOpen={previewPanelOpen}
3924 previewToggleShortcutLabel={previewPanelShortcutLabel}
3831 gitCwd={gitCwd}3925 gitCwd={gitCwd}
3832 diffOpen={diffOpen}3926 diffOpen={diffOpen}
3833 onRunProjectScript={runProjectScript}3927 onRunProjectScript={runProjectScript}
...@@ -3835,6 +3929,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -3835,6 +3929,7 @@ export default function ChatView(props: ChatViewProps) {
3835 onUpdateProjectScript={updateProjectScript}3929 onUpdateProjectScript={updateProjectScript}
3836 onDeleteProjectScript={deleteProjectScript}3930 onDeleteProjectScript={deleteProjectScript}
3837 onToggleTerminal={toggleTerminalVisibility}3931 onToggleTerminal={toggleTerminalVisibility}
3932 onTogglePreview={onTogglePreview}
3838 onToggleDiff={onToggleDiff}3933 onToggleDiff={onToggleDiff}
3839 />3934 />
3840 </header>3935 </header>
...@@ -4020,7 +4115,6 @@ export default function ChatView(props: ChatViewProps) {...@@ -4020,7 +4115,6 @@ export default function ChatView(props: ChatViewProps) {
4020 </div>4115 </div>
4021 {/* end chat column */}4116 {/* end chat column */}
40224117
4023 {/* Plan sidebar */}
4024 {planSidebarOpen && !shouldUsePlanSidebarSheet ? (4118 {planSidebarOpen && !shouldUsePlanSidebarSheet ? (
4025 <PlanSidebar4119 <PlanSidebar
4026 activePlan={activePlan}4120 activePlan={activePlan}
...@@ -4034,6 +4128,11 @@ export default function ChatView(props: ChatViewProps) {...@@ -4034,6 +4128,11 @@ export default function ChatView(props: ChatViewProps) {
4034 onClose={closePlanSidebar}4128 onClose={closePlanSidebar}
4035 />4129 />
4036 ) : null}4130 ) : null}
4131 {previewPanelOpen && !shouldUsePlanSidebarSheet && activeThreadRef ? (
4132 <Suspense fallback={null}>
4133 <PreviewPanel mode="inline" threadRef={activeThreadRef} visible />
4134 </Suspense>
4135 ) : null}
4037 </div>4136 </div>
4038 {/* end horizontal flex container */}4137 {/* end horizontal flex container */}
40394138
...@@ -4054,6 +4153,13 @@ export default function ChatView(props: ChatViewProps) {...@@ -4054,6 +4153,13 @@ export default function ChatView(props: ChatViewProps) {
4054 onAddTerminalContext={addTerminalContextToDraft}4153 onAddTerminalContext={addTerminalContextToDraft}
4055 />4154 />
4056 ))}4155 ))}
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}
4057 {shouldUsePlanSidebarSheet ? (4163 {shouldUsePlanSidebarSheet ? (
4058 <RightPanelSheet open={planSidebarOpen} onClose={closePlanSidebar}>4164 <RightPanelSheet open={planSidebarOpen} onClose={closePlanSidebar}>
4059 <PlanSidebar4165 <PlanSidebar
apps/web/src/components/ProjectScriptsControl.tsx+39
...@@ -85,6 +85,10 @@ export interface NewProjectScriptInput {...@@ -85,6 +85,10 @@ export interface NewProjectScriptInput {
85 icon: ProjectScriptIcon;85 icon: ProjectScriptIcon;
86 runOnWorktreeCreate: boolean;86 runOnWorktreeCreate: boolean;
87 keybinding: string | null;87 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;
88}92}
8993
90interface ProjectScriptsControlProps {94interface ProjectScriptsControlProps {
...@@ -115,6 +119,8 @@ export default function ProjectScriptsControl({...@@ -115,6 +119,8 @@ export default function ProjectScriptsControl({
115 const [iconPickerOpen, setIconPickerOpen] = useState(false);119 const [iconPickerOpen, setIconPickerOpen] = useState(false);
116 const [runOnWorktreeCreate, setRunOnWorktreeCreate] = useState(false);120 const [runOnWorktreeCreate, setRunOnWorktreeCreate] = useState(false);
117 const [keybinding, setKeybinding] = useState("");121 const [keybinding, setKeybinding] = useState("");
122 const [previewUrl, setPreviewUrl] = useState("");
123 const [autoOpenPreview, setAutoOpenPreview] = useState(false);
118 const [validationError, setValidationError] = useState<string | null>(null);124 const [validationError, setValidationError] = useState<string | null>(null);
119 const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);125 const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
120126
...@@ -166,12 +172,15 @@ export default function ProjectScriptsControl({...@@ -166,12 +172,15 @@ export default function ProjectScriptsControl({
166 keybinding,172 keybinding,
167 command: commandForProjectScript(scriptIdForValidation),173 command: commandForProjectScript(scriptIdForValidation),
168 });174 });
175 const trimmedPreviewUrl = previewUrl.trim();
169 const payload = {176 const payload = {
170 name: trimmedName,177 name: trimmedName,
171 command: trimmedCommand,178 command: trimmedCommand,
172 icon,179 icon,
173 runOnWorktreeCreate,180 runOnWorktreeCreate,
174 keybinding: keybindingRule?.key ?? null,181 keybinding: keybindingRule?.key ?? null,
182 previewUrl: trimmedPreviewUrl.length > 0 ? trimmedPreviewUrl : null,
183 autoOpenPreview: trimmedPreviewUrl.length > 0 ? autoOpenPreview : false,
175 } satisfies NewProjectScriptInput;184 } satisfies NewProjectScriptInput;
176 if (editingScriptId) {185 if (editingScriptId) {
177 await onUpdateScript(editingScriptId, payload);186 await onUpdateScript(editingScriptId, payload);
...@@ -193,6 +202,8 @@ export default function ProjectScriptsControl({...@@ -193,6 +202,8 @@ export default function ProjectScriptsControl({
193 setIconPickerOpen(false);202 setIconPickerOpen(false);
194 setRunOnWorktreeCreate(false);203 setRunOnWorktreeCreate(false);
195 setKeybinding("");204 setKeybinding("");
205 setPreviewUrl("");
206 setAutoOpenPreview(false);
196 setValidationError(null);207 setValidationError(null);
197 setDialogOpen(true);208 setDialogOpen(true);
198 };209 };
...@@ -205,6 +216,8 @@ export default function ProjectScriptsControl({...@@ -205,6 +216,8 @@ export default function ProjectScriptsControl({
205 setIconPickerOpen(false);216 setIconPickerOpen(false);
206 setRunOnWorktreeCreate(script.runOnWorktreeCreate);217 setRunOnWorktreeCreate(script.runOnWorktreeCreate);
207 setKeybinding(keybindingValueForCommand(keybindings, commandForProjectScript(script.id)) ?? "");218 setKeybinding(keybindingValueForCommand(keybindings, commandForProjectScript(script.id)) ?? "");
219 setPreviewUrl(script.previewUrl ?? "");
220 setAutoOpenPreview(script.autoOpenPreview ?? false);
208 setValidationError(null);221 setValidationError(null);
209 setDialogOpen(true);222 setDialogOpen(true);
210 };223 };
...@@ -327,6 +340,8 @@ export default function ProjectScriptsControl({...@@ -327,6 +340,8 @@ export default function ProjectScriptsControl({
327 setIcon("play");340 setIcon("play");
328 setRunOnWorktreeCreate(false);341 setRunOnWorktreeCreate(false);
329 setKeybinding("");342 setKeybinding("");
343 setPreviewUrl("");
344 setAutoOpenPreview(false);
330 setValidationError(null);345 setValidationError(null);
331 }}346 }}
332 open={dialogOpen}347 open={dialogOpen}
...@@ -413,6 +428,18 @@ export default function ProjectScriptsControl({...@@ -413,6 +428,18 @@ export default function ProjectScriptsControl({
413 onChange={(event) => setCommand(event.target.value)}428 onChange={(event) => setCommand(event.target.value)}
414 />429 />
415 </div>430 </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>
416 <label className="flex items-center justify-between gap-3 rounded-md border border-border/70 px-3 py-2 text-sm">443 <label className="flex items-center justify-between gap-3 rounded-md border border-border/70 px-3 py-2 text-sm">
417 <span>Run automatically on worktree creation</span>444 <span>Run automatically on worktree creation</span>
418 <Switch445 <Switch
...@@ -420,6 +447,18 @@ export default function ProjectScriptsControl({...@@ -420,6 +447,18 @@ export default function ProjectScriptsControl({
420 onCheckedChange={(checked) => setRunOnWorktreeCreate(Boolean(checked))}447 onCheckedChange={(checked) => setRunOnWorktreeCreate(Boolean(checked))}
421 />448 />
422 </label>449 </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>
423 {validationError && <p className="text-sm text-destructive">{validationError}</p>}462 {validationError && <p className="text-sm text-destructive">{validationError}</p>}
424 </form>463 </form>
425 </DialogPanel>464 </DialogPanel>
apps/web/src/components/ThreadTerminalDrawer.tsx+21-5
...@@ -48,6 +48,7 @@ import {...@@ -48,6 +48,7 @@ import {
48import { readEnvironmentApi } from "~/environmentApi";48import { readEnvironmentApi } from "~/environmentApi";
49import { readLocalApi } from "~/localApi";49import { readLocalApi } from "~/localApi";
50import { attachTerminalSession } from "../terminalSessionState";50import { attachTerminalSession } from "../terminalSessionState";
51import { openTerminalLinkInPreview } from "./preview/openTerminalLinkInPreview";
5152
52const MIN_DRAWER_HEIGHT = 180;53const MIN_DRAWER_HEIGHT = 180;
53const MAX_DRAWER_HEIGHT_RATIO = 0.75;54const MAX_DRAWER_HEIGHT_RATIO = 0.75;
...@@ -509,11 +510,26 @@ export function TerminalViewport({...@@ -509,11 +510,26 @@ export function TerminalViewport({
509 }510 }
510511
511 if (match.kind === "url") {512 if (match.kind === "url") {
512 void localApi.shell.openExternal(match.text).catch((error: unknown) => {513 const fallbackToBrowser = () => {
513 writeSystemMessage(514 void localApi.shell.openExternal(match.text).catch((error: unknown) => {
514 latestTerminal,515 writeSystemMessage(
515 error instanceof Error ? error.message : "Unable to open link",516 latestTerminal,
516 );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,
517 });533 });
518 return;534 return;
519 }535 }
apps/web/src/components/chat/ChatHeader.tsx+34-1
...@@ -9,7 +9,8 @@ import { scopeThreadRef } from "@t3tools/client-runtime";...@@ -9,7 +9,8 @@ import { scopeThreadRef } from "@t3tools/client-runtime";
9import { memo } from "react";9import { memo } from "react";
10import GitActionsControl from "../GitActionsControl";10import GitActionsControl from "../GitActionsControl";
11import { type DraftId } from "~/composerDraftStore";11import { type DraftId } from "~/composerDraftStore";
12import { DiffIcon, TerminalSquareIcon } from "lucide-react";12import { DiffIcon, Globe, TerminalSquareIcon } from "lucide-react";
13import { Badge } from "../ui/badge";
13import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";14import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
14import ProjectScriptsControl, { type NewProjectScriptInput } from "../ProjectScriptsControl";15import ProjectScriptsControl, { type NewProjectScriptInput } from "../ProjectScriptsControl";
15import { Toggle } from "../ui/toggle";16import { Toggle } from "../ui/toggle";
...@@ -33,6 +34,10 @@ interface ChatHeaderProps {...@@ -33,6 +34,10 @@ interface ChatHeaderProps {
33 terminalOpen: boolean;34 terminalOpen: boolean;
34 terminalToggleShortcutLabel: string | null;35 terminalToggleShortcutLabel: string | null;
35 diffToggleShortcutLabel: string | null;36 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;
36 gitCwd: string | null;41 gitCwd: string | null;
37 diffOpen: boolean;42 diffOpen: boolean;
38 onRunProjectScript: (script: ProjectScript) => void;43 onRunProjectScript: (script: ProjectScript) => void;
...@@ -40,6 +45,7 @@ interface ChatHeaderProps {...@@ -40,6 +45,7 @@ interface ChatHeaderProps {
40 onUpdateProjectScript: (scriptId: string, input: NewProjectScriptInput) => Promise<void>;45 onUpdateProjectScript: (scriptId: string, input: NewProjectScriptInput) => Promise<void>;
41 onDeleteProjectScript: (scriptId: string) => Promise<void>;46 onDeleteProjectScript: (scriptId: string) => Promise<void>;
42 onToggleTerminal: () => void;47 onToggleTerminal: () => void;
48 onTogglePreview: () => void;
43 onToggleDiff: () => void;49 onToggleDiff: () => void;
44}50}
4551
...@@ -71,6 +77,9 @@ export const ChatHeader = memo(function ChatHeader({...@@ -71,6 +77,9 @@ export const ChatHeader = memo(function ChatHeader({
71 terminalOpen,77 terminalOpen,
72 terminalToggleShortcutLabel,78 terminalToggleShortcutLabel,
73 diffToggleShortcutLabel,79 diffToggleShortcutLabel,
80 previewAvailable,
81 previewOpen,
82 previewToggleShortcutLabel,
74 gitCwd,83 gitCwd,
75 diffOpen,84 diffOpen,
76 onRunProjectScript,85 onRunProjectScript,
...@@ -78,6 +87,7 @@ export const ChatHeader = memo(function ChatHeader({...@@ -78,6 +87,7 @@ export const ChatHeader = memo(function ChatHeader({
78 onUpdateProjectScript,87 onUpdateProjectScript,
79 onDeleteProjectScript,88 onDeleteProjectScript,
80 onToggleTerminal,89 onToggleTerminal,
90 onTogglePreview,
81 onToggleDiff,91 onToggleDiff,
82}: ChatHeaderProps) {92}: ChatHeaderProps) {
83 const primaryEnvironmentId = usePrimaryEnvironmentId();93 const primaryEnvironmentId = usePrimaryEnvironmentId();
...@@ -155,6 +165,29 @@ export const ChatHeader = memo(function ChatHeader({...@@ -155,6 +165,29 @@ export const ChatHeader = memo(function ChatHeader({
155 : "Toggle terminal drawer"}165 : "Toggle terminal drawer"}
156 </TooltipPopup>166 </TooltipPopup>
157 </Tooltip>167 </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}
158 <Tooltip>191 <Tooltip>
159 <TooltipTrigger192 <TooltipTrigger
160 render={193 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 {...@@ -58,6 +58,16 @@ export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi {
58 subscribeThread: (input, callback, options) =>58 subscribeThread: (input, callback, options) =>
59 rpcClient.orchestration.subscribeThread(input, callback, options),59 rpcClient.orchestration.subscribeThread(input, callback, options),
60 },60 },
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 },
61 };71 };
62}72}
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 {...@@ -30,6 +30,8 @@ export interface ShortcutModifierStateLike {
30export interface ShortcutMatchContext {30export interface ShortcutMatchContext {
31 terminalFocus: boolean;31 terminalFocus: boolean;
32 terminalOpen: boolean;32 terminalOpen: boolean;
33 previewFocus: boolean;
34 previewOpen: boolean;
33 [key: string]: boolean;35 [key: string]: boolean;
34}36}
3537
...@@ -112,6 +114,8 @@ function resolveContext(options: ShortcutMatchOptions | undefined): ShortcutMatc...@@ -112,6 +114,8 @@ function resolveContext(options: ShortcutMatchOptions | undefined): ShortcutMatc
112 return {114 return {
113 terminalFocus: false,115 terminalFocus: false,
114 terminalOpen: false,116 terminalOpen: false,
117 previewFocus: false,
118 previewOpen: false,
115 ...options?.context,119 ...options?.context,
116 };120 };
117}121}
...@@ -379,6 +383,30 @@ export function isDiffToggleShortcut(...@@ -379,6 +383,30 @@ export function isDiffToggleShortcut(
379 return matchesCommandShortcut(event, keybindings, "diff.toggle", options);383 return matchesCommandShortcut(event, keybindings, "diff.toggle", options);
380}384}
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
382export function isChatNewShortcut(410export function isChatNewShortcut(
383 event: ShortcutEventLike,411 event: ShortcutEventLike,
384 keybindings: ResolvedKeybindingsConfig,412 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";...@@ -18,6 +18,10 @@ import { getRouter } from "./router";
18import { APP_DISPLAY_NAME } from "./branding";18import { APP_DISPLAY_NAME } from "./branding";
19import { syncDocumentWindowControlsOverlayClass } from "./lib/windowControlsOverlay";19import { syncDocumentWindowControlsOverlayClass } from "./lib/windowControlsOverlay";
2020
21if (import.meta.env.DEV) {
22 void import("react-grab");
23}
24
21// Electron loads the app from a file-backed shell, so hash history avoids path resolution issues.25// Electron loads the app from a file-backed shell, so hash history avoids path resolution issues.
22const history = isElectron ? createHashHistory() : createBrowserHistory();26const 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";...@@ -2,16 +2,21 @@ import { Outlet, createFileRoute, redirect } from "@tanstack/react-router";
2import { useEffect } from "react";2import { useEffect } from "react";
33
4import { useCommandPaletteStore } from "../commandPaletteStore";4import { useCommandPaletteStore } from "../commandPaletteStore";
5import { dispatchPreviewAction } from "../components/preview/previewActionBus";
5import { useHandleNewThread } from "../hooks/useHandleNewThread";6import { useHandleNewThread } from "../hooks/useHandleNewThread";
6import {7import {
7 startNewLocalThreadFromContext,8 startNewLocalThreadFromContext,
8 startNewThreadFromContext,9 startNewThreadFromContext,
9} from "../lib/chatThreadActions";10} from "../lib/chatThreadActions";
11import { isPreviewFocused } from "../lib/previewFocus";
10import { isTerminalFocused } from "../lib/terminalFocus";12import { isTerminalFocused } from "../lib/terminalFocus";
11import { resolveShortcutCommand } from "../keybindings";13import { resolveShortcutCommand } from "../keybindings";
12import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";14import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";
15import { isPreviewSupportedInRuntime } from "../previewStateStore";
16import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore";
13import { useThreadSelectionStore } from "../threadSelectionStore";17import { useThreadSelectionStore } from "../threadSelectionStore";
14import { resolveSidebarNewThreadEnvMode } from "~/components/Sidebar.logic";18import { resolveSidebarNewThreadEnvMode } from "~/components/Sidebar.logic";
19import { stackedThreadToast, toastManager } from "~/components/ui/toast";
15import { useSettings } from "~/hooks/useSettings";20import { useSettings } from "~/hooks/useSettings";
16import { useServerKeybindings } from "~/rpc/serverState";21import { useServerKeybindings } from "~/rpc/serverState";
1722
...@@ -26,6 +31,14 @@ function ChatRouteGlobalShortcuts() {...@@ -26,6 +31,14 @@ function ChatRouteGlobalShortcuts() {
26 ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen31 ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen
27 : false,32 : false,
28 );33 );
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 );
29 const appSettings = useSettings();42 const appSettings = useSettings();
3043
31 useEffect(() => {44 useEffect(() => {
...@@ -35,6 +48,8 @@ function ChatRouteGlobalShortcuts() {...@@ -35,6 +48,8 @@ function ChatRouteGlobalShortcuts() {
35 context: {48 context: {
36 terminalFocus: isTerminalFocused(),49 terminalFocus: isTerminalFocused(),
37 terminalOpen,50 terminalOpen,
51 previewFocus: isPreviewFocused(),
52 previewOpen,
38 },53 },
39 });54 });
4055
...@@ -75,6 +90,50 @@ function ChatRouteGlobalShortcuts() {...@@ -75,6 +90,50 @@ function ChatRouteGlobalShortcuts() {
75 }),90 }),
76 handleNewThread,91 handleNewThread,
77 });92 });
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);
78 }137 }
79 };138 };
80139
...@@ -89,6 +148,8 @@ function ChatRouteGlobalShortcuts() {...@@ -89,6 +148,8 @@ function ChatRouteGlobalShortcuts() {
89 handleNewThread,148 handleNewThread,
90 keybindings,149 keybindings,
91 defaultProjectRef,150 defaultProjectRef,
151 previewOpen,
152 routeThreadRef,
92 selectedThreadKeysSize,153 selectedThreadKeysSize,
93 terminalOpen,154 terminalOpen,
94 appSettings.defaultThreadEnvMode,155 appSettings.defaultThreadEnvMode,
docs/user/keybindings.md+14
...@@ -23,6 +23,12 @@ See the full schema for more details: [`packages/contracts/src/keybindings.ts`](...@@ -23,6 +23,12 @@ See the full schema for more details: [`packages/contracts/src/keybindings.ts`](
23 { "key": "mod+d", "command": "terminal.split", "when": "terminalFocus" },23 { "key": "mod+d", "command": "terminal.split", "when": "terminalFocus" },
24 { "key": "mod+n", "command": "terminal.new", "when": "terminalFocus" },24 { "key": "mod+n", "command": "terminal.new", "when": "terminalFocus" },
25 { "key": "mod+w", "command": "terminal.close", "when": "terminalFocus" },25 { "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" },
26 { "key": "mod+k", "command": "commandPalette.toggle", "when": "!terminalFocus" },32 { "key": "mod+k", "command": "commandPalette.toggle", "when": "!terminalFocus" },
27 { "key": "mod+n", "command": "chat.new", "when": "!terminalFocus" },33 { "key": "mod+n", "command": "chat.new", "when": "!terminalFocus" },
28 { "key": "mod+shift+o", "command": "chat.new", "when": "!terminalFocus" },34 { "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...@@ -51,6 +57,12 @@ Invalid rules are ignored. Invalid config files are ignored. Warnings are logged
51- `terminal.split`: split terminal (in focused terminal context by default)57- `terminal.split`: split terminal (in focused terminal context by default)
52- `terminal.new`: create new terminal (in focused terminal context by default)58- `terminal.new`: create new terminal (in focused terminal context by default)
53- `terminal.close`: close/kill the focused terminal (in focused terminal context by default)59- `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)
54- `commandPalette.toggle`: open or close the global command palette66- `commandPalette.toggle`: open or close the global command palette
55- `chat.new`: create a new chat thread preserving the active thread's branch/worktree state67- `chat.new`: create a new chat thread preserving the active thread's branch/worktree state
56- `chat.newLocal`: create a new chat thread for the active project in a new environment (local/worktree determined by app settings (default `local`))68- `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:...@@ -80,6 +92,8 @@ Currently available context keys:
8092
81- `terminalFocus`93- `terminalFocus`
82- `terminalOpen`94- `terminalOpen`
95- `previewFocus`
96- `previewOpen`
8397
84Supported operators:98Supported operators:
8599
packages/client-runtime/src/wsRpcClient.ts+31
...@@ -79,6 +79,16 @@ export interface WsRpcClient {...@@ -79,6 +79,16 @@ export interface WsRpcClient {
79 readonly onEvent: RpcStreamMethod<typeof WS_METHODS.subscribeTerminalEvents>;79 readonly onEvent: RpcStreamMethod<typeof WS_METHODS.subscribeTerminalEvents>;
80 readonly onMetadata: RpcStreamMethod<typeof WS_METHODS.subscribeTerminalMetadata>;80 readonly onMetadata: RpcStreamMethod<typeof WS_METHODS.subscribeTerminalMetadata>;
81 };81 };
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 };
82 readonly projects: {92 readonly projects: {
83 readonly searchEntries: RpcUnaryMethod<typeof WS_METHODS.projectsSearchEntries>;93 readonly searchEntries: RpcUnaryMethod<typeof WS_METHODS.projectsSearchEntries>;
84 readonly writeFile: RpcUnaryMethod<typeof WS_METHODS.projectsWriteFile>;94 readonly writeFile: RpcUnaryMethod<typeof WS_METHODS.projectsWriteFile>;
...@@ -212,6 +222,27 @@ export function createWsRpcClient(...@@ -212,6 +222,27 @@ export function createWsRpcClient(
212 subscriptionOptions(options, WS_METHODS.subscribeTerminalMetadata),222 subscriptionOptions(options, WS_METHODS.subscribeTerminalMetadata),
213 ),223 ),
214 },224 },
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 },
215 projects: {246 projects: {
216 searchEntries: (input) =>247 searchEntries: (input) =>
217 transport.request((client) => client[WS_METHODS.projectsSearchEntries](input)),248 transport.request((client) => client[WS_METHODS.projectsSearchEntries](input)),
packages/contracts/src/index.ts+1
...@@ -22,4 +22,5 @@ export * from "./editor.ts";...@@ -22,4 +22,5 @@ export * from "./editor.ts";
22export * from "./project.ts";22export * from "./project.ts";
23export * from "./filesystem.ts";23export * from "./filesystem.ts";
24export * from "./review.ts";24export * from "./review.ts";
25export * from "./preview.ts";
25export * from "./rpc.ts";26export * from "./rpc.ts";
packages/contracts/src/ipc.ts+80
...@@ -54,6 +54,18 @@ import type {...@@ -54,6 +54,18 @@ import type {
54} from "./terminal.ts";54} from "./terminal.ts";
55import type { ServerRemoveKeybindingInput, ServerUpsertKeybindingInput } from "./server.ts";55import type { ServerRemoveKeybindingInput, ServerUpsertKeybindingInput } from "./server.ts";
56import * as Schema from "effect/Schema";56import * 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";
57import type {69import type {
58 ClientOrchestrationCommand,70 ClientOrchestrationCommand,
59 OrchestrationGetFullThreadDiffInput,71 OrchestrationGetFullThreadDiffInput,
...@@ -402,6 +414,33 @@ export const DesktopCloudAuthFetchResultSchema = Schema.Struct({...@@ -402,6 +414,33 @@ export const DesktopCloudAuthFetchResultSchema = Schema.Struct({
402});414});
403export type DesktopCloudAuthFetchResult = typeof DesktopCloudAuthFetchResultSchema.Type;415export 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
405export interface DesktopBridge {444export interface DesktopBridge {
406 getAppBranding: () => DesktopAppBranding | null;445 getAppBranding: () => DesktopAppBranding | null;
407 getLocalEnvironmentBootstrap: () => DesktopEnvironmentBootstrap | null;446 getLocalEnvironmentBootstrap: () => DesktopEnvironmentBootstrap | null;
...@@ -460,6 +499,34 @@ export interface DesktopBridge {...@@ -460,6 +499,34 @@ export interface DesktopBridge {
460 downloadUpdate: () => Promise<DesktopUpdateActionResult>;499 downloadUpdate: () => Promise<DesktopUpdateActionResult>;
461 installUpdate: () => Promise<DesktopUpdateActionResult>;500 installUpdate: () => Promise<DesktopUpdateActionResult>;
462 onUpdateState: (listener: (state: DesktopUpdateState) => void) => () => void;501 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;
463}530}
464531
465/**532/**
...@@ -619,4 +686,17 @@ export interface EnvironmentApi {...@@ -619,4 +686,17 @@ export interface EnvironmentApi {
619 },686 },
620 ) => () => void;687 ) => () => void;
621 };688 };
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 };
622}702}
packages/contracts/src/keybindings.ts+6
...@@ -53,6 +53,12 @@ const STATIC_KEYBINDING_COMMANDS = [...@@ -53,6 +53,12 @@ const STATIC_KEYBINDING_COMMANDS = [
53 "terminal.new",53 "terminal.new",
54 "terminal.close",54 "terminal.close",
55 "diff.toggle",55 "diff.toggle",
56 "preview.toggle",
57 "preview.refresh",
58 "preview.focusUrl",
59 "preview.zoomIn",
60 "preview.zoomOut",
61 "preview.resetZoom",
56 "commandPalette.toggle",62 "commandPalette.toggle",
57 "chat.new",63 "chat.new",
58 "chat.newLocal",64 "chat.newLocal",
packages/contracts/src/orchestration.ts+11
...@@ -194,6 +194,17 @@ export const ProjectScript = Schema.Struct({...@@ -194,6 +194,17 @@ export const ProjectScript = Schema.Struct({
194 command: TrimmedNonEmptyString,194 command: TrimmedNonEmptyString,
195 icon: ProjectScriptIcon,195 icon: ProjectScriptIcon,
196 runOnWorktreeCreate: Schema.Boolean,196 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),
197});208});
198export type ProjectScript = typeof ProjectScript.Type;209export 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 {...@@ -85,6 +85,19 @@ import {
85 TerminalSessionSnapshot,85 TerminalSessionSnapshot,
86 TerminalWriteInput,86 TerminalWriteInput,
87} from "./terminal.ts";87} 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";
88import {101import {
89 ServerConfigStreamEvent,102 ServerConfigStreamEvent,
90 ServerConfig,103 ServerConfig,
...@@ -157,6 +170,14 @@ export const WS_METHODS = {...@@ -157,6 +170,14 @@ export const WS_METHODS = {
157 terminalRestart: "terminal.restart",170 terminalRestart: "terminal.restart",
158 terminalClose: "terminal.close",171 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
160 // Server meta181 // Server meta
161 serverGetConfig: "server.getConfig",182 serverGetConfig: "server.getConfig",
162 serverRefreshProviders: "server.refreshProviders",183 serverRefreshProviders: "server.refreshProviders",
...@@ -184,6 +205,8 @@ export const WS_METHODS = {...@@ -184,6 +205,8 @@ export const WS_METHODS = {
184 subscribeVcsStatus: "subscribeVcsStatus",205 subscribeVcsStatus: "subscribeVcsStatus",
185 subscribeTerminalEvents: "subscribeTerminalEvents",206 subscribeTerminalEvents: "subscribeTerminalEvents",
186 subscribeTerminalMetadata: "subscribeTerminalMetadata",207 subscribeTerminalMetadata: "subscribeTerminalMetadata",
208 subscribePreviewEvents: "subscribePreviewEvents",
209 subscribeDiscoveredLocalServers: "subscribeDiscoveredLocalServers",
187 subscribeServerConfig: "subscribeServerConfig",210 subscribeServerConfig: "subscribeServerConfig",
188 subscribeServerLifecycle: "subscribeServerLifecycle",211 subscribeServerLifecycle: "subscribeServerLifecycle",
189 subscribeAuthAccess: "subscribeAuthAccess",212 subscribeAuthAccess: "subscribeAuthAccess",
...@@ -454,6 +477,53 @@ export const WsTerminalCloseRpc = Rpc.make(WS_METHODS.terminalClose, {...@@ -454,6 +477,53 @@ export const WsTerminalCloseRpc = Rpc.make(WS_METHODS.terminalClose, {
454 error: Schema.Union([TerminalError, EnvironmentAuthorizationError]),477 error: Schema.Union([TerminalError, EnvironmentAuthorizationError]),
455});478});
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
457export const WsOrchestrationDispatchCommandRpc = Rpc.make(527export const WsOrchestrationDispatchCommandRpc = Rpc.make(
458 ORCHESTRATION_WS_METHODS.dispatchCommand,528 ORCHESTRATION_WS_METHODS.dispatchCommand,
459 {529 {
...@@ -589,6 +659,14 @@ export const WsRpcGroup = RpcGroup.make(...@@ -589,6 +659,14 @@ export const WsRpcGroup = RpcGroup.make(
589 WsTerminalCloseRpc,659 WsTerminalCloseRpc,
590 WsSubscribeTerminalEventsRpc,660 WsSubscribeTerminalEventsRpc,
591 WsSubscribeTerminalMetadataRpc,661 WsSubscribeTerminalMetadataRpc,
662 WsPreviewOpenRpc,
663 WsPreviewNavigateRpc,
664 WsPreviewRefreshRpc,
665 WsPreviewCloseRpc,
666 WsPreviewListRpc,
667 WsPreviewReportStatusRpc,
668 WsSubscribePreviewEventsRpc,
669 WsSubscribeDiscoveredLocalServersRpc,
592 WsSubscribeServerConfigRpc,670 WsSubscribeServerConfigRpc,
593 WsSubscribeServerLifecycleRpc,671 WsSubscribeServerLifecycleRpc,
594 WsSubscribeAuthAccessRpc,672 WsSubscribeAuthAccessRpc,
packages/shared/package.json+4
...@@ -150,6 +150,10 @@...@@ -150,6 +150,10 @@
150 "./relayClient": {150 "./relayClient": {
151 "types": "./src/relayClient.ts",151 "types": "./src/relayClient.ts",
152 "import": "./src/relayClient.ts"152 "import": "./src/relayClient.ts"
153 },
154 "./preview": {
155 "types": "./src/preview.ts",
156 "import": "./src/preview.ts"
153 }157 }
154 },158 },
155 "scripts": {159 "scripts": {
packages/shared/src/keybindings.ts+7
...@@ -24,6 +24,13 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [...@@ -24,6 +24,13 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [
24 { key: "mod+n", command: "terminal.new", when: "terminalFocus" },24 { key: "mod+n", command: "terminal.new", when: "terminalFocus" },
25 { key: "mod+w", command: "terminal.close", when: "terminalFocus" },25 { key: "mod+w", command: "terminal.close", when: "terminalFocus" },
26 { key: "mod+d", command: "diff.toggle", when: "!terminalFocus" },26 { 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" },
27 { key: "mod+k", command: "commandPalette.toggle", when: "!terminalFocus" },34 { key: "mod+k", command: "commandPalette.toggle", when: "!terminalFocus" },
28 { key: "mod+n", command: "chat.new", when: "!terminalFocus" },35 { key: "mod+n", command: "chat.new", when: "!terminalFocus" },
29 { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" },36 { 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}