| author | |
| committer | |
| log | 52c77c1ec4c1c2c2c27b917baaec91d050d0a737 |
| tree | e00bf772b3631077978c118c0dc39d6091741791 |
| parent | d6eb386e3d3a1cb114f7e00ab42c5b8e6b63c181 |
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 | 143 | - Remove the local `planSidebarOpen` state (`:688`). |
| 144 | 144 | - Replace `setPlanSidebarOpen(true)` callsites with `rightPanelStore.open(activeThreadRef, "plan")`. |
| 145 | 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. | |
| 147 | 147 | |
| 148 | 148 | ### Render arbitration |
| 149 | 149 | |
| ... | ... | @@ -176,49 +176,49 @@ const useSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY); |
| 176 | 176 | |
| 177 | 177 | ### NEW files |
| 178 | 178 | |
| 179 | | File | Purpose | | |
| 180 | |---|---| | |
| 181 | | `packages/contracts/src/preview.ts` | Effect/Schema schemas: inputs, snapshot, events, errors | | |
| 182 | | `packages/contracts/src/preview.test.ts` | Schema round‑trip tests | | |
| 183 | | `apps/server/src/preview/Services/Manager.ts` | `PreviewManager` Service tag + interface | | |
| 184 | | `apps/server/src/preview/Layers/Manager.ts` | Implementation: in‑memory map + event subject | | |
| 185 | | `apps/server/src/preview/Layers/Manager.test.ts` | Lifecycle, snapshot, event ordering | | |
| 186 | | `apps/desktop/src/preview-view-manager.ts` | Plain‑Node Electron port of ami's BrowserManager (subset) | | |
| 187 | | `apps/desktop/src/preview-preload.ts` | Webview preload (no‑op v1) | | |
| 188 | | `apps/web/src/previewStateStore.ts` | Per‑thread zustand store (mirrors `terminalStateStore.ts`) | | |
| 189 | | `apps/web/src/previewStateStore.test.ts` | Reducer tests | | |
| 190 | | `apps/web/src/rightPanelStore.ts` | Right‑panel arbiter | | |
| 191 | | `apps/web/src/rightPanelStore.test.ts` | Arbiter tests | | |
| 192 | | `apps/web/src/components/preview/PreviewPanel.tsx` | Right‑panel entry (wraps `PreviewPanelShell`) | | |
| 193 | | `apps/web/src/components/preview/PreviewPanelShell.tsx` | Shell mirroring `DiffPanelShell` (`mode: "inline"\|"sheet"\|"sidebar"`) | | |
| 194 | | `apps/web/src/components/preview/PreviewView.tsx` | Chrome bar (URL/back/fwd/refresh) + `<PreviewWebview>` | | |
| 195 | | `apps/web/src/components/preview/PreviewWebview.tsx` | Electron `<webview>` host; null on web build | | |
| 196 | | `apps/web/src/components/preview/PreviewEmptyState.tsx` | Pre‑URL empty state | | |
| 197 | | `apps/web/src/components/preview/PreviewUnsupportedToast.ts` | `"Preview is only available in the desktop app"` toast | | |
| 179 | | File | Purpose | | |
| 180 | | ------------------------------------------------------------ | ----------------------------------------------------------------------- | | |
| 181 | | `packages/contracts/src/preview.ts` | Effect/Schema schemas: inputs, snapshot, events, errors | | |
| 182 | | `packages/contracts/src/preview.test.ts` | Schema round‑trip tests | | |
| 183 | | `apps/server/src/preview/Services/Manager.ts` | `PreviewManager` Service tag + interface | | |
| 184 | | `apps/server/src/preview/Layers/Manager.ts` | Implementation: in‑memory map + event subject | | |
| 185 | | `apps/server/src/preview/Layers/Manager.test.ts` | Lifecycle, snapshot, event ordering | | |
| 186 | | `apps/desktop/src/preview-view-manager.ts` | Plain‑Node Electron port of ami's BrowserManager (subset) | | |
| 187 | | `apps/desktop/src/preview-preload.ts` | Webview preload (no‑op v1) | | |
| 188 | | `apps/web/src/previewStateStore.ts` | Per‑thread zustand store (mirrors `terminalStateStore.ts`) | | |
| 189 | | `apps/web/src/previewStateStore.test.ts` | Reducer tests | | |
| 190 | | `apps/web/src/rightPanelStore.ts` | Right‑panel arbiter | | |
| 191 | | `apps/web/src/rightPanelStore.test.ts` | Arbiter tests | | |
| 192 | | `apps/web/src/components/preview/PreviewPanel.tsx` | Right‑panel entry (wraps `PreviewPanelShell`) | | |
| 193 | | `apps/web/src/components/preview/PreviewPanelShell.tsx` | Shell mirroring `DiffPanelShell` (`mode: "inline"\|"sheet"\|"sidebar"`) | | |
| 194 | | `apps/web/src/components/preview/PreviewView.tsx` | Chrome bar (URL/back/fwd/refresh) + `<PreviewWebview>` | | |
| 195 | | `apps/web/src/components/preview/PreviewWebview.tsx` | Electron `<webview>` host; null on web build | | |
| 196 | | `apps/web/src/components/preview/PreviewEmptyState.tsx` | Pre‑URL empty state | | |
| 197 | | `apps/web/src/components/preview/PreviewUnsupportedToast.ts` | `"Preview is only available in the desktop app"` toast | | |
| 198 | 198 | |
| 199 | 199 | ### MODIFIED files |
| 200 | 200 | |
| 201 | | File | Change | | |
| 202 | |---|---| | |
| 203 | | `packages/contracts/src/keybindings.ts` | Add `preview.toggle`, `preview.refresh`, `preview.focusUrl` to `STATIC_KEYBINDING_COMMANDS`; add `previewFocus`, `previewOpen` to context keys | | |
| 204 | | `packages/contracts/src/project.ts` | Add `previewUrl?: string` and `autoOpenPreview?: boolean` to `ProjectScript` schema | | |
| 205 | | `packages/contracts/src/server.ts` | Extend `EnvironmentApi` with `preview` namespace | | |
| 206 | | `packages/contracts/src/index.ts` | Re‑export new types | | |
| 207 | | `apps/server/src/keybindings.ts` | Add defaults: `mod+shift+j` → `preview.toggle`, `mod+shift+r` → `preview.refresh` (when `previewFocus`) | | |
| 208 | | `apps/server/src/ws.ts` | Route `preview.open`, `preview.navigate`, `preview.refresh`, `preview.close`, `preview.list`, `preview.onEvent` | | |
| 209 | | `apps/server/src/orchestration/runtimeLayer.ts` (or equivalent) | Provide `PreviewManager.Default` | | |
| 210 | | `apps/web/src/environmentApi.ts` | Wire `preview` slot in `createEnvironmentApi` | | |
| 211 | | `apps/web/src/keybindings.ts` | Add `isPreviewToggleShortcut`, `isPreviewRefreshShortcut` helpers | | |
| 212 | | `apps/web/src/routes/_chat.tsx` | Handle `preview.toggle` in global shortcut handler | | |
| 213 | | `apps/web/src/components/ChatView.tsx` | Replace local `planSidebarOpen` with `rightPanelStore`; render `PreviewPanel` | | |
| 214 | | `apps/web/src/components/ThreadTerminalDrawer.tsx` | At terminal link activation, when `match.kind === "url"` and link looks like a dev URL, show context menu with "Open in preview" / "Open in browser"; pass through to `localApi.preview.openTab(...)` when chosen | | |
| 215 | | `apps/web/src/components/ProjectScriptsControl.tsx` | Add `previewUrl` + `autoOpenPreview` form fields in the Add/Edit dialog | | |
| 216 | | `apps/web/src/projectScripts.ts` | Carry the new fields through `commandForProjectScript` / serialization | | |
| 217 | | `apps/web/src/types.ts` | Add `PreviewSession` mirror types (or re‑export from contracts) | | |
| 218 | | `apps/web/src/lib/desktopBridge.d.ts` (or wherever bridge types live) | Add `preview` namespace shape | | |
| 219 | | `apps/desktop/src/main.ts` | Register `preview:*` IPC handlers; instantiate `previewViewManager`; wire `mainWindow` injection | | |
| 220 | | `apps/desktop/src/preload.ts` | Expose `desktopBridge.preview.*` | | |
| 221 | | `KEYBINDINGS.md` | Document new commands and `previewFocus`/`previewOpen` `when` keys | | |
| 201 | | File | Change | | |
| 202 | | --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | |
| 203 | | `packages/contracts/src/keybindings.ts` | Add `preview.toggle`, `preview.refresh`, `preview.focusUrl` to `STATIC_KEYBINDING_COMMANDS`; add `previewFocus`, `previewOpen` to context keys | | |
| 204 | | `packages/contracts/src/project.ts` | Add `previewUrl?: string` and `autoOpenPreview?: boolean` to `ProjectScript` schema | | |
| 205 | | `packages/contracts/src/server.ts` | Extend `EnvironmentApi` with `preview` namespace | | |
| 206 | | `packages/contracts/src/index.ts` | Re‑export new types | | |
| 207 | | `apps/server/src/keybindings.ts` | Add defaults: `mod+shift+j` → `preview.toggle`, `mod+shift+r` → `preview.refresh` (when `previewFocus`) | | |
| 208 | | `apps/server/src/ws.ts` | Route `preview.open`, `preview.navigate`, `preview.refresh`, `preview.close`, `preview.list`, `preview.onEvent` | | |
| 209 | | `apps/server/src/orchestration/runtimeLayer.ts` (or equivalent) | Provide `PreviewManager.Default` | | |
| 210 | | `apps/web/src/environmentApi.ts` | Wire `preview` slot in `createEnvironmentApi` | | |
| 211 | | `apps/web/src/keybindings.ts` | Add `isPreviewToggleShortcut`, `isPreviewRefreshShortcut` helpers | | |
| 212 | | `apps/web/src/routes/_chat.tsx` | Handle `preview.toggle` in global shortcut handler | | |
| 213 | | `apps/web/src/components/ChatView.tsx` | Replace local `planSidebarOpen` with `rightPanelStore`; render `PreviewPanel` | | |
| 214 | | `apps/web/src/components/ThreadTerminalDrawer.tsx` | At terminal link activation, when `match.kind === "url"` and link looks like a dev URL, show context menu with "Open in preview" / "Open in browser"; pass through to `localApi.preview.openTab(...)` when chosen | | |
| 215 | | `apps/web/src/components/ProjectScriptsControl.tsx` | Add `previewUrl` + `autoOpenPreview` form fields in the Add/Edit dialog | | |
| 216 | | `apps/web/src/projectScripts.ts` | Carry the new fields through `commandForProjectScript` / serialization | | |
| 217 | | `apps/web/src/types.ts` | Add `PreviewSession` mirror types (or re‑export from contracts) | | |
| 218 | | `apps/web/src/lib/desktopBridge.d.ts` (or wherever bridge types live) | Add `preview` namespace shape | | |
| 219 | | `apps/desktop/src/main.ts` | Register `preview:*` IPC handlers; instantiate `previewViewManager`; wire `mainWindow` injection | | |
| 220 | | `apps/desktop/src/preload.ts` | Expose `desktopBridge.preview.*` | | |
| 221 | | `KEYBINDINGS.md` | Document new commands and `previewFocus`/`previewOpen` `when` keys | | |
| 222 | 222 | |
| 223 | 223 | ### NOT changed |
| 224 | 224 | |
| ... | ... | @@ -335,10 +335,7 @@ export class PreviewInvalidUrlError extends Schema.TaggedErrorClass<PreviewInval |
| 335 | 335 | } |
| 336 | 336 | } |
| 337 | 337 | |
| 338 | export const PreviewError = Schema.Union([ | |
| 339 | PreviewSessionLookupError, | |
| 340 | PreviewInvalidUrlError, | |
| 341 | ]); | |
| 338 | export const PreviewError = Schema.Union([PreviewSessionLookupError, PreviewInvalidUrlError]); | |
| 342 | 339 | export type PreviewError = typeof PreviewError.Type; |
| 343 | 340 | ``` |
| 344 | 341 | |
| ... | ... | @@ -360,9 +357,9 @@ const STATIC_KEYBINDING_COMMANDS = [ |
| 360 | 357 | "terminal.new", |
| 361 | 358 | "terminal.close", |
| 362 | 359 | "diff.toggle", |
| 363 | "preview.toggle", // NEW | |
| 364 | "preview.refresh", // NEW | |
| 365 | "preview.focusUrl", // NEW | |
| 360 | "preview.toggle", // NEW | |
| 361 | "preview.refresh", // NEW | |
| 362 | "preview.focusUrl", // NEW | |
| 366 | 363 | "commandPalette.toggle", |
| 367 | 364 | "chat.new", |
| 368 | 365 | "chat.newLocal", |
| ... | ... | @@ -399,28 +396,21 @@ import { |
| 399 | 396 | } from "@t3tools/contracts"; |
| 400 | 397 | |
| 401 | 398 | export interface PreviewManagerShape { |
| 402 | readonly open: ( | |
| 403 | input: PreviewOpenInput, | |
| 404 | ) => Effect.Effect<PreviewSessionSnapshot, PreviewError>; | |
| 399 | readonly open: (input: PreviewOpenInput) => Effect.Effect<PreviewSessionSnapshot, PreviewError>; | |
| 405 | 400 | readonly navigate: ( |
| 406 | 401 | input: PreviewNavigateInput, |
| 407 | 402 | ) => Effect.Effect<PreviewSessionSnapshot, PreviewError>; |
| 408 | readonly refresh: ( | |
| 409 | input: PreviewRefreshInput, | |
| 410 | ) => Effect.Effect<void, PreviewError>; | |
| 403 | readonly refresh: (input: PreviewRefreshInput) => Effect.Effect<void, PreviewError>; | |
| 411 | 404 | readonly close: (input: PreviewCloseInput) => Effect.Effect<void, PreviewError>; |
| 412 | readonly list: ( | |
| 413 | threadId: string, | |
| 414 | ) => Effect.Effect<ReadonlyArray<PreviewSessionSnapshot>>; | |
| 405 | readonly list: (threadId: string) => Effect.Effect<ReadonlyArray<PreviewSessionSnapshot>>; | |
| 415 | 406 | readonly subscribe: ( |
| 416 | 407 | listener: (event: PreviewEvent) => Effect.Effect<void>, |
| 417 | 408 | ) => Effect.Effect<() => void>; |
| 418 | 409 | } |
| 419 | 410 | |
| 420 | export class PreviewManager extends Context.Service< | |
| 421 | PreviewManager, | |
| 422 | PreviewManagerShape | |
| 423 | >()("t3/preview/Services/Manager/PreviewManager") {} | |
| 411 | export class PreviewManager extends Context.Service<PreviewManager, PreviewManagerShape>()( | |
| 412 | "t3/preview/Services/Manager/PreviewManager", | |
| 413 | ) {} | |
| 424 | 414 | ``` |
| 425 | 415 | |
| 426 | 416 | ### `apps/server/src/preview/Layers/Manager.ts` |
| ... | ... | @@ -435,15 +425,12 @@ const normalizeUrl = (input: string) => |
| 435 | 425 | const trimmed = input.trim(); |
| 436 | 426 | if (!trimmed) throw new Error("empty"); |
| 437 | 427 | // localhost stays http unless explicitly https |
| 438 | const useHttp = | |
| 439 | /^(localhost|127\.0\.0\.1|0\.0\.0\.0)(:|\/|$)/.test(trimmed); | |
| 428 | const useHttp = /^(localhost|127\.0\.0\.1|0\.0\.0\.0)(:|\/|$)/.test(trimmed); | |
| 440 | 429 | const parsed = urlParseLax(trimmed, { https: !useHttp }); |
| 441 | 430 | if (!parsed?.href) throw new Error("unparseable"); |
| 442 | 431 | return parsed.href; |
| 443 | 432 | }).pipe( |
| 444 | Effect.catchAll((cause) => | |
| 445 | Effect.fail(new PreviewInvalidUrlError({ rawUrl: input, cause })), | |
| 446 | ), | |
| 433 | Effect.catchAll((cause) => Effect.fail(new PreviewInvalidUrlError({ rawUrl: input, cause }))), | |
| 447 | 434 | ); |
| 448 | 435 | ``` |
| 449 | 436 | |
| ... | ... | @@ -513,14 +500,19 @@ export class PreviewViewManager { |
| 513 | 500 | this.mainWindow = window; |
| 514 | 501 | } |
| 515 | 502 | |
| 516 | getPreloadPath(): string { return this.preloadPath; } | |
| 517 | getBrowserPartition(): string { return PREVIEW_PARTITION; } | |
| 503 | getPreloadPath(): string { | |
| 504 | return this.preloadPath; | |
| 505 | } | |
| 506 | getBrowserPartition(): string { | |
| 507 | return PREVIEW_PARTITION; | |
| 508 | } | |
| 518 | 509 | |
| 519 | 510 | getBrowserSession(): Session { |
| 520 | 511 | if (this.browserSession) return this.browserSession; |
| 521 | 512 | const sess = session.fromPartition(PREVIEW_PARTITION); |
| 522 | 513 | // 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 | 516 | .replace(/Electron\/[\d.]+ /, "") |
| 525 | 517 | .replace(/\s*t3code\/[\d.]+/, ""); |
| 526 | 518 | sess.setUserAgent(ua); |
| ... | ... | @@ -593,9 +585,15 @@ export class PreviewViewManager { |
| 593 | 585 | await wc.loadURL(url); |
| 594 | 586 | } |
| 595 | 587 | |
| 596 | goBack(tabId: string): void { this.requireWebContents(tabId).navigationHistory.goBack(); } | |
| 597 | goForward(tabId: string): void { this.requireWebContents(tabId).navigationHistory.goForward(); } | |
| 598 | refresh(tabId: string): void { this.requireWebContents(tabId).reload(); } | |
| 588 | goBack(tabId: string): void { | |
| 589 | this.requireWebContents(tabId).navigationHistory.goBack(); | |
| 590 | } | |
| 591 | goForward(tabId: string): void { | |
| 592 | this.requireWebContents(tabId).navigationHistory.goForward(); | |
| 593 | } | |
| 594 | refresh(tabId: string): void { | |
| 595 | this.requireWebContents(tabId).reload(); | |
| 596 | } | |
| 599 | 597 | |
| 600 | 598 | onStateChange(listener: Listener): () => void { |
| 601 | 599 | this.listeners.add(listener); |
| ... | ... | @@ -656,16 +654,17 @@ export class PreviewViewManager { |
| 656 | 654 | if (input.type !== "keyDown") return false; |
| 657 | 655 | // Mirror the t3code keybinding defaults that should always reach the main window. |
| 658 | 656 | const SHORTCUTS = [ |
| 659 | { key: "j", meta: true, shift: true }, // preview.toggle | |
| 660 | { key: "k", meta: true, shift: false }, // commandPalette.toggle | |
| 661 | { key: ",", meta: true, shift: false }, // settings | |
| 662 | { key: "w", meta: true, shift: false }, // close | |
| 657 | { key: "j", meta: true, shift: true }, // preview.toggle | |
| 658 | { key: "k", meta: true, shift: false }, // commandPalette.toggle | |
| 659 | { key: ",", meta: true, shift: false }, // settings | |
| 660 | { key: "w", meta: true, shift: false }, // close | |
| 663 | 661 | // future: terminal.* if user wants them while preview focused |
| 664 | 662 | ]; |
| 665 | return SHORTCUTS.some((s) => | |
| 666 | s.key.toLowerCase() === input.key.toLowerCase() | |
| 667 | && s.meta === input.meta | |
| 668 | && s.shift === input.shift, | |
| 663 | return SHORTCUTS.some( | |
| 664 | (s) => | |
| 665 | s.key.toLowerCase() === input.key.toLowerCase() && | |
| 666 | s.meta === input.meta && | |
| 667 | s.shift === input.shift, | |
| 669 | 668 | ); |
| 670 | 669 | } |
| 671 | 670 | |
| ... | ... | @@ -704,9 +703,9 @@ export class PreviewViewManager { |
| 704 | 703 | const trimmed = input.trim(); |
| 705 | 704 | if (!trimmed) throw new PreviewInvalidUrlError(input); |
| 706 | 705 | const useHttp = /^(localhost|127\.0\.0\.1|0\.0\.0\.0)(:|\/|$)/.test(trimmed); |
| 707 | const parsed = new URL(trimmed.includes("://") | |
| 708 | ? trimmed | |
| 709 | : `${useHttp ? "http" : "https"}://${trimmed}`); | |
| 706 | const parsed = new URL( | |
| 707 | trimmed.includes("://") ? trimmed : `${useHttp ? "http" : "https"}://${trimmed}`, | |
| 708 | ); | |
| 710 | 709 | return parsed.href; |
| 711 | 710 | } |
| 712 | 711 | } |
| ... | ... | @@ -716,9 +715,15 @@ export class PreviewTabNotFoundError extends Error { |
| 716 | 715 | super(`Preview tab not found: ${tabId}`); |
| 717 | 716 | } |
| 718 | 717 | } |
| 719 | export class PreviewWebContentsNotFoundError extends Error { /* … */ } | |
| 720 | export class PreviewWebviewNotInitializedError extends Error { /* … */ } | |
| 721 | export class PreviewInvalidUrlError extends Error { /* … */ } | |
| 718 | export class PreviewWebContentsNotFoundError extends Error { | |
| 719 | /* … */ | |
| 720 | } | |
| 721 | export class PreviewWebviewNotInitializedError extends Error { | |
| 722 | /* … */ | |
| 723 | } | |
| 724 | export class PreviewInvalidUrlError extends Error { | |
| 725 | /* … */ | |
| 726 | } | |
| 722 | 727 | |
| 723 | 728 | export const previewViewManager = new PreviewViewManager(); |
| 724 | 729 | ``` |
| ... | ... | @@ -734,18 +739,20 @@ previewViewManager.setMainWindow(mainWindow); |
| 734 | 739 | Register IPC handlers (in `registerIpcHandlers()`): |
| 735 | 740 | |
| 736 | 741 | ```ts |
| 737 | ipcMain.handle("preview:createTab", (_e, tabId: string) => | |
| 738 | previewViewManager.createTab(tabId)); | |
| 739 | ipcMain.handle("preview:closeTab", (_e, tabId: string) => | |
| 740 | previewViewManager.closeTab(tabId)); | |
| 742 | ipcMain.handle("preview:createTab", (_e, tabId: string) => previewViewManager.createTab(tabId)); | |
| 743 | ipcMain.handle("preview:closeTab", (_e, tabId: string) => previewViewManager.closeTab(tabId)); | |
| 741 | 744 | ipcMain.handle("preview:setVisibility", (_e, tabId: string, visible: boolean) => |
| 742 | previewViewManager.setVisibility(tabId, visible)); | |
| 745 | previewViewManager.setVisibility(tabId, visible), | |
| 746 | ); | |
| 743 | 747 | ipcMain.handle("preview:registerWebview", (_e, tabId: string, wcId: number) => |
| 744 | previewViewManager.registerWebview(tabId, wcId)); | |
| 748 | previewViewManager.registerWebview(tabId, wcId), | |
| 749 | ); | |
| 745 | 750 | ipcMain.handle("preview:unregisterWebview", (_e, tabId: string) => |
| 746 | previewViewManager.unregisterWebview(tabId)); | |
| 751 | previewViewManager.unregisterWebview(tabId), | |
| 752 | ); | |
| 747 | 753 | ipcMain.handle("preview:navigate", (_e, tabId: string, url: string) => |
| 748 | previewViewManager.navigate(tabId, url)); | |
| 754 | previewViewManager.navigate(tabId, url), | |
| 755 | ); | |
| 749 | 756 | ipcMain.handle("preview:goBack", (_e, tabId: string) => previewViewManager.goBack(tabId)); |
| 750 | 757 | ipcMain.handle("preview:goForward", (_e, tabId: string) => previewViewManager.goForward(tabId)); |
| 751 | 758 | ipcMain.handle("preview:refresh", (_e, tabId: string) => previewViewManager.refresh(tabId)); |
| ... | ... | @@ -776,16 +783,13 @@ contextBridge.exposeInMainWorld("desktopBridge", { |
| 776 | 783 | ipcRenderer.invoke("preview:setVisibility", tabId, visible), |
| 777 | 784 | registerWebview: (tabId: string, wcId: number) => |
| 778 | 785 | ipcRenderer.invoke("preview:registerWebview", tabId, wcId), |
| 779 | unregisterWebview: (tabId: string) => | |
| 780 | ipcRenderer.invoke("preview:unregisterWebview", tabId), | |
| 781 | navigate: (tabId: string, url: string) => | |
| 782 | ipcRenderer.invoke("preview:navigate", tabId, url), | |
| 786 | unregisterWebview: (tabId: string) => ipcRenderer.invoke("preview:unregisterWebview", tabId), | |
| 787 | navigate: (tabId: string, url: string) => ipcRenderer.invoke("preview:navigate", tabId, url), | |
| 783 | 788 | goBack: (tabId: string) => ipcRenderer.invoke("preview:goBack", tabId), |
| 784 | 789 | goForward: (tabId: string) => ipcRenderer.invoke("preview:goForward", tabId), |
| 785 | 790 | refresh: (tabId: string) => ipcRenderer.invoke("preview:refresh", tabId), |
| 786 | 791 | getPreloadPath: (): Promise<string> => ipcRenderer.invoke("preview:getPreloadPath"), |
| 787 | getBrowserPartition: (): Promise<string> => | |
| 788 | ipcRenderer.invoke("preview:getBrowserPartition"), | |
| 792 | getBrowserPartition: (): Promise<string> => ipcRenderer.invoke("preview:getBrowserPartition"), | |
| 789 | 793 | onStateChange: (cb: (tabId: string, state: DesktopPreviewTabState) => void) => { |
| 790 | 794 | const listener = (_e: unknown, tabId: string, state: DesktopPreviewTabState) => |
| 791 | 795 | cb(tabId, state); |
| ... | ... | @@ -1096,14 +1100,14 @@ Adding `preview.toggle`, `preview.refresh`, `preview.focusUrl` to `STATIC_KEYBIN |
| 1096 | 1100 | |
| 1097 | 1101 | Following t3code's vitest patterns (`bun run test`, never `bun test`): |
| 1098 | 1102 | |
| 1099 | | Test file | What it covers | | |
| 1100 | |---|---| | |
| 1101 | | `packages/contracts/src/preview.test.ts` | Schema encode/decode round-trips for inputs, snapshot, events; URL trimming; error tagged unions | | |
| 1102 | | `apps/server/src/preview/Layers/Manager.test.ts` | `open` creates session and emits `opened`; `navigate` updates and emits `navigated`; `close` removes and emits `closed`; subscribers receive monotonic events; `list` returns sorted snapshots | | |
| 1103 | | `apps/web/src/previewStateStore.test.ts` | `applyServerEvent` reducer correctness; ring buffer cap; `closed` removes entry; `desktopOverlay` is independent of snapshot fields | | |
| 1104 | | `apps/web/src/rightPanelStore.test.ts` | `open` / `close` / `toggle` semantics; per-thread isolation; `?diff=1` sync compatibility | | |
| 1105 | | `apps/web/src/components/preview/PreviewView.test.ts` (logic-only via `PreviewView.logic.ts` extraction) | URL bar input → navigation; navigation button enabled-state derivation; visibility toggle on panel hide | | |
| 1106 | | Existing `ThreadTerminalDrawer.test.ts` | Add a case: link activation with `kind: "url"` and `previewable: true` shows the context menu (mock `localApi.contextMenu.show`) | | |
| 1103 | | Test file | What it covers | | |
| 1104 | | -------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | |
| 1105 | | `packages/contracts/src/preview.test.ts` | Schema encode/decode round-trips for inputs, snapshot, events; URL trimming; error tagged unions | | |
| 1106 | | `apps/server/src/preview/Layers/Manager.test.ts` | `open` creates session and emits `opened`; `navigate` updates and emits `navigated`; `close` removes and emits `closed`; subscribers receive monotonic events; `list` returns sorted snapshots | | |
| 1107 | | `apps/web/src/previewStateStore.test.ts` | `applyServerEvent` reducer correctness; ring buffer cap; `closed` removes entry; `desktopOverlay` is independent of snapshot fields | | |
| 1108 | | `apps/web/src/rightPanelStore.test.ts` | `open` / `close` / `toggle` semantics; per-thread isolation; `?diff=1` sync compatibility | | |
| 1109 | | `apps/web/src/components/preview/PreviewView.test.ts` (logic-only via `PreviewView.logic.ts` extraction) | URL bar input → navigation; navigation button enabled-state derivation; visibility toggle on panel hide | | |
| 1110 | | Existing `ThreadTerminalDrawer.test.ts` | Add a case: link activation with `kind: "url"` and `previewable: true` shows the context menu (mock `localApi.contextMenu.show`) | | |
| 1107 | 1111 | |
| 1108 | 1112 | Manual smoke checklist (drop in `apps/desktop/test/smoke/`): |
| 1109 | 1113 | |
| ... | ... | @@ -1120,15 +1124,15 @@ Manual smoke checklist (drop in `apps/desktop/test/smoke/`): |
| 1120 | 1124 | |
| 1121 | 1125 | ## 12. Risks and resolutions |
| 1122 | 1126 | |
| 1123 | | Risk | Resolution | | |
| 1124 | |---|---| | |
| 1125 | | Hidden `<webview>` GPU cost when keeping multiple threads' previews mounted | v1 only mounts the active thread's preview. v2 can adopt the `PersistentThreadTerminalDrawer` pattern + `setVisibility` | | |
| 1126 | | `<webview>` keyboard capture eats `mod+shift+j` etc. | `before-input-event` forwarder in `PreviewViewManager` (mirror of ami's pattern, smaller shortcut list) | | |
| 1127 | | URL with `X-Frame-Options: DENY` works fine in `<webview>` (good — that's why we picked `<webview>` over iframe) | n/a | | |
| 1128 | | Server restart while desktop is alive: `<webview>` is still loaded but server has no record | On WS reconnect, web side sends a `preview.list(threadId)` and if empty, sends a `preview.open(...)` to re‑register the current URL. Add a small `useReconciliation` effect in `PreviewView.tsx` | | |
| 1129 | | Renderer process renders something into `<webview>` but never registered → orphaned tab in `PreviewViewManager` | `closeTab` is idempotent; on `<webview>` unmount, `unregisterWebview` is called; if that didn't fire (crash), the next `createTab` for the same `tabId` reuses the record | | |
| 1130 | | `autoOpenPreview` racing with terminal output (URL might not be in stdout yet by the time the script "starts") | Two‑phase: if `script.previewUrl` is set, open eagerly with that URL. If not set but `autoOpenPreview === true`, watch terminal output via existing `terminal-links` extraction and open the first `previewable` URL within a 60s window | | |
| 1131 | | Multiple windows of the desktop both rendering the same `<webview>` for the same thread | `<webview>` is per‑renderer; each window creates its own. The shared `persist:t3code-preview` partition keeps cookies in sync. Server records the last navigation URL but doesn't enforce single‑renderer | | |
| 1127 | | Risk | Resolution | | |
| 1128 | | ---------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | |
| 1129 | | Hidden `<webview>` GPU cost when keeping multiple threads' previews mounted | v1 only mounts the active thread's preview. v2 can adopt the `PersistentThreadTerminalDrawer` pattern + `setVisibility` | | |
| 1130 | | `<webview>` keyboard capture eats `mod+shift+j` etc. | `before-input-event` forwarder in `PreviewViewManager` (mirror of ami's pattern, smaller shortcut list) | | |
| 1131 | | URL with `X-Frame-Options: DENY` works fine in `<webview>` (good — that's why we picked `<webview>` over iframe) | n/a | | |
| 1132 | | Server restart while desktop is alive: `<webview>` is still loaded but server has no record | On WS reconnect, web side sends a `preview.list(threadId)` and if empty, sends a `preview.open(...)` to re‑register the current URL. Add a small `useReconciliation` effect in `PreviewView.tsx` | | |
| 1133 | | Renderer process renders something into `<webview>` but never registered → orphaned tab in `PreviewViewManager` | `closeTab` is idempotent; on `<webview>` unmount, `unregisterWebview` is called; if that didn't fire (crash), the next `createTab` for the same `tabId` reuses the record | | |
| 1134 | | `autoOpenPreview` racing with terminal output (URL might not be in stdout yet by the time the script "starts") | Two‑phase: if `script.previewUrl` is set, open eagerly with that URL. If not set but `autoOpenPreview === true`, watch terminal output via existing `terminal-links` extraction and open the first `previewable` URL within a 60s window | | |
| 1135 | | Multiple windows of the desktop both rendering the same `<webview>` for the same thread | `<webview>` is per‑renderer; each window creates its own. The shared `persist:t3code-preview` partition keeps cookies in sync. Server records the last navigation URL but doesn't enforce single‑renderer | | |
| 1132 | 1136 | |
| 1133 | 1137 | --- |
| 1134 | 1138 | |
| ... | ... | @@ -1155,38 +1159,43 @@ Any of these can land in follow-up PRs against the same `PreviewViewManager` + ` |
| 1155 | 1159 | |
| 1156 | 1160 | Every visible element below is built on existing components. No new design primitives are introduced. References below are to the `apps/web/src/components/ui/` directory. |
| 1157 | 1161 | |
| 1158 | | Need | Primitive | File ref | Notes | | |
| 1159 | |---|---|---|---| | |
| 1160 | | Theme colors / radii | CSS vars `--background`, `--card`, `--muted`, `--muted-foreground`, `--border`, `--input`, `--ring`, `--success`, etc. | `apps/web/src/index.css:86` | Light + `@variant dark` blocks. Always reference vars via tailwind utilities (`bg-card`, `text-muted-foreground`) — never hard‑code hex | | |
| 1161 | | `cn` class merger | `cn(...inputs)` from `~/lib/utils` | `apps/web/src/lib/utils.ts:8` | Wraps `cx` + `tailwind-merge` | | |
| 1162 | | Buttons (chrome bar back/fwd/refresh) | `Button` `variant="ghost"` `size="icon-xs"` | `apps/web/src/components/ui/button.tsx` | `icon-xs` is `size-7 rounded-md sm:size-6` — exactly the density in the screenshot | | |
| 1163 | | Buttons (URL field submit) | `Button` `variant="outline"` `size="sm"` | same | Matches the "ProjectScript primary action" density already in `BranchToolbar` | | |
| 1164 | | Button group (back / fwd / refresh as a unit) | `Group` + implicit segmenting (no `GroupSeparator`) | `apps/web/src/components/ui/group.tsx` | Group automatically removes outer borders between adjacent `[data-slot]` children | | |
| 1165 | | URL input (chrome bar editable) | `InputGroup` + `InputGroupInput` + `InputGroupAddon align="inline-start"` (globe icon) | `apps/web/src/components/ui/input-group.tsx` | Click‑anywhere‑to‑focus already wired in `InputGroupAddon`'s `onMouseDown` | | |
| 1166 | | URL input (chrome bar disabled / read‑only) | Same `InputGroup` with `disabled` on the input | same | Yields the muted look in the screenshot via `has-[input:disabled]:opacity-64` | | |
| 1167 | | Tab strip cells | Plain `<button>` + `cn` — **not** `Tabs` (base‑ui Tabs is over‑featured for browser tabs); `Group` semantics differ | new file `PreviewTabStrip.tsx` | Same hover/active treatment as the sidebar's `ChatPreview` rows | | |
| 1168 | | "Local" recommendation card | `Card` (no `CardHeader`/`CardPanel` — flat) | `apps/web/src/components/ui/card.tsx` | `Card` already gives `rounded-2xl border bg-card text-card-foreground shadow-xs/5` plus the inset stroke via `before:` | | |
| 1169 | | Status pulse dot (running) | `<span>` with `bg-success` and `animate-pulse` | tailwind built‑in | Theme‑aware via `--success` | | |
| 1170 | | Tooltips (back/fwd/refresh hover) | `Tooltip` + `TooltipTrigger` + `TooltipPopup` | `apps/web/src/components/ui/tooltip.tsx` | Sub‑200ms hover already configured | | |
| 1171 | | Empty state title + description | `Empty`, `EmptyHeader`, `EmptyTitle`, `EmptyDescription`, `EmptyContent`, `EmptyMedia` | `apps/web/src/components/ui/empty.tsx` | `EmptyMedia variant="icon"` gives the stacked‑cards icon with side rotation we want for "no preview yet" | | |
| 1172 | | Loading bar (top of webview while navigating) | Plain `<div>` with `bg-primary` + `transition-all` | n/a | Mirrors ami's loading bar (`browser-view.tsx:434`) — single‑pixel‑height div animating `width` | | |
| 1173 | | Spinner (inline in URL field while loading) | `Spinner` | `apps/web/src/components/ui/spinner.tsx` | Wraps `Loader2Icon` from lucide | | |
| 1174 | | Context menu (terminal link "Open in preview") | `localApi.contextMenu.show(...)` (already exists) | `apps/web/src/localApi.ts:58` | Native menu on desktop, fallback in `contextMenuFallback.ts` on web | | |
| 1175 | | Keybinding shortcut label in tooltips | `shortcutLabelForCommand(keybindings, "preview.toggle")` | `apps/web/src/keybindings.ts` | Renders `⌘⇧J` etc. | | |
| 1176 | | Right‑panel shell sizing | Match `DiffPanelShell` exactly: `w-[42vw] min-w-[360px] max-w-[560px] shrink-0 border-l border-border` | `apps/web/src/components/DiffPanelShell.tsx:32` | Single class string, do not reinvent | | |
| 1162 | | Need | Primitive | File ref | Notes | | |
| 1163 | | ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | | |
| 1164 | | Theme colors / radii | CSS vars `--background`, `--card`, `--muted`, `--muted-foreground`, `--border`, `--input`, `--ring`, `--success`, etc. | `apps/web/src/index.css:86` | Light + `@variant dark` blocks. Always reference vars via tailwind utilities (`bg-card`, `text-muted-foreground`) — never hard‑code hex | | |
| 1165 | | `cn` class merger | `cn(...inputs)` from `~/lib/utils` | `apps/web/src/lib/utils.ts:8` | Wraps `cx` + `tailwind-merge` | | |
| 1166 | | Buttons (chrome bar back/fwd/refresh) | `Button` `variant="ghost"` `size="icon-xs"` | `apps/web/src/components/ui/button.tsx` | `icon-xs` is `size-7 rounded-md sm:size-6` — exactly the density in the screenshot | | |
| 1167 | | Buttons (URL field submit) | `Button` `variant="outline"` `size="sm"` | same | Matches the "ProjectScript primary action" density already in `BranchToolbar` | | |
| 1168 | | Button group (back / fwd / refresh as a unit) | `Group` + implicit segmenting (no `GroupSeparator`) | `apps/web/src/components/ui/group.tsx` | Group automatically removes outer borders between adjacent `[data-slot]` children | | |
| 1169 | | URL input (chrome bar editable) | `InputGroup` + `InputGroupInput` + `InputGroupAddon align="inline-start"` (globe icon) | `apps/web/src/components/ui/input-group.tsx` | Click‑anywhere‑to‑focus already wired in `InputGroupAddon`'s `onMouseDown` | | |
| 1170 | | URL input (chrome bar disabled / read‑only) | Same `InputGroup` with `disabled` on the input | same | Yields the muted look in the screenshot via `has-[input:disabled]:opacity-64` | | |
| 1171 | | Tab strip cells | Plain `<button>` + `cn` — **not** `Tabs` (base‑ui Tabs is over‑featured for browser tabs); `Group` semantics differ | new file `PreviewTabStrip.tsx` | Same hover/active treatment as the sidebar's `ChatPreview` rows | | |
| 1172 | | "Local" recommendation card | `Card` (no `CardHeader`/`CardPanel` — flat) | `apps/web/src/components/ui/card.tsx` | `Card` already gives `rounded-2xl border bg-card text-card-foreground shadow-xs/5` plus the inset stroke via `before:` | | |
| 1173 | | Status pulse dot (running) | `<span>` with `bg-success` and `animate-pulse` | tailwind built‑in | Theme‑aware via `--success` | | |
| 1174 | | Tooltips (back/fwd/refresh hover) | `Tooltip` + `TooltipTrigger` + `TooltipPopup` | `apps/web/src/components/ui/tooltip.tsx` | Sub‑200ms hover already configured | | |
| 1175 | | Empty state title + description | `Empty`, `EmptyHeader`, `EmptyTitle`, `EmptyDescription`, `EmptyContent`, `EmptyMedia` | `apps/web/src/components/ui/empty.tsx` | `EmptyMedia variant="icon"` gives the stacked‑cards icon with side rotation we want for "no preview yet" | | |
| 1176 | | Loading bar (top of webview while navigating) | Plain `<div>` with `bg-primary` + `transition-all` | n/a | Mirrors ami's loading bar (`browser-view.tsx:434`) — single‑pixel‑height div animating `width` | | |
| 1177 | | Spinner (inline in URL field while loading) | `Spinner` | `apps/web/src/components/ui/spinner.tsx` | Wraps `Loader2Icon` from lucide | | |
| 1178 | | Context menu (terminal link "Open in preview") | `localApi.contextMenu.show(...)` (already exists) | `apps/web/src/localApi.ts:58` | Native menu on desktop, fallback in `contextMenuFallback.ts` on web | | |
| 1179 | | Keybinding shortcut label in tooltips | `shortcutLabelForCommand(keybindings, "preview.toggle")` | `apps/web/src/keybindings.ts` | Renders `⌘⇧J` etc. | | |
| 1180 | | Right‑panel shell sizing | Match `DiffPanelShell` exactly: `w-[42vw] min-w-[360px] max-w-[560px] shrink-0 border-l border-border` | `apps/web/src/components/DiffPanelShell.tsx:32` | Single class string, do not reinvent | | |
| 1177 | 1181 | |
| 1178 | 1182 | **Icons.** Use `lucide-react` everywhere (already in `package.json`). The exact icons we need: |
| 1179 | 1183 | |
| 1180 | 1184 | ```ts |
| 1181 | 1185 | import { |
| 1182 | ArrowLeft, ArrowRight, // back / forward | |
| 1183 | RotateCw, Loader2, // refresh / spinning | |
| 1184 | Globe, // favicon fallback | |
| 1185 | X, // close tab | |
| 1186 | Plus, // new tab | |
| 1187 | Copy, ExternalLink, // open in browser, copy URL | |
| 1188 | Sidebar, Columns2, // right‑side toggles in tab strip (matches screenshot 2) | |
| 1189 | PanelRightOpen, PanelRightClose, // alt for above | |
| 1186 | ArrowLeft, | |
| 1187 | ArrowRight, // back / forward | |
| 1188 | RotateCw, | |
| 1189 | Loader2, // refresh / spinning | |
| 1190 | Globe, // favicon fallback | |
| 1191 | X, // close tab | |
| 1192 | Plus, // new tab | |
| 1193 | Copy, | |
| 1194 | ExternalLink, // open in browser, copy URL | |
| 1195 | Sidebar, | |
| 1196 | Columns2, // right‑side toggles in tab strip (matches screenshot 2) | |
| 1197 | PanelRightOpen, | |
| 1198 | PanelRightClose, // alt for above | |
| 1190 | 1199 | } from "lucide-react"; |
| 1191 | 1200 | ``` |
| 1192 | 1201 | |
| ... | ... | @@ -1200,11 +1209,11 @@ import { |
| 1200 | 1209 | |
| 1201 | 1210 | Three states render inside `PreviewView.tsx` based on the `PreviewSession` for the active thread: |
| 1202 | 1211 | |
| 1203 | | Session state | Component rendered | | |
| 1204 | |---|---| | |
| 1205 | | no tab open | `<PreviewEmptyState />` — disabled chrome row + "Local" recommendations | | |
| 1206 | | tab loading / loaded | `<PreviewWebview />` + chrome row + tab strip | | |
| 1207 | | tab navigation failed | `<PreviewUnreachable />` overlay above the webview | | |
| 1212 | | Session state | Component rendered | | |
| 1213 | | --------------------- | ----------------------------------------------------------------------- | | |
| 1214 | | no tab open | `<PreviewEmptyState />` — disabled chrome row + "Local" recommendations | | |
| 1215 | | tab loading / loaded | `<PreviewWebview />` + chrome row + tab strip | | |
| 1216 | | tab navigation failed | `<PreviewUnreachable />` overlay above the webview | | |
| 1208 | 1217 | |
| 1209 | 1218 | ### 15.1 Empty state (`PreviewEmptyState.tsx`) — matches screenshot 1 |
| 1210 | 1219 | |
| ... | ... | @@ -1259,9 +1268,7 @@ export function PreviewEmptyState({ threadId, onOpen }: Props) { |
| 1259 | 1268 | return ( |
| 1260 | 1269 | <div className="flex h-full min-h-0 flex-col"> |
| 1261 | 1270 | <div className="px-6 pt-10 pb-4"> |
| 1262 | <h2 className="text-xs font-medium uppercase tracking-wide text-muted-foreground"> | |
| 1263 | Local | |
| 1264 | </h2> | |
| 1271 | <h2 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Local</h2> | |
| 1265 | 1272 | </div> |
| 1266 | 1273 | |
| 1267 | 1274 | {servers.length === 0 ? ( |
| ... | ... | @@ -1308,10 +1315,7 @@ function LocalServerCard({ |
| 1308 | 1315 | {server.processName ?? `${server.host}:${server.port}`} |
| 1309 | 1316 | </span> |
| 1310 | 1317 | </div> |
| 1311 | <span | |
| 1312 | className="size-2 shrink-0 rounded-full bg-success" | |
| 1313 | aria-label="Listening" | |
| 1314 | > | |
| 1318 | <span className="size-2 shrink-0 rounded-full bg-success" aria-label="Listening"> | |
| 1315 | 1319 | <span className="block size-2 rounded-full bg-success animate-ping opacity-60" /> |
| 1316 | 1320 | </span> |
| 1317 | 1321 | </Card> |
| ... | ... | @@ -1362,8 +1366,8 @@ The chrome row in the empty state — note the `disabled` props on every interac |
| 1362 | 1366 | onForward={NOOP} |
| 1363 | 1367 | onRefresh={NOOP} |
| 1364 | 1368 | onSubmitUrl={(url) => api.preview.open({ threadId, url })} |
| 1365 | inputDisabled={false} // we still want to allow paste-to-open | |
| 1366 | buttonsDisabled // back/fwd/refresh are visually disabled | |
| 1369 | inputDisabled={false} // we still want to allow paste-to-open | |
| 1370 | buttonsDisabled // back/fwd/refresh are visually disabled | |
| 1367 | 1371 | /> |
| 1368 | 1372 | ``` |
| 1369 | 1373 | |
| ... | ... | @@ -1568,17 +1572,17 @@ When `navStatus._tag === "LoadFailed"`, render this instead of the failed `<webv |
| 1568 | 1572 | |
| 1569 | 1573 | Color mapping (Chromium → t3code theme): |
| 1570 | 1574 | |
| 1571 | | Chromium | Tailwind/t3code | | |
| 1572 | |---|---| | |
| 1573 | | `--background-color: #fff` / `--google-gray-900` (dark) | `bg-background` | | |
| 1574 | | `--text-color: --google-gray-700` / `--google-gray-500` (dark) | `text-muted-foreground` | | |
| 1575 | | `--heading-color: --google-gray-900` / `--google-gray-500` (dark) | `text-foreground` | | |
| 1576 | | `--error-code-color: --google-gray-700` / `--google-gray-500` (dark) | `text-muted-foreground/70` | | |
| 1577 | | `--quiet-background-color: rgb(247,247,247)` / `--background` (dark) | `bg-muted/40` | | |
| 1578 | | `--primary-button-fill-color: --google-blue-600` / `--google-blue-300` (dark) | `bg-primary text-primary-foreground` (theme primary is `oklch(0.488 0.217 264)` light / `oklch(0.588 0.217 264)` dark — a very close blue) | | |
| 1579 | | `--secondary-button-*` | `Button variant="outline"` | | |
| 1580 | | `--link-color: rgb(88,88,88)` / `--google-blue-300` (dark) | `text-primary underline-offset-4 hover:underline` | | |
| 1581 | | Body font `system-ui, sans-serif; font-size: 75%` | We keep DM Sans (already in `apps/web/src/index.css:148`) and `text-sm` — the Chromium font tweak just compensates for their `html { font-size: 125% }` — we don't replicate that | | |
| 1575 | | Chromium | Tailwind/t3code | | |
| 1576 | | ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | |
| 1577 | | `--background-color: #fff` / `--google-gray-900` (dark) | `bg-background` | | |
| 1578 | | `--text-color: --google-gray-700` / `--google-gray-500` (dark) | `text-muted-foreground` | | |
| 1579 | | `--heading-color: --google-gray-900` / `--google-gray-500` (dark) | `text-foreground` | | |
| 1580 | | `--error-code-color: --google-gray-700` / `--google-gray-500` (dark) | `text-muted-foreground/70` | | |
| 1581 | | `--quiet-background-color: rgb(247,247,247)` / `--background` (dark) | `bg-muted/40` | | |
| 1582 | | `--primary-button-fill-color: --google-blue-600` / `--google-blue-300` (dark) | `bg-primary text-primary-foreground` (theme primary is `oklch(0.488 0.217 264)` light / `oklch(0.588 0.217 264)` dark — a very close blue) | | |
| 1583 | | `--secondary-button-*` | `Button variant="outline"` | | |
| 1584 | | `--link-color: rgb(88,88,88)` / `--google-blue-300` (dark) | `text-primary underline-offset-4 hover:underline` | | |
| 1585 | | Body font `system-ui, sans-serif; font-size: 75%` | We keep DM Sans (already in `apps/web/src/index.css:148`) and `text-sm` — the Chromium font tweak just compensates for their `html { font-size: 125% }` — we don't replicate that | | |
| 1582 | 1586 | |
| 1583 | 1587 | Skeleton: |
| 1584 | 1588 | |
| ... | ... | @@ -1592,7 +1596,7 @@ import { cn } from "~/lib/utils"; |
| 1592 | 1596 | |
| 1593 | 1597 | interface Props { |
| 1594 | 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 | 1600 | description: string; |
| 1597 | 1601 | onReload: () => void; |
| 1598 | 1602 | } |
| ... | ... | @@ -1601,7 +1605,13 @@ const ICON_GENERIC = ( |
| 1601 | 1605 | // Replace with an inline SVG that matches the Chromium "icon-generic" |
| 1602 | 1606 | // (a stylized broken page). For v1 a Lucide MapPinOff is a fine stand-in — |
| 1603 | 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 | 1615 | <path d="M16 12 L48 12 L48 52 L16 52 Z" /> |
| 1606 | 1616 | <path d="M22 22 L42 22 M22 30 L36 30 M22 38 L40 38" strokeLinecap="round" /> |
| 1607 | 1617 | <path d="M52 8 L12 56" strokeLinecap="round" /> |
| ... | ... | @@ -1626,7 +1636,13 @@ export function PreviewUnreachable({ url, errorCode, description, onReload }: Pr |
| 1626 | 1636 | {/* Summary — uses dangerouslySetInnerHTML only for the bold host treatment */} |
| 1627 | 1637 | <p className="text-sm leading-relaxed text-muted-foreground"> |
| 1628 | 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 | 1646 | could not be found. |
| 1631 | 1647 | </p> |
| 1632 | 1648 | |
| ... | ... | @@ -1668,11 +1684,16 @@ export function PreviewUnreachable({ url, errorCode, description, onReload }: Pr |
| 1668 | 1684 | } |
| 1669 | 1685 | |
| 1670 | 1686 | function 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 | ``` |
| 1674 | 1694 | |
| 1675 | 1695 | The component intentionally: |
| 1696 | ||
| 1676 | 1697 | - Uses theme tokens only — looks correct in light + dark with no extra wiring. |
| 1677 | 1698 | - Drops Chromium's "Diagnose connection" / "Portal sign-in" buttons (irrelevant in our shell). |
| 1678 | 1699 | - Drops the dinosaur game (RIP). |
| ... | ... | @@ -1683,15 +1704,17 @@ The component intentionally: |
| 1683 | 1704 | A 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`): |
| 1684 | 1705 | |
| 1685 | 1706 | ```tsx |
| 1686 | {loadProgress > 0 && ( | |
| 1687 | <div | |
| 1688 | className="pointer-events-none absolute bottom-0 left-0 z-10 h-0.5 rounded-full bg-primary transition-all duration-150 ease-out" | |
| 1689 | style={{ | |
| 1690 | width: `${loadProgress}%`, | |
| 1691 | boxShadow: "0 0 6px 1px var(--ring)", | |
| 1692 | }} | |
| 1693 | /> | |
| 1694 | )} | |
| 1707 | { | |
| 1708 | loadProgress > 0 && ( | |
| 1709 | <div | |
| 1710 | className="pointer-events-none absolute bottom-0 left-0 z-10 h-0.5 rounded-full bg-primary transition-all duration-150 ease-out" | |
| 1711 | style={{ | |
| 1712 | width: `${loadProgress}%`, | |
| 1713 | boxShadow: "0 0 6px 1px var(--ring)", | |
| 1714 | }} | |
| 1715 | /> | |
| 1716 | ); | |
| 1717 | } | |
| 1695 | 1718 | ``` |
| 1696 | 1719 | |
| 1697 | 1720 | Progress 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 | 1731 | import { Context, Effect } from "effect"; |
| 1709 | 1732 | |
| 1710 | 1733 | export interface DiscoveredLocalServer { |
| 1711 | host: string; // "localhost" | |
| 1712 | port: number; // 5175 | |
| 1713 | url: string; // "http://localhost:5175" | |
| 1734 | host: string; // "localhost" | |
| 1735 | port: number; // 5175 | |
| 1736 | url: string; // "http://localhost:5175" | |
| 1714 | 1737 | processName: string | null; // "node", "vite", "next-server" |
| 1715 | 1738 | pid: number | null; |
| 1716 | 1739 | } |
| ... | ... | @@ -1740,6 +1763,7 @@ Two strategies, picked at startup: |
| 1740 | 1763 | 2. **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. |
| 1741 | 1764 | |
| 1742 | 1765 | Polling cadence: |
| 1766 | ||
| 1743 | 1767 | - When `retain()` count is 0 → not polling. |
| 1744 | 1768 | - When ≥1 → poll every 3s. Diff against last result; only emit `subscribe` callbacks when the set differs. |
| 1745 | 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 | 1786 | |
| 1763 | 1787 | Append these to the file list: |
| 1764 | 1788 | |
| 1765 | | File | Purpose | | |
| 1766 | |---|---| | |
| 1767 | | `apps/server/src/preview/Services/PortScanner.ts` | Service tag + interface | | |
| 1768 | | `apps/server/src/preview/Layers/PortScanner.ts` | `lsof` strategy + TCP probe fallback + polling | | |
| 1769 | | `apps/server/src/preview/Layers/PortScanner.test.ts` | Parser tests for `lsof -F pcn` output | | |
| 1770 | | `apps/web/src/lib/favicon.ts` | `faviconUrlForOrigin(url, size)` helper | | |
| 1771 | | `apps/web/src/components/preview/PreviewTabStrip.tsx` | Tab strip (screenshot 2) | | |
| 1772 | | `apps/web/src/components/preview/TabFavicon.tsx` | Favicon `<img>` w/ `<Globe>` fallback | | |
| 1773 | | `apps/web/src/components/preview/BrowserMockup.tsx` | Tiny tailwind browser thumbnail icon | | |
| 1774 | | `apps/web/src/components/preview/PreviewLocalServerCard.tsx` | Card row for a discovered server | | |
| 1775 | | `apps/web/src/components/preview/PreviewUnreachable.tsx` | 404.html rewritten in tailwind | | |
| 1789 | | File | Purpose | | |
| 1790 | | -------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | | |
| 1791 | | `apps/server/src/preview/Services/PortScanner.ts` | Service tag + interface | | |
| 1792 | | `apps/server/src/preview/Layers/PortScanner.ts` | `lsof` strategy + TCP probe fallback + polling | | |
| 1793 | | `apps/server/src/preview/Layers/PortScanner.test.ts` | Parser tests for `lsof -F pcn` output | | |
| 1794 | | `apps/web/src/lib/favicon.ts` | `faviconUrlForOrigin(url, size)` helper | | |
| 1795 | | `apps/web/src/components/preview/PreviewTabStrip.tsx` | Tab strip (screenshot 2) | | |
| 1796 | | `apps/web/src/components/preview/TabFavicon.tsx` | Favicon `<img>` w/ `<Globe>` fallback | | |
| 1797 | | `apps/web/src/components/preview/BrowserMockup.tsx` | Tiny tailwind browser thumbnail icon | | |
| 1798 | | `apps/web/src/components/preview/PreviewLocalServerCard.tsx` | Card row for a discovered server | | |
| 1799 | | `apps/web/src/components/preview/PreviewUnreachable.tsx` | 404.html rewritten in tailwind | | |
| 1776 | 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) | | |
| 1778 | | `apps/web/src/components/preview/errorCodeMessages.ts` | `ERR_*` → human-readable description map | | |
| 1801 | | `apps/web/src/components/preview/useLoadingProgress.ts` | 30-line progress simulator (port of ami's) | | |
| 1802 | | `apps/web/src/components/preview/errorCodeMessages.ts` | `ERR_*` → human-readable description map | | |
| 1779 | 1803 | |
| 1780 | 1804 | Update the contract additions in §4 to add: |
| 1781 | 1805 |
apps/desktop/src/ipc/DesktopIpcHandlers.ts+4| ... | ... | @@ -48,6 +48,7 @@ import { |
| 48 | 48 | setTheme, |
| 49 | 49 | showContextMenu, |
| 50 | 50 | } from "./methods/window.ts"; |
| 51 | import { previewMethods } from "./methods/preview.ts"; | |
| 51 | 52 | |
| 52 | 53 | export const installDesktopIpcHandlers = Effect.gen(function* () { |
| 53 | 54 | const ipc = yield* DesktopIpc.DesktopIpc; |
| ... | ... | @@ -92,4 +93,7 @@ export const installDesktopIpcHandlers = Effect.gen(function* () { |
| 92 | 93 | yield* ipc.handle(downloadUpdate); |
| 93 | 94 | yield* ipc.handle(installUpdate); |
| 94 | 95 | yield* ipc.handle(checkForUpdate); |
| 96 | for (const previewMethod of previewMethods) { | |
| 97 | yield* ipc.handle(previewMethod); | |
| 98 | } | |
| 95 | 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 | 39 | export const SET_TAILSCALE_SERVE_ENABLED_CHANNEL = "desktop:set-tailscale-serve-enabled"; |
| 40 | 40 | export const GET_ADVERTISED_ENDPOINTS_CHANNEL = "desktop:get-advertised-endpoints"; |
| 41 | 41 | export const SSH_PASSWORD_PROMPT_CANCELLED_RESULT = "ssh-password-prompt-cancelled"; |
| 42 | export const PREVIEW_CREATE_TAB_CHANNEL = "desktop:preview-create-tab"; | |
| 43 | export const PREVIEW_CLOSE_TAB_CHANNEL = "desktop:preview-close-tab"; | |
| 44 | export const PREVIEW_REGISTER_WEBVIEW_CHANNEL = "desktop:preview-register-webview"; | |
| 45 | export const PREVIEW_NAVIGATE_CHANNEL = "desktop:preview-navigate"; | |
| 46 | export const PREVIEW_GO_BACK_CHANNEL = "desktop:preview-go-back"; | |
| 47 | export const PREVIEW_GO_FORWARD_CHANNEL = "desktop:preview-go-forward"; | |
| 48 | export const PREVIEW_REFRESH_CHANNEL = "desktop:preview-refresh"; | |
| 49 | export const PREVIEW_ZOOM_IN_CHANNEL = "desktop:preview-zoom-in"; | |
| 50 | export const PREVIEW_ZOOM_OUT_CHANNEL = "desktop:preview-zoom-out"; | |
| 51 | export const PREVIEW_RESET_ZOOM_CHANNEL = "desktop:preview-reset-zoom"; | |
| 52 | export const PREVIEW_HARD_RELOAD_CHANNEL = "desktop:preview-hard-reload"; | |
| 53 | export const PREVIEW_OPEN_DEVTOOLS_CHANNEL = "desktop:preview-open-devtools"; | |
| 54 | export const PREVIEW_CLEAR_COOKIES_CHANNEL = "desktop:preview-clear-cookies"; | |
| 55 | export const PREVIEW_CLEAR_CACHE_CHANNEL = "desktop:preview-clear-cache"; | |
| 56 | export const PREVIEW_GET_BROWSER_PARTITION_CHANNEL = "desktop:preview-get-browser-partition"; | |
| 57 | export const PREVIEW_STATE_CHANGE_CHANNEL = "desktop:preview-state-change"; |
apps/desktop/src/ipc/methods/preview.ts created+61| ... | ... | @@ -0,0 +1,61 @@ |
| 1 | import * as Effect from "effect/Effect"; | |
| 2 | import { BrowserWindow } from "electron"; | |
| 3 | ||
| 4 | import { previewViewManager } from "../../preview-view-manager.ts"; | |
| 5 | import * as IpcChannels from "../channels.ts"; | |
| 6 | import type { DesktopIpcMethod } from "../DesktopIpc.ts"; | |
| 7 | ||
| 8 | previewViewManager.getBrowserSession(); | |
| 9 | previewViewManager.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 | ||
| 17 | const 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 | ||
| 28 | const 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 | ||
| 33 | export 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 | import type { DesktopBridge } from "@t3tools/contracts"; | |
| 1 | import type { DesktopBridge, DesktopPreviewTabState } from "@t3tools/contracts"; | |
| 2 | 2 | import { contextBridge, ipcRenderer } from "electron"; |
| 3 | 3 | |
| 4 | 4 | import * as IpcChannels from "./ipc/channels.ts"; |
| ... | ... | @@ -141,4 +141,37 @@ contextBridge.exposeInMainWorld("desktopBridge", { |
| 141 | 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 | 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 | */ | |
| 8 | import { normalizePreviewUrl } from "@t3tools/shared/preview"; | |
| 9 | import { type BrowserWindow, type Session, session, webContents } from "electron"; | |
| 10 | ||
| 11 | const PREVIEW_PARTITION = "persist:t3code-preview"; | |
| 12 | ||
| 13 | export 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 | ||
| 25 | export 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. */ | |
| 36 | const 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 | ||
| 40 | const DEFAULT_ZOOM_FACTOR = 1.0; | |
| 41 | const ZOOM_EPSILON = 0.001; | |
| 42 | ||
| 43 | const 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 | ||
| 51 | const 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 | ||
| 59 | type Listener = (tabId: string, state: PreviewTabState) => void; | |
| 60 | ||
| 61 | interface ManagedListeners { | |
| 62 | navigate: () => void; | |
| 63 | failed: (event: Event, code: number, description: string) => void; | |
| 64 | } | |
| 65 | ||
| 66 | const 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 | ||
| 82 | export 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 | ||
| 426 | export 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 | ||
| 435 | export 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 | ||
| 446 | export 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 | ||
| 455 | export const previewViewManager = new PreviewViewManager(); |
apps/desktop/src/window/DesktopWindow.ts+4| ... | ... | @@ -11,6 +11,7 @@ import * as DesktopAssets from "../app/DesktopAssets.ts"; |
| 11 | 11 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; |
| 12 | 12 | import * as DesktopObservability from "../app/DesktopObservability.ts"; |
| 13 | 13 | import * as DesktopState from "../app/DesktopState.ts"; |
| 14 | import { previewViewManager } from "../preview-view-manager.ts"; | |
| 14 | 15 | import * as ElectronMenu from "../electron/ElectronMenu.ts"; |
| 15 | 16 | import * as ElectronShell from "../electron/ElectronShell.ts"; |
| 16 | 17 | import * as ElectronTheme from "../electron/ElectronTheme.ts"; |
| ... | ... | @@ -192,9 +193,12 @@ const make = Effect.gen(function* () { |
| 192 | 193 | contextIsolation: true, |
| 193 | 194 | nodeIntegration: false, |
| 194 | 195 | sandbox: true, |
| 196 | webviewTag: true, | |
| 195 | 197 | }, |
| 196 | 198 | }); |
| 197 | 199 | |
| 200 | previewViewManager.setMainWindow(window); | |
| 201 | ||
| 198 | 202 | window.webContents.on("context-menu", (event, params) => { |
| 199 | 203 | event.preventDefault(); |
| 200 | 204 |
apps/server/src/preview/Layers/Manager.test.ts created+261| ... | ... | @@ -0,0 +1,261 @@ |
| 1 | import { it } from "@effect/vitest"; | |
| 2 | import { type PreviewEvent, ThreadId } from "@t3tools/contracts"; | |
| 3 | import { Effect, PubSub } from "effect"; | |
| 4 | import { expect } from "vitest"; | |
| 5 | ||
| 6 | import { PreviewManager } from "../Services/Manager.ts"; | |
| 7 | import { PreviewManagerLive } from "./Manager.ts"; | |
| 8 | ||
| 9 | const DRAIN_LIMIT = 100; | |
| 10 | ||
| 11 | interface 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 | */ | |
| 21 | let nextThreadId = 0; | |
| 22 | const 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 | */ | |
| 29 | const 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 | ||
| 38 | it.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 | */ | |
| 12 | import { | |
| 13 | type PreviewEvent, | |
| 14 | PreviewInvalidUrlError, | |
| 15 | type PreviewListResult, | |
| 16 | PreviewSessionLookupError, | |
| 17 | type PreviewSessionSnapshot, | |
| 18 | } from "@t3tools/contracts"; | |
| 19 | import { | |
| 20 | newPreviewTabId, | |
| 21 | normalizePreviewUrl, | |
| 22 | PreviewUrlNormalizationError, | |
| 23 | } from "@t3tools/shared/preview"; | |
| 24 | import { Effect, Layer, PubSub, Stream, SynchronizedRef } from "effect"; | |
| 25 | ||
| 26 | import { PreviewManager, type PreviewManagerShape } from "../Services/Manager.ts"; | |
| 27 | ||
| 28 | interface PreviewSessionState { | |
| 29 | readonly threadId: string; | |
| 30 | readonly tabId: string; | |
| 31 | readonly snapshot: PreviewSessionSnapshot; | |
| 32 | } | |
| 33 | ||
| 34 | interface ManagerState { | |
| 35 | /** All sessions across every thread, keyed by `${threadId}\u0000${tabId}`. */ | |
| 36 | readonly sessions: ReadonlyMap<string, PreviewSessionState>; | |
| 37 | } | |
| 38 | ||
| 39 | const initialState: ManagerState = { sessions: new Map() }; | |
| 40 | ||
| 41 | const compositeKey = (threadId: string, tabId: string): string => `${threadId}\u0000${tabId}`; | |
| 42 | ||
| 43 | const 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 | ||
| 54 | const 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 | ||
| 69 | const 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 | ||
| 83 | const 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 | ||
| 95 | export 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 | ||
| 313 | export const PreviewManagerLive = Layer.effect(PreviewManager, makePreviewManager); |
apps/server/src/preview/Layers/PortScanner.test.ts created+180| ... | ... | @@ -0,0 +1,180 @@ |
| 1 | import * as net from "node:net"; | |
| 2 | ||
| 3 | import { Effect } from "effect"; | |
| 4 | import { afterEach, beforeEach, describe, expect, it } from "vitest"; | |
| 5 | ||
| 6 | import { PreviewPortScanner } from "../Services/PortScanner.ts"; | |
| 7 | import { __testing, PreviewPortScannerLive } from "./PortScanner.ts"; | |
| 8 | ||
| 9 | const { parseLsofOutput, parsePortFromLsofName, serversEqual } = __testing; | |
| 10 | ||
| 11 | describe("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 | ||
| 44 | describe("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 | ||
| 97 | describe("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 | */ | |
| 121 | describe("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 | */ | |
| 14 | import * as net from "node:net"; | |
| 15 | ||
| 16 | import type { DiscoveredLocalServer } from "@t3tools/contracts"; | |
| 17 | import { LSOF_LOCAL_HOST_TOKENS } from "@t3tools/shared/preview"; | |
| 18 | import { Cause, Data, Duration, Effect, Layer, Ref, Schedule } from "effect"; | |
| 19 | ||
| 20 | import { runProcess } from "../../processRunner.ts"; | |
| 21 | import { | |
| 22 | COMMON_DEV_PORTS, | |
| 23 | PreviewPortScanner, | |
| 24 | type PreviewPortScannerShape, | |
| 25 | } from "../Services/PortScanner.ts"; | |
| 26 | ||
| 27 | const POLL_INTERVAL = Duration.seconds(3); | |
| 28 | const TCP_PROBE_TIMEOUT_MS = 200; | |
| 29 | const LSOF_TIMEOUT_MS = 5_000; | |
| 30 | ||
| 31 | type Listener = (servers: ReadonlyArray<DiscoveredLocalServer>) => Effect.Effect<void>; | |
| 32 | ||
| 33 | class LsofProbeError extends Data.TaggedError("LsofProbeError")<{ | |
| 34 | readonly cause: unknown; | |
| 35 | }> {} | |
| 36 | ||
| 37 | interface ScannerState { | |
| 38 | readonly lastSnapshot: ReadonlyArray<DiscoveredLocalServer>; | |
| 39 | } | |
| 40 | ||
| 41 | const 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 | ||
| 79 | const 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 | ||
| 94 | const 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 | ||
| 109 | const 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 | ||
| 126 | const 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 | ||
| 142 | const 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 | ||
| 164 | export 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 | ||
| 236 | export const PreviewPortScannerLive = Layer.effect(PreviewPortScanner, makePreviewPortScanner); | |
| 237 | ||
| 238 | /** Exposed for tests. */ | |
| 239 | export 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 | */ | |
| 17 | import { | |
| 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"; | |
| 29 | import { Context, type Effect, type PubSub, type Scope, type Stream } from "effect"; | |
| 30 | ||
| 31 | export 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 | ||
| 92 | export 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 | */ | |
| 11 | import type { DiscoveredLocalServer } from "@t3tools/contracts"; | |
| 12 | import { Context, type Effect } from "effect"; | |
| 13 | ||
| 14 | export 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 | ||
| 28 | export 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. */ | |
| 34 | export 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 | 20 | type OrchestrationCommand, |
| 21 | 21 | type OrchestrationEvent, |
| 22 | 22 | ORCHESTRATION_WS_METHODS, |
| 23 | type PreviewEvent, | |
| 23 | 24 | ProjectId, |
| 24 | 25 | ProviderDriverKind, |
| 25 | 26 | ProviderInstanceId, |
| ... | ... | @@ -48,6 +49,7 @@ import * as Layer from "effect/Layer"; |
| 48 | 49 | import * as ManagedRuntime from "effect/ManagedRuntime"; |
| 49 | 50 | import * as Option from "effect/Option"; |
| 50 | 51 | import * as Path from "effect/Path"; |
| 52 | import * as PubSub from "effect/PubSub"; | |
| 51 | 53 | import * as Stream from "effect/Stream"; |
| 52 | 54 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 53 | 55 | import { |
| ... | ... | @@ -97,6 +99,8 @@ import { ServerLifecycleEvents, type ServerLifecycleEventsShape } from "./server |
| 97 | 99 | import { ServerRuntimeStartup, type ServerRuntimeStartupShape } from "./serverRuntimeStartup.ts"; |
| 98 | 100 | import { ServerSettingsService, type ServerSettingsShape } from "./serverSettings.ts"; |
| 99 | 101 | import { TerminalManager, type TerminalManagerShape } from "./terminal/Services/Manager.ts"; |
| 102 | import { PreviewManager } from "./preview/Services/Manager.ts"; | |
| 103 | import { PreviewPortScanner } from "./preview/Services/PortScanner.ts"; | |
| 100 | 104 | import { |
| 101 | 105 | BrowserTraceCollector, |
| 102 | 106 | type BrowserTraceCollectorShape, |
| ... | ... | @@ -663,6 +667,27 @@ const buildAppUnderTest = (options?: { |
| 663 | 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 | 691 | Layer.provide( |
| 667 | 692 | Layer.mock(OrchestrationEngineService)({ |
| 668 | 693 | readEvents: () => Stream.empty, |
apps/server/src/server.ts+5| ... | ... | @@ -35,6 +35,8 @@ import * as GitLabCli from "./sourceControl/GitLabCli.ts"; |
| 35 | 35 | import * as TextGeneration from "./textGeneration/TextGeneration.ts"; |
| 36 | 36 | import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/ProviderInstanceRegistryHydration.ts"; |
| 37 | 37 | import { TerminalManagerLive } from "./terminal/Layers/Manager.ts"; |
| 38 | import { PreviewManagerLive } from "./preview/Layers/Manager.ts"; | |
| 39 | import { PreviewPortScannerLive } from "./preview/Layers/PortScanner.ts"; | |
| 38 | 40 | import * as GitManager from "./git/GitManager.ts"; |
| 39 | 41 | import { KeybindingsLive } from "./keybindings.ts"; |
| 40 | 42 | import { ServerRuntimeStartup, ServerRuntimeStartupLive } from "./serverRuntimeStartup.ts"; |
| ... | ... | @@ -233,6 +235,8 @@ const CheckpointingLayerLive = Layer.empty.pipe( |
| 233 | 235 | |
| 234 | 236 | const TerminalLayerLive = TerminalManagerLive.pipe(Layer.provide(PtyAdapterLive)); |
| 235 | 237 | |
| 238 | const PreviewLayerLive = Layer.mergeAll(PreviewManagerLive, PreviewPortScannerLive); | |
| 239 | ||
| 236 | 240 | const WorkspaceEntriesLayerLive = WorkspaceEntriesLive.pipe( |
| 237 | 241 | Layer.provide(WorkspacePathsLive), |
| 238 | 242 | Layer.provideMerge(VcsDriverRegistryLayerLive), |
| ... | ... | @@ -275,6 +279,7 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( |
| 275 | 279 | Layer.provideMerge(VcsLayerLive), |
| 276 | 280 | Layer.provideMerge(ProviderRuntimeLayerLive), |
| 277 | 281 | Layer.provideMerge(TerminalLayerLive), |
| 282 | Layer.provideMerge(PreviewLayerLive), | |
| 278 | 283 | Layer.provideMerge(PersistenceLayerLive), |
| 279 | 284 | Layer.provideMerge(KeybindingsLive), |
| 280 | 285 | Layer.provideMerge(ProviderRegistryLive), |
apps/server/src/ws.ts+62| ... | ... | @@ -22,6 +22,7 @@ import { |
| 22 | 22 | type AuthEnvironmentScope, |
| 23 | 23 | AuthSessionId, |
| 24 | 24 | CommandId, |
| 25 | type DiscoveredLocalServerList, | |
| 25 | 26 | EventId, |
| 26 | 27 | type OrchestrationCommand, |
| 27 | 28 | type GitActionProgressEvent, |
| ... | ... | @@ -70,6 +71,8 @@ import { ServerLifecycleEvents } from "./serverLifecycleEvents.ts"; |
| 70 | 71 | import { ServerRuntimeStartup } from "./serverRuntimeStartup.ts"; |
| 71 | 72 | import { redactServerSettingsForClient, ServerSettingsService } from "./serverSettings.ts"; |
| 72 | 73 | import { TerminalManager } from "./terminal/Services/Manager.ts"; |
| 74 | import { PreviewManager } from "./preview/Services/Manager.ts"; | |
| 75 | import { PreviewPortScanner } from "./preview/Services/PortScanner.ts"; | |
| 73 | 76 | import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries.ts"; |
| 74 | 77 | import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem.ts"; |
| 75 | 78 | import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePaths.ts"; |
| ... | ... | @@ -240,6 +243,8 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => |
| 240 | 243 | const vcsProvisioning = yield* VcsProvisioningService; |
| 241 | 244 | const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; |
| 242 | 245 | const terminalManager = yield* TerminalManager; |
| 246 | const previewManager = yield* PreviewManager; | |
| 247 | const previewPortScanner = yield* PreviewPortScanner; | |
| 243 | 248 | const providerRegistry = yield* ProviderRegistry; |
| 244 | 249 | const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner; |
| 245 | 250 | const config = yield* ServerConfig; |
| ... | ... | @@ -1350,6 +1355,63 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => |
| 1350 | 1355 | ), |
| 1351 | 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 | 1415 | [WS_METHODS.subscribeServerConfig]: (_input) => |
| 1354 | 1416 | observeRpcStreamEffect( |
| 1355 | 1417 | WS_METHODS.subscribeServerConfig, |
apps/web/package.json+1| ... | ... | @@ -64,6 +64,7 @@ |
| 64 | 64 | "babel-plugin-react-compiler": "1.0.0", |
| 65 | 65 | "msw": "2.12.11", |
| 66 | 66 | "playwright": "^1.58.2", |
| 67 | "react-grab": "^0.1.32", | |
| 67 | 68 | "tailwindcss": "^4.0.0", |
| 68 | 69 | "vite": "catalog:", |
| 69 | 70 | "vite-plus": "catalog:", |
apps/web/src/components/ChatView.browser.tsx+20| ... | ... | @@ -268,6 +268,26 @@ function createMockEnvironmentApi(input: { |
| 268 | 268 | subscribeThread: (() => () => |
| 269 | 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 | } |
| 273 | 293 |
apps/web/src/components/ChatView.tsx+130-24| ... | ... | @@ -36,7 +36,7 @@ import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/proje |
| 36 | 36 | import { truncate } from "@t3tools/shared/String"; |
| 37 | 37 | import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels"; |
| 38 | 38 | import { Debouncer } from "@tanstack/react-pacer"; |
| 39 | import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; | |
| 39 | import { lazy, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; | |
| 40 | 40 | import { useNavigate, useSearch } from "@tanstack/react-router"; |
| 41 | 41 | import { useShallow } from "zustand/react/shallow"; |
| 42 | 42 | import { useVcsStatus } from "~/lib/vcsStatusState"; |
| ... | ... | @@ -98,6 +98,14 @@ import { useCommandPaletteStore } from "../commandPaletteStore"; |
| 98 | 98 | import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; |
| 99 | 99 | import { useMediaQuery } from "../hooks/useMediaQuery"; |
| 100 | 100 | import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout"; |
| 101 | import { selectActiveRightPanelKindWithUrl, useRightPanelStore } from "../rightPanelStore"; | |
| 102 | import { isPreviewSupportedInRuntime } from "../previewStateStore"; | |
| 103 | import { 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. | |
| 106 | const PreviewPanel = lazy(() => | |
| 107 | import("./preview/PreviewPanel").then((mod) => ({ default: mod.PreviewPanel })), | |
| 108 | ); | |
| 101 | 109 | import { BranchToolbar } from "./BranchToolbar"; |
| 102 | 110 | import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; |
| 103 | 111 | import PlanSidebar from "./PlanSidebar"; |
| ... | ... | @@ -910,7 +918,6 @@ export default function ChatView(props: ChatViewProps) { |
| 910 | 918 | >({}); |
| 911 | 919 | const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] = |
| 912 | 920 | useState<Record<string, number>>({}); |
| 913 | const [planSidebarOpen, setPlanSidebarOpen] = useState(false); | |
| 914 | 921 | const shouldUsePlanSidebarSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY); |
| 915 | 922 | // Tracks whether the user explicitly dismissed the sidebar for the active turn. |
| 916 | 923 | const planSidebarDismissedForTurnRef = useRef<string | null>(null); |
| ... | ... | @@ -1045,7 +1052,6 @@ export default function ChatView(props: ChatViewProps) { |
| 1045 | 1052 | [activeThread], |
| 1046 | 1053 | ); |
| 1047 | 1054 | const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; |
| 1048 | ||
| 1049 | 1055 | useEffect(() => { |
| 1050 | 1056 | if (!activeThreadRef) { |
| 1051 | 1057 | return; |
| ... | ... | @@ -1068,7 +1074,11 @@ export default function ChatView(props: ChatViewProps) { |
| 1068 | 1074 | reconcileTerminalIds, |
| 1069 | 1075 | terminalUiState.terminalIds, |
| 1070 | 1076 | ]); |
| 1071 | ||
| 1077 | const activeRightPanelKind = useRightPanelStore((store) => | |
| 1078 | selectActiveRightPanelKindWithUrl(store.byThreadKey, activeThreadRef, diffOpen), | |
| 1079 | ); | |
| 1080 | const planSidebarOpen = activeRightPanelKind === "plan"; | |
| 1081 | const previewPanelOpen = activeRightPanelKind === "preview" && isPreviewSupportedInRuntime(); | |
| 1072 | 1082 | const existingOpenTerminalThreadKeys = useMemo(() => { |
| 1073 | 1083 | const existingThreadKeys = new Set<string>([...serverThreadKeys, ...draftThreadKeys]); |
| 1074 | 1084 | return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey)); |
| ... | ... | @@ -1925,12 +1935,47 @@ export default function ChatView(props: ChatViewProps) { |
| 1925 | 1935 | () => shortcutLabelForCommand(keybindings, "diff.toggle", nonTerminalShortcutLabelOptions), |
| 1926 | 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 | 1967 | const onToggleDiff = useCallback(() => { |
| 1929 | 1968 | if (!isServerThread) { |
| 1930 | 1969 | return; |
| 1931 | 1970 | } |
| 1932 | 1971 | if (!diffOpen) { |
| 1933 | 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 | 1980 | void navigate({ |
| 1936 | 1981 | to: "/$environmentId/$threadId", |
| ... | ... | @@ -1944,7 +1989,25 @@ export default function ChatView(props: ChatViewProps) { |
| 1944 | 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]); | |
| 1948 | 2011 | |
| 1949 | 2012 | const envLocked = Boolean( |
| 1950 | 2013 | activeThread && |
| ... | ... | @@ -2223,6 +2286,19 @@ export default function ChatView(props: ChatViewProps) { |
| 2223 | 2286 | terminalId: targetTerminalId, |
| 2224 | 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 | 2302 | } catch (error) { |
| 2227 | 2303 | setThreadError( |
| 2228 | 2304 | activeThreadId, |
| ... | ... | @@ -2295,6 +2371,8 @@ export default function ChatView(props: ChatViewProps) { |
| 2295 | 2371 | command: input.command, |
| 2296 | 2372 | icon: input.icon, |
| 2297 | 2373 | runOnWorktreeCreate: input.runOnWorktreeCreate, |
| 2374 | ...(input.previewUrl ? { previewUrl: input.previewUrl } : {}), | |
| 2375 | ...(input.autoOpenPreview ? { autoOpenPreview: input.autoOpenPreview } : {}), | |
| 2298 | 2376 | }; |
| 2299 | 2377 | const nextScripts = input.runOnWorktreeCreate |
| 2300 | 2378 | ? [ |
| ... | ... | @@ -2330,6 +2408,10 @@ export default function ChatView(props: ChatViewProps) { |
| 2330 | 2408 | command: input.command, |
| 2331 | 2409 | icon: input.icon, |
| 2332 | 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 | 2416 | const nextScripts = activeProject.scripts.map((script) => |
| 2335 | 2417 | script.id === scriptId |
| ... | ... | @@ -2424,21 +2506,25 @@ export default function ChatView(props: ChatViewProps) { |
| 2424 | 2506 | handleInteractionModeChange(interactionMode === "plan" ? "default" : "plan"); |
| 2425 | 2507 | }, [handleInteractionModeChange, interactionMode]); |
| 2426 | 2508 | const togglePlanSidebar = useCallback(() => { |
| 2427 | setPlanSidebarOpen((open) => { | |
| 2428 | if (open) { | |
| 2429 | planSidebarDismissedForTurnRef.current = | |
| 2430 | activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__"; | |
| 2431 | } else { | |
| 2432 | planSidebarDismissedForTurnRef.current = null; | |
| 2433 | } | |
| 2434 | return !open; | |
| 2435 | }); | |
| 2436 | }, [activePlan?.turnId, sidebarProposedPlan?.turnId]); | |
| 2509 | if (!activeThreadRef) return; | |
| 2510 | if (planSidebarOpen) { | |
| 2511 | planSidebarDismissedForTurnRef.current = | |
| 2512 | activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__"; | |
| 2513 | } else { | |
| 2514 | planSidebarDismissedForTurnRef.current = null; | |
| 2515 | } | |
| 2516 | useRightPanelStore.getState().toggle(activeThreadRef, "plan"); | |
| 2517 | }, [activePlan?.turnId, activeThreadRef, planSidebarOpen, sidebarProposedPlan?.turnId]); | |
| 2437 | 2518 | const closePlanSidebar = useCallback(() => { |
| 2438 | setPlanSidebarOpen(false); | |
| 2519 | if (!activeThreadRef) return; | |
| 2520 | useRightPanelStore.getState().close(activeThreadRef); | |
| 2439 | 2521 | planSidebarDismissedForTurnRef.current = |
| 2440 | 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]); | |
| 2442 | 2528 | |
| 2443 | 2529 | const persistThreadSettingsForNextTurn = useCallback( |
| 2444 | 2530 | async (input: { |
| ... | ... | @@ -2523,12 +2609,12 @@ export default function ChatView(props: ChatViewProps) { |
| 2523 | 2609 | setShowScrollToBottom(false); |
| 2524 | 2610 | if (planSidebarOpenOnNextThreadRef.current) { |
| 2525 | 2611 | planSidebarOpenOnNextThreadRef.current = false; |
| 2526 | setPlanSidebarOpen(true); | |
| 2527 | } else { | |
| 2528 | planSidebarOpenOnNextThreadRef.current = false; | |
| 2529 | setPlanSidebarOpen(false); | |
| 2612 | if (activeThreadRef) { | |
| 2613 | useRightPanelStore.getState().open(activeThreadRef, "plan"); | |
| 2614 | } | |
| 2530 | 2615 | } |
| 2531 | 2616 | planSidebarDismissedForTurnRef.current = null; |
| 2617 | // eslint-disable-next-line react-hooks/exhaustive-deps -- activeThreadRef is reset transitively | |
| 2532 | 2618 | }, [activeThread?.id]); |
| 2533 | 2619 | |
| 2534 | 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 | 2627 | if (latestTurnId && activePlan.turnId !== latestTurnId) return; |
| 2542 | 2628 | const turnKey = activePlan.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__"; |
| 2543 | 2629 | if (planSidebarDismissedForTurnRef.current === turnKey) return; |
| 2544 | setPlanSidebarOpen(true); | |
| 2630 | if (activeThreadRef) { | |
| 2631 | useRightPanelStore.getState().open(activeThreadRef, "plan"); | |
| 2632 | } | |
| 2545 | 2633 | }, [ |
| 2546 | 2634 | activePlan, |
| 2547 | 2635 | activeLatestTurn?.turnId, |
| 2636 | activeThreadRef, | |
| 2548 | 2637 | autoOpenPlanSidebar, |
| 2549 | 2638 | planSidebarOpen, |
| 2550 | 2639 | sidebarProposedPlan?.turnId, |
| ... | ... | @@ -3462,7 +3551,9 @@ export default function ChatView(props: ChatViewProps) { |
| 3462 | 3551 | // step-tracking activities that the sidebar will display. |
| 3463 | 3552 | if (nextInteractionMode === "default" && autoOpenPlanSidebar) { |
| 3464 | 3553 | planSidebarDismissedForTurnRef.current = null; |
| 3465 | setPlanSidebarOpen(true); | |
| 3554 | if (activeThreadRef) { | |
| 3555 | useRightPanelStore.getState().open(activeThreadRef, "plan"); | |
| 3556 | } | |
| 3466 | 3557 | } |
| 3467 | 3558 | sendInFlightRef.current = false; |
| 3468 | 3559 | } catch (err) { |
| ... | ... | @@ -3828,6 +3919,9 @@ export default function ChatView(props: ChatViewProps) { |
| 3828 | 3919 | terminalOpen={terminalUiState.terminalOpen} |
| 3829 | 3920 | terminalToggleShortcutLabel={terminalToggleShortcutLabel} |
| 3830 | 3921 | diffToggleShortcutLabel={diffPanelShortcutLabel} |
| 3922 | previewAvailable={isPreviewSupportedInRuntime()} | |
| 3923 | previewOpen={previewPanelOpen} | |
| 3924 | previewToggleShortcutLabel={previewPanelShortcutLabel} | |
| 3831 | 3925 | gitCwd={gitCwd} |
| 3832 | 3926 | diffOpen={diffOpen} |
| 3833 | 3927 | onRunProjectScript={runProjectScript} |
| ... | ... | @@ -3835,6 +3929,7 @@ export default function ChatView(props: ChatViewProps) { |
| 3835 | 3929 | onUpdateProjectScript={updateProjectScript} |
| 3836 | 3930 | onDeleteProjectScript={deleteProjectScript} |
| 3837 | 3931 | onToggleTerminal={toggleTerminalVisibility} |
| 3932 | onTogglePreview={onTogglePreview} | |
| 3838 | 3933 | onToggleDiff={onToggleDiff} |
| 3839 | 3934 | /> |
| 3840 | 3935 | </header> |
| ... | ... | @@ -4020,7 +4115,6 @@ export default function ChatView(props: ChatViewProps) { |
| 4020 | 4115 | </div> |
| 4021 | 4116 | {/* end chat column */} |
| 4022 | 4117 | |
| 4023 | {/* Plan sidebar */} | |
| 4024 | 4118 | {planSidebarOpen && !shouldUsePlanSidebarSheet ? ( |
| 4025 | 4119 | <PlanSidebar |
| 4026 | 4120 | activePlan={activePlan} |
| ... | ... | @@ -4034,6 +4128,11 @@ export default function ChatView(props: ChatViewProps) { |
| 4034 | 4128 | onClose={closePlanSidebar} |
| 4035 | 4129 | /> |
| 4036 | 4130 | ) : null} |
| 4131 | {previewPanelOpen && !shouldUsePlanSidebarSheet && activeThreadRef ? ( | |
| 4132 | <Suspense fallback={null}> | |
| 4133 | <PreviewPanel mode="inline" threadRef={activeThreadRef} visible /> | |
| 4134 | </Suspense> | |
| 4135 | ) : null} | |
| 4037 | 4136 | </div> |
| 4038 | 4137 | {/* end horizontal flex container */} |
| 4039 | 4138 | |
| ... | ... | @@ -4054,6 +4153,13 @@ export default function ChatView(props: ChatViewProps) { |
| 4054 | 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 | 4163 | {shouldUsePlanSidebarSheet ? ( |
| 4058 | 4164 | <RightPanelSheet open={planSidebarOpen} onClose={closePlanSidebar}> |
| 4059 | 4165 | <PlanSidebar |
apps/web/src/components/ProjectScriptsControl.tsx+39| ... | ... | @@ -85,6 +85,10 @@ export interface NewProjectScriptInput { |
| 85 | 85 | icon: ProjectScriptIcon; |
| 86 | 86 | runOnWorktreeCreate: boolean; |
| 87 | 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 | } |
| 89 | 93 | |
| 90 | 94 | interface ProjectScriptsControlProps { |
| ... | ... | @@ -115,6 +119,8 @@ export default function ProjectScriptsControl({ |
| 115 | 119 | const [iconPickerOpen, setIconPickerOpen] = useState(false); |
| 116 | 120 | const [runOnWorktreeCreate, setRunOnWorktreeCreate] = useState(false); |
| 117 | 121 | const [keybinding, setKeybinding] = useState(""); |
| 122 | const [previewUrl, setPreviewUrl] = useState(""); | |
| 123 | const [autoOpenPreview, setAutoOpenPreview] = useState(false); | |
| 118 | 124 | const [validationError, setValidationError] = useState<string | null>(null); |
| 119 | 125 | const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); |
| 120 | 126 | |
| ... | ... | @@ -166,12 +172,15 @@ export default function ProjectScriptsControl({ |
| 166 | 172 | keybinding, |
| 167 | 173 | command: commandForProjectScript(scriptIdForValidation), |
| 168 | 174 | }); |
| 175 | const trimmedPreviewUrl = previewUrl.trim(); | |
| 169 | 176 | const payload = { |
| 170 | 177 | name: trimmedName, |
| 171 | 178 | command: trimmedCommand, |
| 172 | 179 | icon, |
| 173 | 180 | runOnWorktreeCreate, |
| 174 | 181 | keybinding: keybindingRule?.key ?? null, |
| 182 | previewUrl: trimmedPreviewUrl.length > 0 ? trimmedPreviewUrl : null, | |
| 183 | autoOpenPreview: trimmedPreviewUrl.length > 0 ? autoOpenPreview : false, | |
| 175 | 184 | } satisfies NewProjectScriptInput; |
| 176 | 185 | if (editingScriptId) { |
| 177 | 186 | await onUpdateScript(editingScriptId, payload); |
| ... | ... | @@ -193,6 +202,8 @@ export default function ProjectScriptsControl({ |
| 193 | 202 | setIconPickerOpen(false); |
| 194 | 203 | setRunOnWorktreeCreate(false); |
| 195 | 204 | setKeybinding(""); |
| 205 | setPreviewUrl(""); | |
| 206 | setAutoOpenPreview(false); | |
| 196 | 207 | setValidationError(null); |
| 197 | 208 | setDialogOpen(true); |
| 198 | 209 | }; |
| ... | ... | @@ -205,6 +216,8 @@ export default function ProjectScriptsControl({ |
| 205 | 216 | setIconPickerOpen(false); |
| 206 | 217 | setRunOnWorktreeCreate(script.runOnWorktreeCreate); |
| 207 | 218 | setKeybinding(keybindingValueForCommand(keybindings, commandForProjectScript(script.id)) ?? ""); |
| 219 | setPreviewUrl(script.previewUrl ?? ""); | |
| 220 | setAutoOpenPreview(script.autoOpenPreview ?? false); | |
| 208 | 221 | setValidationError(null); |
| 209 | 222 | setDialogOpen(true); |
| 210 | 223 | }; |
| ... | ... | @@ -327,6 +340,8 @@ export default function ProjectScriptsControl({ |
| 327 | 340 | setIcon("play"); |
| 328 | 341 | setRunOnWorktreeCreate(false); |
| 329 | 342 | setKeybinding(""); |
| 343 | setPreviewUrl(""); | |
| 344 | setAutoOpenPreview(false); | |
| 330 | 345 | setValidationError(null); |
| 331 | 346 | }} |
| 332 | 347 | open={dialogOpen} |
| ... | ... | @@ -413,6 +428,18 @@ export default function ProjectScriptsControl({ |
| 413 | 428 | onChange={(event) => setCommand(event.target.value)} |
| 414 | 429 | /> |
| 415 | 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 | 443 | <label className="flex items-center justify-between gap-3 rounded-md border border-border/70 px-3 py-2 text-sm"> |
| 417 | 444 | <span>Run automatically on worktree creation</span> |
| 418 | 445 | <Switch |
| ... | ... | @@ -420,6 +447,18 @@ export default function ProjectScriptsControl({ |
| 420 | 447 | onCheckedChange={(checked) => setRunOnWorktreeCreate(Boolean(checked))} |
| 421 | 448 | /> |
| 422 | 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 | 462 | {validationError && <p className="text-sm text-destructive">{validationError}</p>} |
| 424 | 463 | </form> |
| 425 | 464 | </DialogPanel> |
apps/web/src/components/ThreadTerminalDrawer.tsx+21-5| ... | ... | @@ -48,6 +48,7 @@ import { |
| 48 | 48 | import { readEnvironmentApi } from "~/environmentApi"; |
| 49 | 49 | import { readLocalApi } from "~/localApi"; |
| 50 | 50 | import { attachTerminalSession } from "../terminalSessionState"; |
| 51 | import { openTerminalLinkInPreview } from "./preview/openTerminalLinkInPreview"; | |
| 51 | 52 | |
| 52 | 53 | const MIN_DRAWER_HEIGHT = 180; |
| 53 | 54 | const MAX_DRAWER_HEIGHT_RATIO = 0.75; |
| ... | ... | @@ -509,11 +510,26 @@ export function TerminalViewport({ |
| 509 | 510 | } |
| 510 | 511 | |
| 511 | 512 | if (match.kind === "url") { |
| 512 | void localApi.shell.openExternal(match.text).catch((error: unknown) => { | |
| 513 | writeSystemMessage( | |
| 514 | latestTerminal, | |
| 515 | error instanceof Error ? error.message : "Unable to open link", | |
| 516 | ); | |
| 513 | const fallbackToBrowser = () => { | |
| 514 | void localApi.shell.openExternal(match.text).catch((error: unknown) => { | |
| 515 | writeSystemMessage( | |
| 516 | latestTerminal, | |
| 517 | error instanceof Error ? error.message : "Unable to open link", | |
| 518 | ); | |
| 519 | }); | |
| 520 | }; | |
| 521 | const api = readEnvironmentApi(threadRef.environmentId); | |
| 522 | if (!api) { | |
| 523 | fallbackToBrowser(); | |
| 524 | return; | |
| 525 | } | |
| 526 | void openTerminalLinkInPreview({ | |
| 527 | url: match.text, | |
| 528 | position: { x: event.clientX, y: event.clientY }, | |
| 529 | threadRef, | |
| 530 | api, | |
| 531 | localApi, | |
| 532 | fallbackToBrowser, | |
| 517 | 533 | }); |
| 518 | 534 | return; |
| 519 | 535 | } |
apps/web/src/components/chat/ChatHeader.tsx+34-1| ... | ... | @@ -9,7 +9,8 @@ import { scopeThreadRef } from "@t3tools/client-runtime"; |
| 9 | 9 | import { memo } from "react"; |
| 10 | 10 | import GitActionsControl from "../GitActionsControl"; |
| 11 | 11 | import { type DraftId } from "~/composerDraftStore"; |
| 12 | import { DiffIcon, TerminalSquareIcon } from "lucide-react"; | |
| 12 | import { DiffIcon, Globe, TerminalSquareIcon } from "lucide-react"; | |
| 13 | import { Badge } from "../ui/badge"; | |
| 13 | 14 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 14 | 15 | import ProjectScriptsControl, { type NewProjectScriptInput } from "../ProjectScriptsControl"; |
| 15 | 16 | import { Toggle } from "../ui/toggle"; |
| ... | ... | @@ -33,6 +34,10 @@ interface ChatHeaderProps { |
| 33 | 34 | terminalOpen: boolean; |
| 34 | 35 | terminalToggleShortcutLabel: string | null; |
| 35 | 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 | 41 | gitCwd: string | null; |
| 37 | 42 | diffOpen: boolean; |
| 38 | 43 | onRunProjectScript: (script: ProjectScript) => void; |
| ... | ... | @@ -40,6 +45,7 @@ interface ChatHeaderProps { |
| 40 | 45 | onUpdateProjectScript: (scriptId: string, input: NewProjectScriptInput) => Promise<void>; |
| 41 | 46 | onDeleteProjectScript: (scriptId: string) => Promise<void>; |
| 42 | 47 | onToggleTerminal: () => void; |
| 48 | onTogglePreview: () => void; | |
| 43 | 49 | onToggleDiff: () => void; |
| 44 | 50 | } |
| 45 | 51 | |
| ... | ... | @@ -71,6 +77,9 @@ export const ChatHeader = memo(function ChatHeader({ |
| 71 | 77 | terminalOpen, |
| 72 | 78 | terminalToggleShortcutLabel, |
| 73 | 79 | diffToggleShortcutLabel, |
| 80 | previewAvailable, | |
| 81 | previewOpen, | |
| 82 | previewToggleShortcutLabel, | |
| 74 | 83 | gitCwd, |
| 75 | 84 | diffOpen, |
| 76 | 85 | onRunProjectScript, |
| ... | ... | @@ -78,6 +87,7 @@ export const ChatHeader = memo(function ChatHeader({ |
| 78 | 87 | onUpdateProjectScript, |
| 79 | 88 | onDeleteProjectScript, |
| 80 | 89 | onToggleTerminal, |
| 90 | onTogglePreview, | |
| 81 | 91 | onToggleDiff, |
| 82 | 92 | }: ChatHeaderProps) { |
| 83 | 93 | const primaryEnvironmentId = usePrimaryEnvironmentId(); |
| ... | ... | @@ -155,6 +165,29 @@ export const ChatHeader = memo(function ChatHeader({ |
| 155 | 165 | : "Toggle terminal drawer"} |
| 156 | 166 | </TooltipPopup> |
| 157 | 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 | 191 | <Tooltip> |
| 159 | 192 | <TooltipTrigger |
| 160 | 193 | render={ |
apps/web/src/components/preview/BrowserMockup.tsx created+24| ... | ... | @@ -0,0 +1,24 @@ |
| 1 | import { cn } from "~/lib/utils"; | |
| 2 | ||
| 3 | /** Browser-window thumbnail glyph for the "Local" recommendation cards. */ | |
| 4 | export 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 @@ |
| 1 | import { ArrowLeft, ArrowRight, ExternalLink, Globe, RotateCw } from "lucide-react"; | |
| 2 | import { | |
| 3 | type FormEvent, | |
| 4 | type KeyboardEvent, | |
| 5 | type ReactNode, | |
| 6 | useEffect, | |
| 7 | useRef, | |
| 8 | useState, | |
| 9 | } from "react"; | |
| 10 | ||
| 11 | import { Button } from "~/components/ui/button"; | |
| 12 | import { InputGroup, InputGroupAddon, InputGroupInput } from "~/components/ui/input-group"; | |
| 13 | import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; | |
| 14 | import { cn } from "~/lib/utils"; | |
| 15 | ||
| 16 | interface 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 | ||
| 39 | const NOOP = () => {}; | |
| 40 | ||
| 41 | export 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 @@ |
| 1 | import type { EnvironmentId } from "@t3tools/contracts"; | |
| 2 | import { Globe } from "lucide-react"; | |
| 3 | ||
| 4 | import { Empty, EmptyDescription, EmptyMedia, EmptyTitle } from "~/components/ui/empty"; | |
| 5 | ||
| 6 | import { PreviewLocalServerCard } from "./PreviewLocalServerCard"; | |
| 7 | import { useDiscoveredLocalServers } from "./useDiscoveredLocalServers"; | |
| 8 | ||
| 9 | interface Props { | |
| 10 | environmentId: EnvironmentId; | |
| 11 | configuredUrls?: ReadonlyArray<string> | undefined; | |
| 12 | recentlySeenUrls?: ReadonlyArray<string> | undefined; | |
| 13 | onOpenUrl: (url: string) => void; | |
| 14 | } | |
| 15 | ||
| 16 | export 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 @@ |
| 1 | import { cn } from "~/lib/utils"; | |
| 2 | ||
| 3 | import { BrowserMockup } from "./BrowserMockup"; | |
| 4 | import type { PreviewableServer } from "./useDiscoveredLocalServers"; | |
| 5 | ||
| 6 | interface Props { | |
| 7 | server: PreviewableServer; | |
| 8 | onOpen: () => void; | |
| 9 | } | |
| 10 | ||
| 11 | export 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 | ||
| 35 | function 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 | ||
| 42 | function 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 | ||
| 51 | function 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 | ||
| 3 | import { Minus, MoreVertical, Plus as PlusIcon, RotateCcw } from "lucide-react"; | |
| 4 | ||
| 5 | import { Button } from "~/components/ui/button"; | |
| 6 | import { Menu, MenuItem, MenuPopup, MenuSeparator, MenuTrigger } from "~/components/ui/menu"; | |
| 7 | import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; | |
| 8 | ||
| 9 | import { previewBridge } from "./previewBridge"; | |
| 10 | ||
| 11 | interface 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 | */ | |
| 29 | export 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 | ||
| 3 | import type { ScopedThreadRef } from "@t3tools/contracts"; | |
| 4 | ||
| 5 | import { isPreviewSupportedInRuntime } from "~/previewStateStore"; | |
| 6 | ||
| 7 | import { PreviewPanelShell, type PreviewPanelMode } from "./PreviewPanelShell"; | |
| 8 | import { PreviewView } from "./PreviewView"; | |
| 9 | ||
| 10 | interface Props { | |
| 11 | mode: PreviewPanelMode; | |
| 12 | threadRef: ScopedThreadRef; | |
| 13 | configuredUrls?: ReadonlyArray<string> | undefined; | |
| 14 | visible: boolean; | |
| 15 | } | |
| 16 | ||
| 17 | export 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 @@ |
| 1 | import { type ReactNode, useEffect, useState } from "react"; | |
| 2 | ||
| 3 | import { isElectron } from "~/env"; | |
| 4 | import { useResizableWidth } from "~/hooks/useResizableWidth"; | |
| 5 | import { cn } from "~/lib/utils"; | |
| 6 | ||
| 7 | import { RightPanelResizeHandle } from "./RightPanelResizeHandle"; | |
| 8 | ||
| 9 | export type PreviewPanelMode = "inline" | "sheet" | "sidebar"; | |
| 10 | ||
| 11 | const PREVIEW_PANEL_WIDTH_STORAGE_KEY = "t3code:preview-panel-width"; | |
| 12 | const PREVIEW_PANEL_MIN_WIDTH = 360; | |
| 13 | /** Hard ceiling so a wide monitor can't yield a panel that swallows the chat. */ | |
| 14 | const PREVIEW_PANEL_MAX_WIDTH_PX = 1400; | |
| 15 | /** Fraction of the viewport allowed; the panel is min(this · vw, MAX_PX). */ | |
| 16 | const PREVIEW_PANEL_MAX_WIDTH_FRACTION = 0.7; | |
| 17 | const 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 | */ | |
| 24 | export 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 | */ | |
| 57 | function 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 @@ |
| 1 | import { useState } from "react"; | |
| 2 | ||
| 3 | import { Button } from "~/components/ui/button"; | |
| 4 | ||
| 5 | import { describePreviewError } from "./errorCodeMessages"; | |
| 6 | ||
| 7 | interface 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. */ | |
| 17 | export 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 | ||
| 68 | function 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 | ||
| 84 | function 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 | ||
| 3 | import { scopedThreadKey } from "@t3tools/client-runtime"; | |
| 4 | import { type ScopedThreadRef } from "@t3tools/contracts"; | |
| 5 | import { useCallback, useEffect, useState } from "react"; | |
| 6 | ||
| 7 | import { ensureEnvironmentApi } from "~/environmentApi"; | |
| 8 | import { ensureLocalApi } from "~/localApi"; | |
| 9 | import { selectThreadPreviewState, usePreviewStateStore } from "~/previewStateStore"; | |
| 10 | ||
| 11 | import { previewBridge } from "./previewBridge"; | |
| 12 | import { subscribePreviewAction } from "./previewActionBus"; | |
| 13 | import { PreviewChromeRow } from "./PreviewChromeRow"; | |
| 14 | import { PreviewEmptyState } from "./PreviewEmptyState"; | |
| 15 | import { PreviewMoreMenu } from "./PreviewMoreMenu"; | |
| 16 | import { PreviewUnreachable } from "./PreviewUnreachable"; | |
| 17 | import { PreviewWebview } from "./PreviewWebview"; | |
| 18 | import { useLoadingProgress } from "./useLoadingProgress"; | |
| 19 | import { usePreviewSession } from "./usePreviewSession"; | |
| 20 | import { ZoomIndicator } from "./ZoomIndicator"; | |
| 21 | ||
| 22 | interface Props { | |
| 23 | threadRef: ScopedThreadRef; | |
| 24 | configuredUrls?: ReadonlyArray<string> | undefined; | |
| 25 | visible: boolean; | |
| 26 | } | |
| 27 | ||
| 28 | const 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 | */ | |
| 34 | export 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 | ||
| 3 | import type { ScopedThreadRef } from "@t3tools/contracts"; | |
| 4 | import { useEffect, useRef, useState } from "react"; | |
| 5 | ||
| 6 | import { isElectron } from "~/env"; | |
| 7 | ||
| 8 | import { previewBridge } from "./previewBridge"; | |
| 9 | import { usePreviewBridge } from "./usePreviewBridge"; | |
| 10 | ||
| 11 | interface 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 | ||
| 25 | interface ElectronWebview extends HTMLElement { | |
| 26 | src: string; | |
| 27 | partition: string; | |
| 28 | allowpopups: boolean; | |
| 29 | reload: () => void; | |
| 30 | getWebContentsId: () => number; | |
| 31 | } | |
| 32 | ||
| 33 | declare 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 | */ | |
| 46 | export 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 @@ |
| 1 | import type { ResizableWidthHandlers } from "~/hooks/useResizableWidth"; | |
| 2 | import { cn } from "~/lib/utils"; | |
| 3 | ||
| 4 | interface 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 | */ | |
| 17 | export 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 @@ |
| 1 | import { useEffect, useRef, useState } from "react"; | |
| 2 | ||
| 3 | import { cn } from "~/lib/utils"; | |
| 4 | ||
| 5 | const HIDE_AFTER_MS = 1500; | |
| 6 | const ZOOM_EPSILON = 0.001; | |
| 7 | ||
| 8 | interface 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 | */ | |
| 19 | export 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 @@ |
| 1 | import { 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 | */ | |
| 7 | export 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 @@ |
| 1 | import type { EnvironmentApi, LocalApi, ScopedThreadRef } from "@t3tools/contracts"; | |
| 2 | import { isPreviewableUrl } from "@t3tools/shared/preview"; | |
| 3 | ||
| 4 | import { isPreviewSupportedInRuntime } from "~/previewStateStore"; | |
| 5 | import { useRightPanelStore } from "~/rightPanelStore"; | |
| 6 | ||
| 7 | interface 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 | */ | |
| 27 | export 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 | */ | |
| 8 | export type PreviewAction = | |
| 9 | | "toggle-panel" | |
| 10 | | "refresh" | |
| 11 | | "focus-url" | |
| 12 | | "zoom-in" | |
| 13 | | "zoom-out" | |
| 14 | | "reset-zoom"; | |
| 15 | ||
| 16 | const EVENT_NAME = "t3code:preview-action"; | |
| 17 | ||
| 18 | export function dispatchPreviewAction(action: PreviewAction): void { | |
| 19 | if (typeof window === "undefined") return; | |
| 20 | window.dispatchEvent(new CustomEvent<PreviewAction>(EVENT_NAME, { detail: action })); | |
| 21 | } | |
| 22 | ||
| 23 | export 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 | */ | |
| 8 | export 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. */ | |
| 2 | export 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 | */ | |
| 8 | export 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 @@ |
| 1 | import type { DiscoveredLocalServer } from "@t3tools/contracts"; | |
| 2 | import { describe, expect, it } from "vitest"; | |
| 3 | ||
| 4 | import { mergeServers, type PreviewableServer } from "./useDiscoveredLocalServers"; | |
| 5 | ||
| 6 | const 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 | ||
| 15 | describe("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 | ||
| 106 | describe("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 @@ |
| 1 | import type { DiscoveredLocalServer } from "@t3tools/contracts"; | |
| 2 | import { isLoopbackHost } from "@t3tools/shared/preview"; | |
| 3 | import { useEffect, useMemo, useState } from "react"; | |
| 4 | ||
| 5 | import { ensureEnvironmentApi } from "~/environmentApi"; | |
| 6 | import type { EnvironmentId } from "@t3tools/contracts"; | |
| 7 | ||
| 8 | export 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 | ||
| 17 | interface 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 | */ | |
| 28 | export 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 | ||
| 53 | export 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 | ||
| 122 | function canonicalKey(host: string, port: number): string { | |
| 123 | return `${host.toLowerCase()}:${port}`; | |
| 124 | } | |
| 125 | ||
| 126 | function 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 @@ |
| 1 | import { useEffect, useRef, useState } from "react"; | |
| 2 | ||
| 3 | const TICK_INTERVAL_MS = 120; | |
| 4 | const FADE_OUT_DELAY_MS = 220; | |
| 5 | const SEED_PERCENT = 4; | |
| 6 | const ASYMPTOTE_PERCENT = 90; | |
| 7 | const APPROACH_FACTOR = 0.08; | |
| 8 | const 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 | */ | |
| 19 | export 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 | ||
| 3 | import type { | |
| 4 | DesktopPreviewTabState, | |
| 5 | PreviewReportStatusInput, | |
| 6 | ScopedThreadRef, | |
| 7 | ThreadId, | |
| 8 | } from "@t3tools/contracts"; | |
| 9 | import { useEffect, useRef } from "react"; | |
| 10 | ||
| 11 | import { ensureEnvironmentApi } from "~/environmentApi"; | |
| 12 | import { type DesktopPreviewOverlay, usePreviewStateStore } from "~/previewStateStore"; | |
| 13 | ||
| 14 | import { 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 | */ | |
| 24 | export 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 | ||
| 65 | function 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 | */ | |
| 84 | function 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 | ||
| 3 | import { scopedThreadKey } from "@t3tools/client-runtime"; | |
| 4 | import type { ScopedThreadRef } from "@t3tools/contracts"; | |
| 5 | import { useEffect } from "react"; | |
| 6 | ||
| 7 | import { ensureEnvironmentApi } from "~/environmentApi"; | |
| 8 | import { 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 | */ | |
| 18 | export 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 | 58 | subscribeThread: (input, callback, options) => |
| 59 | 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 | } |
| 63 | 73 |
apps/web/src/hooks/useResizableWidth.ts created+176| ... | ... | @@ -0,0 +1,176 @@ |
| 1 | import * as Schema from "effect/Schema"; | |
| 2 | import { | |
| 3 | type PointerEvent as ReactPointerEvent, | |
| 4 | useCallback, | |
| 5 | useEffect, | |
| 6 | useRef, | |
| 7 | useState, | |
| 8 | } from "react"; | |
| 9 | ||
| 10 | import { getLocalStorageItem, setLocalStorageItem } from "./useLocalStorage"; | |
| 11 | ||
| 12 | const WidthSchema = Schema.Finite; | |
| 13 | ||
| 14 | export 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 | ||
| 28 | export 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 | */ | |
| 44 | export 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 | 30 | export interface ShortcutMatchContext { |
| 31 | 31 | terminalFocus: boolean; |
| 32 | 32 | terminalOpen: boolean; |
| 33 | previewFocus: boolean; | |
| 34 | previewOpen: boolean; | |
| 33 | 35 | [key: string]: boolean; |
| 34 | 36 | } |
| 35 | 37 | |
| ... | ... | @@ -112,6 +114,8 @@ function resolveContext(options: ShortcutMatchOptions | undefined): ShortcutMatc |
| 112 | 114 | return { |
| 113 | 115 | terminalFocus: false, |
| 114 | 116 | terminalOpen: false, |
| 117 | previewFocus: false, | |
| 118 | previewOpen: false, | |
| 115 | 119 | ...options?.context, |
| 116 | 120 | }; |
| 117 | 121 | } |
| ... | ... | @@ -379,6 +383,30 @@ export function isDiffToggleShortcut( |
| 379 | 383 | return matchesCommandShortcut(event, keybindings, "diff.toggle", options); |
| 380 | 384 | } |
| 381 | 385 | |
| 386 | export function isPreviewToggleShortcut( | |
| 387 | event: ShortcutEventLike, | |
| 388 | keybindings: ResolvedKeybindingsConfig, | |
| 389 | options?: ShortcutMatchOptions, | |
| 390 | ): boolean { | |
| 391 | return matchesCommandShortcut(event, keybindings, "preview.toggle", options); | |
| 392 | } | |
| 393 | ||
| 394 | export function isPreviewRefreshShortcut( | |
| 395 | event: ShortcutEventLike, | |
| 396 | keybindings: ResolvedKeybindingsConfig, | |
| 397 | options?: ShortcutMatchOptions, | |
| 398 | ): boolean { | |
| 399 | return matchesCommandShortcut(event, keybindings, "preview.refresh", options); | |
| 400 | } | |
| 401 | ||
| 402 | export function isPreviewFocusUrlShortcut( | |
| 403 | event: ShortcutEventLike, | |
| 404 | keybindings: ResolvedKeybindingsConfig, | |
| 405 | options?: ShortcutMatchOptions, | |
| 406 | ): boolean { | |
| 407 | return matchesCommandShortcut(event, keybindings, "preview.focusUrl", options); | |
| 408 | } | |
| 409 | ||
| 382 | 410 | export function isChatNewShortcut( |
| 383 | 411 | event: ShortcutEventLike, |
| 384 | 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 | */ | |
| 8 | const FAVICON_PROVIDER = "https://www.google.com/s2/favicons"; | |
| 9 | ||
| 10 | export 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 | */ | |
| 9 | export 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 | 18 | import { APP_DISPLAY_NAME } from "./branding"; |
| 19 | 19 | import { syncDocumentWindowControlsOverlayClass } from "./lib/windowControlsOverlay"; |
| 20 | 20 | |
| 21 | if (import.meta.env.DEV) { | |
| 22 | void import("react-grab"); | |
| 23 | } | |
| 24 | ||
| 21 | 25 | // Electron loads the app from a file-backed shell, so hash history avoids path resolution issues. |
| 22 | 26 | const history = isElectron ? createHashHistory() : createBrowserHistory(); |
| 23 | 27 |
apps/web/src/previewStateStore.test.ts created+227| ... | ... | @@ -0,0 +1,227 @@ |
| 1 | import { scopeThreadRef } from "@t3tools/client-runtime"; | |
| 2 | import { type EnvironmentId, type PreviewSessionSnapshot, ThreadId } from "@t3tools/contracts"; | |
| 3 | import { beforeEach, describe, expect, it } from "vitest"; | |
| 4 | ||
| 5 | import { __testing, selectThreadPreviewState, usePreviewStateStore } from "./previewStateStore"; | |
| 6 | ||
| 7 | const environmentId = "env-1" as EnvironmentId; | |
| 8 | const ref = scopeThreadRef(environmentId, ThreadId.make("thread-1")); | |
| 9 | ||
| 10 | const 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 | ||
| 20 | beforeEach(() => { | |
| 21 | usePreviewStateStore.setState({ byThreadKey: {} }); | |
| 22 | }); | |
| 23 | ||
| 24 | describe("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 | */ | |
| 14 | import { scopedThreadKey } from "@t3tools/client-runtime"; | |
| 15 | import { | |
| 16 | type PreviewEvent, | |
| 17 | type PreviewSessionSnapshot, | |
| 18 | type ScopedThreadRef, | |
| 19 | } from "@t3tools/contracts"; | |
| 20 | import { create } from "zustand"; | |
| 21 | ||
| 22 | import { PREVIEW_RECENT_URL_LIMIT } from "./components/preview/previewConstants"; | |
| 23 | ||
| 24 | export interface DesktopPreviewOverlay { | |
| 25 | canGoBack: boolean; | |
| 26 | canGoForward: boolean; | |
| 27 | loading: boolean; | |
| 28 | zoomFactor: number; | |
| 29 | } | |
| 30 | ||
| 31 | export 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 | ||
| 39 | const EMPTY_THREAD_PREVIEW_STATE: ThreadPreviewState = Object.freeze({ | |
| 40 | snapshot: null, | |
| 41 | desktopOverlay: null, | |
| 42 | recentlySeenUrls: [] as string[], | |
| 43 | }); | |
| 44 | ||
| 45 | interface 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 | ||
| 54 | const ensureState = ( | |
| 55 | byThreadKey: Record<string, ThreadPreviewState>, | |
| 56 | threadKey: string, | |
| 57 | ): ThreadPreviewState => byThreadKey[threadKey] ?? EMPTY_THREAD_PREVIEW_STATE; | |
| 58 | ||
| 59 | const 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 | ||
| 70 | const 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 | ||
| 79 | const 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 | ||
| 84 | export 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 | ||
| 172 | export 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 | ||
| 180 | export function isPreviewSupportedInRuntime(): boolean { | |
| 181 | if (typeof window === "undefined") return false; | |
| 182 | return Boolean(window.desktopBridge?.preview); | |
| 183 | } | |
| 184 | ||
| 185 | export 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 @@ |
| 1 | import { scopeThreadRef } from "@t3tools/client-runtime"; | |
| 2 | import { type EnvironmentId, ThreadId } from "@t3tools/contracts"; | |
| 3 | import { beforeEach, describe, expect, it } from "vitest"; | |
| 4 | ||
| 5 | import { | |
| 6 | selectActiveRightPanel, | |
| 7 | selectActiveRightPanelKindWithUrl, | |
| 8 | useRightPanelStore, | |
| 9 | } from "./rightPanelStore"; | |
| 10 | ||
| 11 | const refA = scopeThreadRef("env-1" as EnvironmentId, ThreadId.make("thread-A")); | |
| 12 | const refB = scopeThreadRef("env-1" as EnvironmentId, ThreadId.make("thread-B")); | |
| 13 | ||
| 14 | beforeEach(() => { | |
| 15 | useRightPanelStore.setState({ byThreadKey: {} }); | |
| 16 | }); | |
| 17 | ||
| 18 | describe("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 | */ | |
| 12 | import { scopedThreadKey } from "@t3tools/client-runtime"; | |
| 13 | import type { ScopedThreadRef } from "@t3tools/contracts"; | |
| 14 | import { create } from "zustand"; | |
| 15 | import { createJSONStorage, persist } from "zustand/middleware"; | |
| 16 | ||
| 17 | import { resolveStorage } from "./lib/storage"; | |
| 18 | ||
| 19 | export const RIGHT_PANEL_KINDS = ["plan", "diff", "preview"] as const; | |
| 20 | export type RightPanelKind = (typeof RIGHT_PANEL_KINDS)[number]; | |
| 21 | ||
| 22 | const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v1"; | |
| 23 | ||
| 24 | interface ThreadRightPanelState { | |
| 25 | active: RightPanelKind | null; | |
| 26 | } | |
| 27 | ||
| 28 | interface 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 | ||
| 36 | const 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 | ||
| 51 | export 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 | ||
| 91 | export 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 | */ | |
| 107 | export 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 | 2 | import { useEffect } from "react"; |
| 3 | 3 | |
| 4 | 4 | import { useCommandPaletteStore } from "../commandPaletteStore"; |
| 5 | import { dispatchPreviewAction } from "../components/preview/previewActionBus"; | |
| 5 | 6 | import { useHandleNewThread } from "../hooks/useHandleNewThread"; |
| 6 | 7 | import { |
| 7 | 8 | startNewLocalThreadFromContext, |
| 8 | 9 | startNewThreadFromContext, |
| 9 | 10 | } from "../lib/chatThreadActions"; |
| 11 | import { isPreviewFocused } from "../lib/previewFocus"; | |
| 10 | 12 | import { isTerminalFocused } from "../lib/terminalFocus"; |
| 11 | 13 | import { resolveShortcutCommand } from "../keybindings"; |
| 12 | 14 | import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; |
| 15 | import { isPreviewSupportedInRuntime } from "../previewStateStore"; | |
| 16 | import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore"; | |
| 13 | 17 | import { useThreadSelectionStore } from "../threadSelectionStore"; |
| 14 | 18 | import { resolveSidebarNewThreadEnvMode } from "~/components/Sidebar.logic"; |
| 19 | import { stackedThreadToast, toastManager } from "~/components/ui/toast"; | |
| 15 | 20 | import { useSettings } from "~/hooks/useSettings"; |
| 16 | 21 | import { useServerKeybindings } from "~/rpc/serverState"; |
| 17 | 22 | |
| ... | ... | @@ -26,6 +31,14 @@ function ChatRouteGlobalShortcuts() { |
| 26 | 31 | ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen |
| 27 | 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 | 42 | const appSettings = useSettings(); |
| 30 | 43 | |
| 31 | 44 | useEffect(() => { |
| ... | ... | @@ -35,6 +48,8 @@ function ChatRouteGlobalShortcuts() { |
| 35 | 48 | context: { |
| 36 | 49 | terminalFocus: isTerminalFocused(), |
| 37 | 50 | terminalOpen, |
| 51 | previewFocus: isPreviewFocused(), | |
| 52 | previewOpen, | |
| 38 | 53 | }, |
| 39 | 54 | }); |
| 40 | 55 | |
| ... | ... | @@ -75,6 +90,50 @@ function ChatRouteGlobalShortcuts() { |
| 75 | 90 | }), |
| 76 | 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 | }; |
| 80 | 139 | |
| ... | ... | @@ -89,6 +148,8 @@ function ChatRouteGlobalShortcuts() { |
| 89 | 148 | handleNewThread, |
| 90 | 149 | keybindings, |
| 91 | 150 | defaultProjectRef, |
| 151 | previewOpen, | |
| 152 | routeThreadRef, | |
| 92 | 153 | selectedThreadKeysSize, |
| 93 | 154 | terminalOpen, |
| 94 | 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 | 23 | { "key": "mod+d", "command": "terminal.split", "when": "terminalFocus" }, |
| 24 | 24 | { "key": "mod+n", "command": "terminal.new", "when": "terminalFocus" }, |
| 25 | 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 | 32 | { "key": "mod+k", "command": "commandPalette.toggle", "when": "!terminalFocus" }, |
| 27 | 33 | { "key": "mod+n", "command": "chat.new", "when": "!terminalFocus" }, |
| 28 | 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 | 57 | - `terminal.split`: split terminal (in focused terminal context by default) |
| 52 | 58 | - `terminal.new`: create new terminal (in focused terminal context by default) |
| 53 | 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 | 66 | - `commandPalette.toggle`: open or close the global command palette |
| 55 | 67 | - `chat.new`: create a new chat thread preserving the active thread's branch/worktree state |
| 56 | 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 | 92 | |
| 81 | 93 | - `terminalFocus` |
| 82 | 94 | - `terminalOpen` |
| 95 | - `previewFocus` | |
| 96 | - `previewOpen` | |
| 83 | 97 | |
| 84 | 98 | Supported operators: |
| 85 | 99 |
packages/client-runtime/src/wsRpcClient.ts+31| ... | ... | @@ -79,6 +79,16 @@ export interface WsRpcClient { |
| 79 | 79 | readonly onEvent: RpcStreamMethod<typeof WS_METHODS.subscribeTerminalEvents>; |
| 80 | 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 | 92 | readonly projects: { |
| 83 | 93 | readonly searchEntries: RpcUnaryMethod<typeof WS_METHODS.projectsSearchEntries>; |
| 84 | 94 | readonly writeFile: RpcUnaryMethod<typeof WS_METHODS.projectsWriteFile>; |
| ... | ... | @@ -212,6 +222,27 @@ export function createWsRpcClient( |
| 212 | 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 | 246 | projects: { |
| 216 | 247 | searchEntries: (input) => |
| 217 | 248 | transport.request((client) => client[WS_METHODS.projectsSearchEntries](input)), |
packages/contracts/src/index.ts+1| ... | ... | @@ -22,4 +22,5 @@ export * from "./editor.ts"; |
| 22 | 22 | export * from "./project.ts"; |
| 23 | 23 | export * from "./filesystem.ts"; |
| 24 | 24 | export * from "./review.ts"; |
| 25 | export * from "./preview.ts"; | |
| 25 | 26 | export * from "./rpc.ts"; |
packages/contracts/src/ipc.ts+80| ... | ... | @@ -54,6 +54,18 @@ import type { |
| 54 | 54 | } from "./terminal.ts"; |
| 55 | 55 | import type { ServerRemoveKeybindingInput, ServerUpsertKeybindingInput } from "./server.ts"; |
| 56 | 56 | import * as Schema from "effect/Schema"; |
| 57 | import 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"; | |
| 57 | 69 | import type { |
| 58 | 70 | ClientOrchestrationCommand, |
| 59 | 71 | OrchestrationGetFullThreadDiffInput, |
| ... | ... | @@ -402,6 +414,33 @@ export const DesktopCloudAuthFetchResultSchema = Schema.Struct({ |
| 402 | 414 | }); |
| 403 | 415 | export type DesktopCloudAuthFetchResult = typeof DesktopCloudAuthFetchResultSchema.Type; |
| 404 | 416 | |
| 417 | /** | |
| 418 | * Renderer-facing snapshot of a desktop preview tab. Mirrors the main-process | |
| 419 | * PreviewTabState shape but uses serialisable primitives only. | |
| 420 | */ | |
| 421 | export 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 | ||
| 433 | export 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 | ||
| 405 | 444 | export interface DesktopBridge { |
| 406 | 445 | getAppBranding: () => DesktopAppBranding | null; |
| 407 | 446 | getLocalEnvironmentBootstrap: () => DesktopEnvironmentBootstrap | null; |
| ... | ... | @@ -460,6 +499,34 @@ export interface DesktopBridge { |
| 460 | 499 | downloadUpdate: () => Promise<DesktopUpdateActionResult>; |
| 461 | 500 | installUpdate: () => Promise<DesktopUpdateActionResult>; |
| 462 | 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 | ||
| 509 | export 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 | } |
| 464 | 531 | |
| 465 | 532 | /** |
| ... | ... | @@ -619,4 +686,17 @@ export interface EnvironmentApi { |
| 619 | 686 | }, |
| 620 | 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 | 53 | "terminal.new", |
| 54 | 54 | "terminal.close", |
| 55 | 55 | "diff.toggle", |
| 56 | "preview.toggle", | |
| 57 | "preview.refresh", | |
| 58 | "preview.focusUrl", | |
| 59 | "preview.zoomIn", | |
| 60 | "preview.zoomOut", | |
| 61 | "preview.resetZoom", | |
| 56 | 62 | "commandPalette.toggle", |
| 57 | 63 | "chat.new", |
| 58 | 64 | "chat.newLocal", |
packages/contracts/src/orchestration.ts+11| ... | ... | @@ -194,6 +194,17 @@ export const ProjectScript = Schema.Struct({ |
| 194 | 194 | command: TrimmedNonEmptyString, |
| 195 | 195 | icon: ProjectScriptIcon, |
| 196 | 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 | }); |
| 198 | 209 | export type ProjectScript = typeof ProjectScript.Type; |
| 199 | 210 |
packages/contracts/src/preview.test.ts created+162| ... | ... | @@ -0,0 +1,162 @@ |
| 1 | import { Schema } from "effect"; | |
| 2 | import { describe, expect, it } from "vitest"; | |
| 3 | ||
| 4 | import { | |
| 5 | DiscoveredLocalServer, | |
| 6 | PreviewEvent, | |
| 7 | PreviewNavStatus, | |
| 8 | PreviewSessionSnapshot, | |
| 9 | } from "./preview.ts"; | |
| 10 | ||
| 11 | const decodePreviewEvent = Schema.decodeUnknownSync(PreviewEvent); | |
| 12 | const decodeSnapshot = Schema.decodeUnknownSync(PreviewSessionSnapshot); | |
| 13 | const decodeNavStatus = Schema.decodeUnknownSync(PreviewNavStatus); | |
| 14 | const decodeServer = Schema.decodeUnknownSync(DiscoveredLocalServer); | |
| 15 | ||
| 16 | describe("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 | ||
| 52 | describe("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 | ||
| 71 | describe("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 | ||
| 118 | describe("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 | */ | |
| 11 | import { Schema } from "effect"; | |
| 12 | import { ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; | |
| 13 | ||
| 14 | const Url = TrimmedNonEmptyString.check(Schema.isMaxLength(2048)); | |
| 15 | const Title = Schema.String.check(Schema.isMaxLength(512)); | |
| 16 | ||
| 17 | export const PreviewTabId = TrimmedNonEmptyString.check(Schema.isMaxLength(128)); | |
| 18 | export type PreviewTabId = typeof PreviewTabId.Type; | |
| 19 | ||
| 20 | export 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 | ]); | |
| 37 | export type PreviewNavStatus = typeof PreviewNavStatus.Type; | |
| 38 | ||
| 39 | export 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 | }); | |
| 47 | export type PreviewSessionSnapshot = typeof PreviewSessionSnapshot.Type; | |
| 48 | ||
| 49 | export 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 | }); | |
| 54 | export type PreviewOpenInput = typeof PreviewOpenInput.Type; | |
| 55 | ||
| 56 | export const PreviewNavigateInput = Schema.Struct({ | |
| 57 | threadId: ThreadId, | |
| 58 | tabId: PreviewTabId, | |
| 59 | url: Url, | |
| 60 | resolvedTitle: Schema.optional(Title), | |
| 61 | }); | |
| 62 | export type PreviewNavigateInput = typeof PreviewNavigateInput.Type; | |
| 63 | ||
| 64 | export const PreviewReportStatusInput = Schema.Struct({ | |
| 65 | threadId: ThreadId, | |
| 66 | tabId: PreviewTabId, | |
| 67 | navStatus: PreviewNavStatus, | |
| 68 | canGoBack: Schema.Boolean, | |
| 69 | canGoForward: Schema.Boolean, | |
| 70 | }); | |
| 71 | export type PreviewReportStatusInput = typeof PreviewReportStatusInput.Type; | |
| 72 | ||
| 73 | export const PreviewRefreshInput = Schema.Struct({ | |
| 74 | threadId: ThreadId, | |
| 75 | tabId: PreviewTabId, | |
| 76 | }); | |
| 77 | export type PreviewRefreshInput = typeof PreviewRefreshInput.Type; | |
| 78 | ||
| 79 | export const PreviewCloseInput = Schema.Struct({ | |
| 80 | threadId: ThreadId, | |
| 81 | tabId: Schema.optional(PreviewTabId), | |
| 82 | }); | |
| 83 | export type PreviewCloseInput = typeof PreviewCloseInput.Type; | |
| 84 | ||
| 85 | export const PreviewListInput = Schema.Struct({ | |
| 86 | threadId: ThreadId, | |
| 87 | }); | |
| 88 | export type PreviewListInput = typeof PreviewListInput.Type; | |
| 89 | ||
| 90 | export const PreviewListResult = Schema.Struct({ | |
| 91 | sessions: Schema.Array(PreviewSessionSnapshot), | |
| 92 | }); | |
| 93 | export type PreviewListResult = typeof PreviewListResult.Type; | |
| 94 | ||
| 95 | const PreviewEventBaseSchema = Schema.Struct({ | |
| 96 | threadId: TrimmedNonEmptyString, | |
| 97 | tabId: PreviewTabId, | |
| 98 | createdAt: Schema.String, | |
| 99 | }); | |
| 100 | ||
| 101 | const PreviewOpenedEvent = Schema.Struct({ | |
| 102 | ...PreviewEventBaseSchema.fields, | |
| 103 | type: Schema.Literal("opened"), | |
| 104 | snapshot: PreviewSessionSnapshot, | |
| 105 | }); | |
| 106 | ||
| 107 | const PreviewNavigatedEvent = Schema.Struct({ | |
| 108 | ...PreviewEventBaseSchema.fields, | |
| 109 | type: Schema.Literal("navigated"), | |
| 110 | snapshot: PreviewSessionSnapshot, | |
| 111 | }); | |
| 112 | ||
| 113 | const 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 | ||
| 122 | const PreviewClosedEvent = Schema.Struct({ | |
| 123 | ...PreviewEventBaseSchema.fields, | |
| 124 | type: Schema.Literal("closed"), | |
| 125 | }); | |
| 126 | ||
| 127 | export const PreviewEvent = Schema.Union([ | |
| 128 | PreviewOpenedEvent, | |
| 129 | PreviewNavigatedEvent, | |
| 130 | PreviewFailedEvent, | |
| 131 | PreviewClosedEvent, | |
| 132 | ]); | |
| 133 | export 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 | */ | |
| 139 | export 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 | }); | |
| 146 | export type DiscoveredLocalServer = typeof DiscoveredLocalServer.Type; | |
| 147 | ||
| 148 | export const DiscoveredLocalServerList = Schema.Struct({ | |
| 149 | servers: Schema.Array(DiscoveredLocalServer), | |
| 150 | scannedAt: Schema.String, | |
| 151 | }); | |
| 152 | export type DiscoveredLocalServerList = typeof DiscoveredLocalServerList.Type; | |
| 153 | ||
| 154 | export 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 | ||
| 166 | export 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 | ||
| 180 | export const PreviewError = Schema.Union([PreviewSessionLookupError, PreviewInvalidUrlError]); | |
| 181 | export type PreviewError = typeof PreviewError.Type; |
packages/contracts/src/rpc.ts+78| ... | ... | @@ -85,6 +85,19 @@ import { |
| 85 | 85 | TerminalSessionSnapshot, |
| 86 | 86 | TerminalWriteInput, |
| 87 | 87 | } from "./terminal.ts"; |
| 88 | import { | |
| 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"; | |
| 88 | 101 | import { |
| 89 | 102 | ServerConfigStreamEvent, |
| 90 | 103 | ServerConfig, |
| ... | ... | @@ -157,6 +170,14 @@ export const WS_METHODS = { |
| 157 | 170 | terminalRestart: "terminal.restart", |
| 158 | 171 | terminalClose: "terminal.close", |
| 159 | 172 | |
| 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 | 181 | // Server meta |
| 161 | 182 | serverGetConfig: "server.getConfig", |
| 162 | 183 | serverRefreshProviders: "server.refreshProviders", |
| ... | ... | @@ -184,6 +205,8 @@ export const WS_METHODS = { |
| 184 | 205 | subscribeVcsStatus: "subscribeVcsStatus", |
| 185 | 206 | subscribeTerminalEvents: "subscribeTerminalEvents", |
| 186 | 207 | subscribeTerminalMetadata: "subscribeTerminalMetadata", |
| 208 | subscribePreviewEvents: "subscribePreviewEvents", | |
| 209 | subscribeDiscoveredLocalServers: "subscribeDiscoveredLocalServers", | |
| 187 | 210 | subscribeServerConfig: "subscribeServerConfig", |
| 188 | 211 | subscribeServerLifecycle: "subscribeServerLifecycle", |
| 189 | 212 | subscribeAuthAccess: "subscribeAuthAccess", |
| ... | ... | @@ -454,6 +477,53 @@ export const WsTerminalCloseRpc = Rpc.make(WS_METHODS.terminalClose, { |
| 454 | 477 | error: Schema.Union([TerminalError, EnvironmentAuthorizationError]), |
| 455 | 478 | }); |
| 456 | 479 | |
| 480 | export const WsPreviewOpenRpc = Rpc.make(WS_METHODS.previewOpen, { | |
| 481 | payload: PreviewOpenInput, | |
| 482 | success: PreviewSessionSnapshot, | |
| 483 | error: PreviewError, | |
| 484 | }); | |
| 485 | ||
| 486 | export const WsPreviewNavigateRpc = Rpc.make(WS_METHODS.previewNavigate, { | |
| 487 | payload: PreviewNavigateInput, | |
| 488 | success: PreviewSessionSnapshot, | |
| 489 | error: PreviewError, | |
| 490 | }); | |
| 491 | ||
| 492 | export const WsPreviewRefreshRpc = Rpc.make(WS_METHODS.previewRefresh, { | |
| 493 | payload: PreviewRefreshInput, | |
| 494 | error: PreviewError, | |
| 495 | }); | |
| 496 | ||
| 497 | export const WsPreviewCloseRpc = Rpc.make(WS_METHODS.previewClose, { | |
| 498 | payload: PreviewCloseInput, | |
| 499 | error: PreviewError, | |
| 500 | }); | |
| 501 | ||
| 502 | export const WsPreviewListRpc = Rpc.make(WS_METHODS.previewList, { | |
| 503 | payload: PreviewListInput, | |
| 504 | success: PreviewListResult, | |
| 505 | }); | |
| 506 | ||
| 507 | export const WsPreviewReportStatusRpc = Rpc.make(WS_METHODS.previewReportStatus, { | |
| 508 | payload: PreviewReportStatusInput, | |
| 509 | error: PreviewError, | |
| 510 | }); | |
| 511 | ||
| 512 | export const WsSubscribePreviewEventsRpc = Rpc.make(WS_METHODS.subscribePreviewEvents, { | |
| 513 | payload: Schema.Struct({}), | |
| 514 | success: PreviewEvent, | |
| 515 | stream: true, | |
| 516 | }); | |
| 517 | ||
| 518 | export const WsSubscribeDiscoveredLocalServersRpc = Rpc.make( | |
| 519 | WS_METHODS.subscribeDiscoveredLocalServers, | |
| 520 | { | |
| 521 | payload: Schema.Struct({}), | |
| 522 | success: DiscoveredLocalServerList, | |
| 523 | stream: true, | |
| 524 | }, | |
| 525 | ); | |
| 526 | ||
| 457 | 527 | export const WsOrchestrationDispatchCommandRpc = Rpc.make( |
| 458 | 528 | ORCHESTRATION_WS_METHODS.dispatchCommand, |
| 459 | 529 | { |
| ... | ... | @@ -589,6 +659,14 @@ export const WsRpcGroup = RpcGroup.make( |
| 589 | 659 | WsTerminalCloseRpc, |
| 590 | 660 | WsSubscribeTerminalEventsRpc, |
| 591 | 661 | WsSubscribeTerminalMetadataRpc, |
| 662 | WsPreviewOpenRpc, | |
| 663 | WsPreviewNavigateRpc, | |
| 664 | WsPreviewRefreshRpc, | |
| 665 | WsPreviewCloseRpc, | |
| 666 | WsPreviewListRpc, | |
| 667 | WsPreviewReportStatusRpc, | |
| 668 | WsSubscribePreviewEventsRpc, | |
| 669 | WsSubscribeDiscoveredLocalServersRpc, | |
| 592 | 670 | WsSubscribeServerConfigRpc, |
| 593 | 671 | WsSubscribeServerLifecycleRpc, |
| 594 | 672 | WsSubscribeAuthAccessRpc, |
packages/shared/package.json+4| ... | ... | @@ -150,6 +150,10 @@ |
| 150 | 150 | "./relayClient": { |
| 151 | 151 | "types": "./src/relayClient.ts", |
| 152 | 152 | "import": "./src/relayClient.ts" |
| 153 | }, | |
| 154 | "./preview": { | |
| 155 | "types": "./src/preview.ts", | |
| 156 | "import": "./src/preview.ts" | |
| 153 | 157 | } |
| 154 | 158 | }, |
| 155 | 159 | "scripts": { |
packages/shared/src/keybindings.ts+7| ... | ... | @@ -24,6 +24,13 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [ |
| 24 | 24 | { key: "mod+n", command: "terminal.new", when: "terminalFocus" }, |
| 25 | 25 | { key: "mod+w", command: "terminal.close", when: "terminalFocus" }, |
| 26 | 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 | 34 | { key: "mod+k", command: "commandPalette.toggle", when: "!terminalFocus" }, |
| 28 | 35 | { key: "mod+n", command: "chat.new", when: "!terminalFocus" }, |
| 29 | 36 | { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, |
packages/shared/src/preview.test.ts created+75| ... | ... | @@ -0,0 +1,75 @@ |
| 1 | import { describe, expect, it } from "vitest"; | |
| 2 | ||
| 3 | import { | |
| 4 | isLoopbackHost, | |
| 5 | isPreviewableUrl, | |
| 6 | newPreviewTabId, | |
| 7 | normalizePreviewUrl, | |
| 8 | PreviewUrlNormalizationError, | |
| 9 | } from "./preview.ts"; | |
| 10 | ||
| 11 | describe("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 | ||
| 20 | describe("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 | ||
| 30 | describe("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 | ||
| 48 | describe("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 | ||
| 7 | const 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 | */ | |
| 13 | export 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 | ||
| 20 | const 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. */ | |
| 23 | export const LSOF_LOCAL_HOST_TOKENS: ReadonlySet<string> = new Set([ | |
| 24 | ...LOOPBACK_HOSTS, | |
| 25 | "*", | |
| 26 | "[::]", | |
| 27 | "[::1]", | |
| 28 | ]); | |
| 29 | ||
| 30 | const LOOPBACK_PREFIX_PATTERN = /^(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1?\])(?::|\/|$)/i; | |
| 31 | ||
| 32 | export 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. */ | |
| 39 | export 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 | ||
| 49 | export 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 | */ | |
| 70 | export 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 | } |