| 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,7 +143,7 @@ export function selectActiveRightPanel( |
| 143 | - Remove the local `planSidebarOpen` state (`:688`). | 143 | - Remove the local `planSidebarOpen` state (`:688`). |
| 144 | - Replace `setPlanSidebarOpen(true)` callsites with `rightPanelStore.open(activeThreadRef, "plan")`. | 144 | - Replace `setPlanSidebarOpen(true)` callsites with `rightPanelStore.open(activeThreadRef, "plan")`. |
| 145 | - Replace `closePlanSidebar` with `rightPanelStore.close(activeThreadRef)`. | 145 | - Replace `closePlanSidebar` with `rightPanelStore.close(activeThreadRef)`. |
| 146 | - Existing `planSidebarDismissedForTurnRef`/`planSidebarOpenOnNextThreadRef` logic stays local — it only governs whether to *call* `open()`/`close()` on turn change. | 146 | - Existing `planSidebarDismissedForTurnRef`/`planSidebarOpenOnNextThreadRef` logic stays local — it only governs whether to _call_ `open()`/`close()` on turn change. |
| 147 | 147 | ||
| 148 | ### Render arbitration | 148 | ### Render arbitration |
| 149 | 149 | ||
| ... | @@ -176,49 +176,49 @@ const useSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY); | ... | @@ -176,49 +176,49 @@ const useSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY); |
| 176 | 176 | ||
| 177 | ### NEW files | 177 | ### NEW files |
| 178 | 178 | ||
| 179 | | File | Purpose | | 179 | | File | Purpose | |
| 180 | |---|---| | 180 | | ------------------------------------------------------------ | ----------------------------------------------------------------------- | |
| 181 | | `packages/contracts/src/preview.ts` | Effect/Schema schemas: inputs, snapshot, events, errors | | 181 | | `packages/contracts/src/preview.ts` | Effect/Schema schemas: inputs, snapshot, events, errors | |
| 182 | | `packages/contracts/src/preview.test.ts` | Schema round‑trip tests | | 182 | | `packages/contracts/src/preview.test.ts` | Schema round‑trip tests | |
| 183 | | `apps/server/src/preview/Services/Manager.ts` | `PreviewManager` Service tag + interface | | 183 | | `apps/server/src/preview/Services/Manager.ts` | `PreviewManager` Service tag + interface | |
| 184 | | `apps/server/src/preview/Layers/Manager.ts` | Implementation: in‑memory map + event subject | | 184 | | `apps/server/src/preview/Layers/Manager.ts` | Implementation: in‑memory map + event subject | |
| 185 | | `apps/server/src/preview/Layers/Manager.test.ts` | Lifecycle, snapshot, event ordering | | 185 | | `apps/server/src/preview/Layers/Manager.test.ts` | Lifecycle, snapshot, event ordering | |
| 186 | | `apps/desktop/src/preview-view-manager.ts` | Plain‑Node Electron port of ami's BrowserManager (subset) | | 186 | | `apps/desktop/src/preview-view-manager.ts` | Plain‑Node Electron port of ami's BrowserManager (subset) | |
| 187 | | `apps/desktop/src/preview-preload.ts` | Webview preload (no‑op v1) | | 187 | | `apps/desktop/src/preview-preload.ts` | Webview preload (no‑op v1) | |
| 188 | | `apps/web/src/previewStateStore.ts` | Per‑thread zustand store (mirrors `terminalStateStore.ts`) | | 188 | | `apps/web/src/previewStateStore.ts` | Per‑thread zustand store (mirrors `terminalStateStore.ts`) | |
| 189 | | `apps/web/src/previewStateStore.test.ts` | Reducer tests | | 189 | | `apps/web/src/previewStateStore.test.ts` | Reducer tests | |
| 190 | | `apps/web/src/rightPanelStore.ts` | Right‑panel arbiter | | 190 | | `apps/web/src/rightPanelStore.ts` | Right‑panel arbiter | |
| 191 | | `apps/web/src/rightPanelStore.test.ts` | Arbiter tests | | 191 | | `apps/web/src/rightPanelStore.test.ts` | Arbiter tests | |
| 192 | | `apps/web/src/components/preview/PreviewPanel.tsx` | Right‑panel entry (wraps `PreviewPanelShell`) | | 192 | | `apps/web/src/components/preview/PreviewPanel.tsx` | Right‑panel entry (wraps `PreviewPanelShell`) | |
| 193 | | `apps/web/src/components/preview/PreviewPanelShell.tsx` | Shell mirroring `DiffPanelShell` (`mode: "inline"\|"sheet"\|"sidebar"`) | | 193 | | `apps/web/src/components/preview/PreviewPanelShell.tsx` | Shell mirroring `DiffPanelShell` (`mode: "inline"\|"sheet"\|"sidebar"`) | |
| 194 | | `apps/web/src/components/preview/PreviewView.tsx` | Chrome bar (URL/back/fwd/refresh) + `<PreviewWebview>` | | 194 | | `apps/web/src/components/preview/PreviewView.tsx` | Chrome bar (URL/back/fwd/refresh) + `<PreviewWebview>` | |
| 195 | | `apps/web/src/components/preview/PreviewWebview.tsx` | Electron `<webview>` host; null on web build | | 195 | | `apps/web/src/components/preview/PreviewWebview.tsx` | Electron `<webview>` host; null on web build | |
| 196 | | `apps/web/src/components/preview/PreviewEmptyState.tsx` | Pre‑URL empty state | | 196 | | `apps/web/src/components/preview/PreviewEmptyState.tsx` | Pre‑URL empty state | |
| 197 | | `apps/web/src/components/preview/PreviewUnsupportedToast.ts` | `"Preview is only available in the desktop app"` toast | | 197 | | `apps/web/src/components/preview/PreviewUnsupportedToast.ts` | `"Preview is only available in the desktop app"` toast | |
| 198 | 198 | ||
| 199 | ### MODIFIED files | 199 | ### MODIFIED files |
| 200 | 200 | ||
| 201 | | File | Change | | 201 | | File | Change | |
| 202 | |---|---| | 202 | | --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | |
| 203 | | `packages/contracts/src/keybindings.ts` | Add `preview.toggle`, `preview.refresh`, `preview.focusUrl` to `STATIC_KEYBINDING_COMMANDS`; add `previewFocus`, `previewOpen` to context keys | | 203 | | `packages/contracts/src/keybindings.ts` | Add `preview.toggle`, `preview.refresh`, `preview.focusUrl` to `STATIC_KEYBINDING_COMMANDS`; add `previewFocus`, `previewOpen` to context keys | |
| 204 | | `packages/contracts/src/project.ts` | Add `previewUrl?: string` and `autoOpenPreview?: boolean` to `ProjectScript` schema | | 204 | | `packages/contracts/src/project.ts` | Add `previewUrl?: string` and `autoOpenPreview?: boolean` to `ProjectScript` schema | |
| 205 | | `packages/contracts/src/server.ts` | Extend `EnvironmentApi` with `preview` namespace | | 205 | | `packages/contracts/src/server.ts` | Extend `EnvironmentApi` with `preview` namespace | |
| 206 | | `packages/contracts/src/index.ts` | Re‑export new types | | 206 | | `packages/contracts/src/index.ts` | Re‑export new types | |
| 207 | | `apps/server/src/keybindings.ts` | Add defaults: `mod+shift+j` → `preview.toggle`, `mod+shift+r` → `preview.refresh` (when `previewFocus`) | | 207 | | `apps/server/src/keybindings.ts` | Add defaults: `mod+shift+j` → `preview.toggle`, `mod+shift+r` → `preview.refresh` (when `previewFocus`) | |
| 208 | | `apps/server/src/ws.ts` | Route `preview.open`, `preview.navigate`, `preview.refresh`, `preview.close`, `preview.list`, `preview.onEvent` | | 208 | | `apps/server/src/ws.ts` | Route `preview.open`, `preview.navigate`, `preview.refresh`, `preview.close`, `preview.list`, `preview.onEvent` | |
| 209 | | `apps/server/src/orchestration/runtimeLayer.ts` (or equivalent) | Provide `PreviewManager.Default` | | 209 | | `apps/server/src/orchestration/runtimeLayer.ts` (or equivalent) | Provide `PreviewManager.Default` | |
| 210 | | `apps/web/src/environmentApi.ts` | Wire `preview` slot in `createEnvironmentApi` | | 210 | | `apps/web/src/environmentApi.ts` | Wire `preview` slot in `createEnvironmentApi` | |
| 211 | | `apps/web/src/keybindings.ts` | Add `isPreviewToggleShortcut`, `isPreviewRefreshShortcut` helpers | | 211 | | `apps/web/src/keybindings.ts` | Add `isPreviewToggleShortcut`, `isPreviewRefreshShortcut` helpers | |
| 212 | | `apps/web/src/routes/_chat.tsx` | Handle `preview.toggle` in global shortcut handler | | 212 | | `apps/web/src/routes/_chat.tsx` | Handle `preview.toggle` in global shortcut handler | |
| 213 | | `apps/web/src/components/ChatView.tsx` | Replace local `planSidebarOpen` with `rightPanelStore`; render `PreviewPanel` | | 213 | | `apps/web/src/components/ChatView.tsx` | Replace local `planSidebarOpen` with `rightPanelStore`; render `PreviewPanel` | |
| 214 | | `apps/web/src/components/ThreadTerminalDrawer.tsx` | At terminal link activation, when `match.kind === "url"` and link looks like a dev URL, show context menu with "Open in preview" / "Open in browser"; pass through to `localApi.preview.openTab(...)` when chosen | | 214 | | `apps/web/src/components/ThreadTerminalDrawer.tsx` | At terminal link activation, when `match.kind === "url"` and link looks like a dev URL, show context menu with "Open in preview" / "Open in browser"; pass through to `localApi.preview.openTab(...)` when chosen | |
| 215 | | `apps/web/src/components/ProjectScriptsControl.tsx` | Add `previewUrl` + `autoOpenPreview` form fields in the Add/Edit dialog | | 215 | | `apps/web/src/components/ProjectScriptsControl.tsx` | Add `previewUrl` + `autoOpenPreview` form fields in the Add/Edit dialog | |
| 216 | | `apps/web/src/projectScripts.ts` | Carry the new fields through `commandForProjectScript` / serialization | | 216 | | `apps/web/src/projectScripts.ts` | Carry the new fields through `commandForProjectScript` / serialization | |
| 217 | | `apps/web/src/types.ts` | Add `PreviewSession` mirror types (or re‑export from contracts) | | 217 | | `apps/web/src/types.ts` | Add `PreviewSession` mirror types (or re‑export from contracts) | |
| 218 | | `apps/web/src/lib/desktopBridge.d.ts` (or wherever bridge types live) | Add `preview` namespace shape | | 218 | | `apps/web/src/lib/desktopBridge.d.ts` (or wherever bridge types live) | Add `preview` namespace shape | |
| 219 | | `apps/desktop/src/main.ts` | Register `preview:*` IPC handlers; instantiate `previewViewManager`; wire `mainWindow` injection | | 219 | | `apps/desktop/src/main.ts` | Register `preview:*` IPC handlers; instantiate `previewViewManager`; wire `mainWindow` injection | |
| 220 | | `apps/desktop/src/preload.ts` | Expose `desktopBridge.preview.*` | | 220 | | `apps/desktop/src/preload.ts` | Expose `desktopBridge.preview.*` | |
| 221 | | `KEYBINDINGS.md` | Document new commands and `previewFocus`/`previewOpen` `when` keys | | 221 | | `KEYBINDINGS.md` | Document new commands and `previewFocus`/`previewOpen` `when` keys | |
| 222 | 222 | ||
| 223 | ### NOT changed | 223 | ### NOT changed |
| 224 | 224 | ||
| ... | @@ -335,10 +335,7 @@ export class PreviewInvalidUrlError extends Schema.TaggedErrorClass<PreviewInval | ... | @@ -335,10 +335,7 @@ export class PreviewInvalidUrlError extends Schema.TaggedErrorClass<PreviewInval |
| 335 | } | 335 | } |
| 336 | } | 336 | } |
| 337 | 337 | ||
| 338 | export const PreviewError = Schema.Union([ | 338 | export const PreviewError = Schema.Union([PreviewSessionLookupError, PreviewInvalidUrlError]); |
| 339 | PreviewSessionLookupError, | ||
| 340 | PreviewInvalidUrlError, | ||
| 341 | ]); | ||
| 342 | export type PreviewError = typeof PreviewError.Type; | 339 | export type PreviewError = typeof PreviewError.Type; |
| 343 | ``` | 340 | ``` |
| 344 | 341 | ||
| ... | @@ -360,9 +357,9 @@ const STATIC_KEYBINDING_COMMANDS = [ | ... | @@ -360,9 +357,9 @@ const STATIC_KEYBINDING_COMMANDS = [ |
| 360 | "terminal.new", | 357 | "terminal.new", |
| 361 | "terminal.close", | 358 | "terminal.close", |
| 362 | "diff.toggle", | 359 | "diff.toggle", |
| 363 | "preview.toggle", // NEW | 360 | "preview.toggle", // NEW |
| 364 | "preview.refresh", // NEW | 361 | "preview.refresh", // NEW |
| 365 | "preview.focusUrl", // NEW | 362 | "preview.focusUrl", // NEW |
| 366 | "commandPalette.toggle", | 363 | "commandPalette.toggle", |
| 367 | "chat.new", | 364 | "chat.new", |
| 368 | "chat.newLocal", | 365 | "chat.newLocal", |
| ... | @@ -399,28 +396,21 @@ import { | ... | @@ -399,28 +396,21 @@ import { |
| 399 | } from "@t3tools/contracts"; | 396 | } from "@t3tools/contracts"; |
| 400 | 397 | ||
| 401 | export interface PreviewManagerShape { | 398 | export interface PreviewManagerShape { |
| 402 | readonly open: ( | 399 | readonly open: (input: PreviewOpenInput) => Effect.Effect<PreviewSessionSnapshot, PreviewError>; |
| 403 | input: PreviewOpenInput, | ||
| 404 | ) => Effect.Effect<PreviewSessionSnapshot, PreviewError>; | ||
| 405 | readonly navigate: ( | 400 | readonly navigate: ( |
| 406 | input: PreviewNavigateInput, | 401 | input: PreviewNavigateInput, |
| 407 | ) => Effect.Effect<PreviewSessionSnapshot, PreviewError>; | 402 | ) => Effect.Effect<PreviewSessionSnapshot, PreviewError>; |
| 408 | readonly refresh: ( | 403 | readonly refresh: (input: PreviewRefreshInput) => Effect.Effect<void, PreviewError>; |
| 409 | input: PreviewRefreshInput, | ||
| 410 | ) => Effect.Effect<void, PreviewError>; | ||
| 411 | readonly close: (input: PreviewCloseInput) => Effect.Effect<void, PreviewError>; | 404 | readonly close: (input: PreviewCloseInput) => Effect.Effect<void, PreviewError>; |
| 412 | readonly list: ( | 405 | readonly list: (threadId: string) => Effect.Effect<ReadonlyArray<PreviewSessionSnapshot>>; |
| 413 | threadId: string, | ||
| 414 | ) => Effect.Effect<ReadonlyArray<PreviewSessionSnapshot>>; | ||
| 415 | readonly subscribe: ( | 406 | readonly subscribe: ( |
| 416 | listener: (event: PreviewEvent) => Effect.Effect<void>, | 407 | listener: (event: PreviewEvent) => Effect.Effect<void>, |
| 417 | ) => Effect.Effect<() => void>; | 408 | ) => Effect.Effect<() => void>; |
| 418 | } | 409 | } |
| 419 | 410 | ||
| 420 | export class PreviewManager extends Context.Service< | 411 | export class PreviewManager extends Context.Service<PreviewManager, PreviewManagerShape>()( |
| 421 | PreviewManager, | 412 | "t3/preview/Services/Manager/PreviewManager", |
| 422 | PreviewManagerShape | 413 | ) {} |
| 423 | >()("t3/preview/Services/Manager/PreviewManager") {} | ||
| 424 | ``` | 414 | ``` |
| 425 | 415 | ||
| 426 | ### `apps/server/src/preview/Layers/Manager.ts` | 416 | ### `apps/server/src/preview/Layers/Manager.ts` |
| ... | @@ -435,15 +425,12 @@ const normalizeUrl = (input: string) => | ... | @@ -435,15 +425,12 @@ const normalizeUrl = (input: string) => |
| 435 | const trimmed = input.trim(); | 425 | const trimmed = input.trim(); |
| 436 | if (!trimmed) throw new Error("empty"); | 426 | if (!trimmed) throw new Error("empty"); |
| 437 | // localhost stays http unless explicitly https | 427 | // localhost stays http unless explicitly https |
| 438 | const useHttp = | 428 | const useHttp = /^(localhost|127\.0\.0\.1|0\.0\.0\.0)(:|\/|$)/.test(trimmed); |
| 439 | /^(localhost|127\.0\.0\.1|0\.0\.0\.0)(:|\/|$)/.test(trimmed); | ||
| 440 | const parsed = urlParseLax(trimmed, { https: !useHttp }); | 429 | const parsed = urlParseLax(trimmed, { https: !useHttp }); |
| 441 | if (!parsed?.href) throw new Error("unparseable"); | 430 | if (!parsed?.href) throw new Error("unparseable"); |
| 442 | return parsed.href; | 431 | return parsed.href; |
| 443 | }).pipe( | 432 | }).pipe( |
| 444 | Effect.catchAll((cause) => | 433 | Effect.catchAll((cause) => Effect.fail(new PreviewInvalidUrlError({ rawUrl: input, cause }))), |
| 445 | Effect.fail(new PreviewInvalidUrlError({ rawUrl: input, cause })), | ||
| 446 | ), | ||
| 447 | ); | 434 | ); |
| 448 | ``` | 435 | ``` |
| 449 | 436 | ||
| ... | @@ -513,14 +500,19 @@ export class PreviewViewManager { | ... | @@ -513,14 +500,19 @@ export class PreviewViewManager { |
| 513 | this.mainWindow = window; | 500 | this.mainWindow = window; |
| 514 | } | 501 | } |
| 515 | 502 | ||
| 516 | getPreloadPath(): string { return this.preloadPath; } | 503 | getPreloadPath(): string { |
| 517 | getBrowserPartition(): string { return PREVIEW_PARTITION; } | 504 | return this.preloadPath; |
| 505 | } | ||
| 506 | getBrowserPartition(): string { | ||
| 507 | return PREVIEW_PARTITION; | ||
| 508 | } | ||
| 518 | 509 | ||
| 519 | getBrowserSession(): Session { | 510 | getBrowserSession(): Session { |
| 520 | if (this.browserSession) return this.browserSession; | 511 | if (this.browserSession) return this.browserSession; |
| 521 | const sess = session.fromPartition(PREVIEW_PARTITION); | 512 | const sess = session.fromPartition(PREVIEW_PARTITION); |
| 522 | // strip electron/t3code from UA so dev preview doesn't trip bot detection | 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 | .replace(/Electron\/[\d.]+ /, "") | 516 | .replace(/Electron\/[\d.]+ /, "") |
| 525 | .replace(/\s*t3code\/[\d.]+/, ""); | 517 | .replace(/\s*t3code\/[\d.]+/, ""); |
| 526 | sess.setUserAgent(ua); | 518 | sess.setUserAgent(ua); |
| ... | @@ -593,9 +585,15 @@ export class PreviewViewManager { | ... | @@ -593,9 +585,15 @@ export class PreviewViewManager { |
| 593 | await wc.loadURL(url); | 585 | await wc.loadURL(url); |
| 594 | } | 586 | } |
| 595 | 587 | ||
| 596 | goBack(tabId: string): void { this.requireWebContents(tabId).navigationHistory.goBack(); } | 588 | goBack(tabId: string): void { |
| 597 | goForward(tabId: string): void { this.requireWebContents(tabId).navigationHistory.goForward(); } | 589 | this.requireWebContents(tabId).navigationHistory.goBack(); |
| 598 | refresh(tabId: string): void { this.requireWebContents(tabId).reload(); } | 590 | } |
| 591 | goForward(tabId: string): void { | ||
| 592 | this.requireWebContents(tabId).navigationHistory.goForward(); | ||
| 593 | } | ||
| 594 | refresh(tabId: string): void { | ||
| 595 | this.requireWebContents(tabId).reload(); | ||
| 596 | } | ||
| 599 | 597 | ||
| 600 | onStateChange(listener: Listener): () => void { | 598 | onStateChange(listener: Listener): () => void { |
| 601 | this.listeners.add(listener); | 599 | this.listeners.add(listener); |
| ... | @@ -656,16 +654,17 @@ export class PreviewViewManager { | ... | @@ -656,16 +654,17 @@ export class PreviewViewManager { |
| 656 | if (input.type !== "keyDown") return false; | 654 | if (input.type !== "keyDown") return false; |
| 657 | // Mirror the t3code keybinding defaults that should always reach the main window. | 655 | // Mirror the t3code keybinding defaults that should always reach the main window. |
| 658 | const SHORTCUTS = [ | 656 | const SHORTCUTS = [ |
| 659 | { key: "j", meta: true, shift: true }, // preview.toggle | 657 | { key: "j", meta: true, shift: true }, // preview.toggle |
| 660 | { key: "k", meta: true, shift: false }, // commandPalette.toggle | 658 | { key: "k", meta: true, shift: false }, // commandPalette.toggle |
| 661 | { key: ",", meta: true, shift: false }, // settings | 659 | { key: ",", meta: true, shift: false }, // settings |
| 662 | { key: "w", meta: true, shift: false }, // close | 660 | { key: "w", meta: true, shift: false }, // close |
| 663 | // future: terminal.* if user wants them while preview focused | 661 | // future: terminal.* if user wants them while preview focused |
| 664 | ]; | 662 | ]; |
| 665 | return SHORTCUTS.some((s) => | 663 | return SHORTCUTS.some( |
| 666 | s.key.toLowerCase() === input.key.toLowerCase() | 664 | (s) => |
| 667 | && s.meta === input.meta | 665 | s.key.toLowerCase() === input.key.toLowerCase() && |
| 668 | && s.shift === input.shift, | 666 | s.meta === input.meta && |
| 667 | s.shift === input.shift, | ||
| 669 | ); | 668 | ); |
| 670 | } | 669 | } |
| 671 | 670 | ||
| ... | @@ -704,9 +703,9 @@ export class PreviewViewManager { | ... | @@ -704,9 +703,9 @@ export class PreviewViewManager { |
| 704 | const trimmed = input.trim(); | 703 | const trimmed = input.trim(); |
| 705 | if (!trimmed) throw new PreviewInvalidUrlError(input); | 704 | if (!trimmed) throw new PreviewInvalidUrlError(input); |
| 706 | const useHttp = /^(localhost|127\.0\.0\.1|0\.0\.0\.0)(:|\/|$)/.test(trimmed); | 705 | const useHttp = /^(localhost|127\.0\.0\.1|0\.0\.0\.0)(:|\/|$)/.test(trimmed); |
| 707 | const parsed = new URL(trimmed.includes("://") | 706 | const parsed = new URL( |
| 708 | ? trimmed | 707 | trimmed.includes("://") ? trimmed : `${useHttp ? "http" : "https"}://${trimmed}`, |
| 709 | : `${useHttp ? "http" : "https"}://${trimmed}`); | 708 | ); |
| 710 | return parsed.href; | 709 | return parsed.href; |
| 711 | } | 710 | } |
| 712 | } | 711 | } |
| ... | @@ -716,9 +715,15 @@ export class PreviewTabNotFoundError extends Error { | ... | @@ -716,9 +715,15 @@ export class PreviewTabNotFoundError extends Error { |
| 716 | super(`Preview tab not found: ${tabId}`); | 715 | super(`Preview tab not found: ${tabId}`); |
| 717 | } | 716 | } |
| 718 | } | 717 | } |
| 719 | export class PreviewWebContentsNotFoundError extends Error { /* … */ } | 718 | export class PreviewWebContentsNotFoundError extends Error { |
| 720 | export class PreviewWebviewNotInitializedError extends Error { /* … */ } | 719 | /* … */ |
| 721 | export class PreviewInvalidUrlError extends Error { /* … */ } | 720 | } |
| 721 | export class PreviewWebviewNotInitializedError extends Error { | ||
| 722 | /* … */ | ||
| 723 | } | ||
| 724 | export class PreviewInvalidUrlError extends Error { | ||
| 725 | /* … */ | ||
| 726 | } | ||
| 722 | 727 | ||
| 723 | export const previewViewManager = new PreviewViewManager(); | 728 | export const previewViewManager = new PreviewViewManager(); |
| 724 | ``` | 729 | ``` |
| ... | @@ -734,18 +739,20 @@ previewViewManager.setMainWindow(mainWindow); | ... | @@ -734,18 +739,20 @@ previewViewManager.setMainWindow(mainWindow); |
| 734 | Register IPC handlers (in `registerIpcHandlers()`): | 739 | Register IPC handlers (in `registerIpcHandlers()`): |
| 735 | 740 | ||
| 736 | ```ts | 741 | ```ts |
| 737 | ipcMain.handle("preview:createTab", (_e, tabId: string) => | 742 | ipcMain.handle("preview:createTab", (_e, tabId: string) => previewViewManager.createTab(tabId)); |
| 738 | previewViewManager.createTab(tabId)); | 743 | ipcMain.handle("preview:closeTab", (_e, tabId: string) => previewViewManager.closeTab(tabId)); |
| 739 | ipcMain.handle("preview:closeTab", (_e, tabId: string) => | ||
| 740 | previewViewManager.closeTab(tabId)); | ||
| 741 | ipcMain.handle("preview:setVisibility", (_e, tabId: string, visible: boolean) => | 744 | ipcMain.handle("preview:setVisibility", (_e, tabId: string, visible: boolean) => |
| 742 | previewViewManager.setVisibility(tabId, visible)); | 745 | previewViewManager.setVisibility(tabId, visible), |
| 746 | ); | ||
| 743 | ipcMain.handle("preview:registerWebview", (_e, tabId: string, wcId: number) => | 747 | ipcMain.handle("preview:registerWebview", (_e, tabId: string, wcId: number) => |
| 744 | previewViewManager.registerWebview(tabId, wcId)); | 748 | previewViewManager.registerWebview(tabId, wcId), |
| 749 | ); | ||
| 745 | ipcMain.handle("preview:unregisterWebview", (_e, tabId: string) => | 750 | ipcMain.handle("preview:unregisterWebview", (_e, tabId: string) => |
| 746 | previewViewManager.unregisterWebview(tabId)); | 751 | previewViewManager.unregisterWebview(tabId), |
| 752 | ); | ||
| 747 | ipcMain.handle("preview:navigate", (_e, tabId: string, url: string) => | 753 | ipcMain.handle("preview:navigate", (_e, tabId: string, url: string) => |
| 748 | previewViewManager.navigate(tabId, url)); | 754 | previewViewManager.navigate(tabId, url), |
| 755 | ); | ||
| 749 | ipcMain.handle("preview:goBack", (_e, tabId: string) => previewViewManager.goBack(tabId)); | 756 | ipcMain.handle("preview:goBack", (_e, tabId: string) => previewViewManager.goBack(tabId)); |
| 750 | ipcMain.handle("preview:goForward", (_e, tabId: string) => previewViewManager.goForward(tabId)); | 757 | ipcMain.handle("preview:goForward", (_e, tabId: string) => previewViewManager.goForward(tabId)); |
| 751 | ipcMain.handle("preview:refresh", (_e, tabId: string) => previewViewManager.refresh(tabId)); | 758 | ipcMain.handle("preview:refresh", (_e, tabId: string) => previewViewManager.refresh(tabId)); |
| ... | @@ -776,16 +783,13 @@ contextBridge.exposeInMainWorld("desktopBridge", { | ... | @@ -776,16 +783,13 @@ contextBridge.exposeInMainWorld("desktopBridge", { |
| 776 | ipcRenderer.invoke("preview:setVisibility", tabId, visible), | 783 | ipcRenderer.invoke("preview:setVisibility", tabId, visible), |
| 777 | registerWebview: (tabId: string, wcId: number) => | 784 | registerWebview: (tabId: string, wcId: number) => |
| 778 | ipcRenderer.invoke("preview:registerWebview", tabId, wcId), | 785 | ipcRenderer.invoke("preview:registerWebview", tabId, wcId), |
| 779 | unregisterWebview: (tabId: string) => | 786 | unregisterWebview: (tabId: string) => ipcRenderer.invoke("preview:unregisterWebview", tabId), |
| 780 | ipcRenderer.invoke("preview:unregisterWebview", tabId), | 787 | navigate: (tabId: string, url: string) => ipcRenderer.invoke("preview:navigate", tabId, url), |
| 781 | navigate: (tabId: string, url: string) => | ||
| 782 | ipcRenderer.invoke("preview:navigate", tabId, url), | ||
| 783 | goBack: (tabId: string) => ipcRenderer.invoke("preview:goBack", tabId), | 788 | goBack: (tabId: string) => ipcRenderer.invoke("preview:goBack", tabId), |
| 784 | goForward: (tabId: string) => ipcRenderer.invoke("preview:goForward", tabId), | 789 | goForward: (tabId: string) => ipcRenderer.invoke("preview:goForward", tabId), |
| 785 | refresh: (tabId: string) => ipcRenderer.invoke("preview:refresh", tabId), | 790 | refresh: (tabId: string) => ipcRenderer.invoke("preview:refresh", tabId), |
| 786 | getPreloadPath: (): Promise<string> => ipcRenderer.invoke("preview:getPreloadPath"), | 791 | getPreloadPath: (): Promise<string> => ipcRenderer.invoke("preview:getPreloadPath"), |
| 787 | getBrowserPartition: (): Promise<string> => | 792 | getBrowserPartition: (): Promise<string> => ipcRenderer.invoke("preview:getBrowserPartition"), |
| 788 | ipcRenderer.invoke("preview:getBrowserPartition"), | ||
| 789 | onStateChange: (cb: (tabId: string, state: DesktopPreviewTabState) => void) => { | 793 | onStateChange: (cb: (tabId: string, state: DesktopPreviewTabState) => void) => { |
| 790 | const listener = (_e: unknown, tabId: string, state: DesktopPreviewTabState) => | 794 | const listener = (_e: unknown, tabId: string, state: DesktopPreviewTabState) => |
| 791 | cb(tabId, state); | 795 | cb(tabId, state); |
| ... | @@ -1096,14 +1100,14 @@ Adding `preview.toggle`, `preview.refresh`, `preview.focusUrl` to `STATIC_KEYBIN | ... | @@ -1096,14 +1100,14 @@ Adding `preview.toggle`, `preview.refresh`, `preview.focusUrl` to `STATIC_KEYBIN |
| 1096 | 1100 | ||
| 1097 | Following t3code's vitest patterns (`bun run test`, never `bun test`): | 1101 | Following t3code's vitest patterns (`bun run test`, never `bun test`): |
| 1098 | 1102 | ||
| 1099 | | Test file | What it covers | | 1103 | | Test file | What it covers | |
| 1100 | |---|---| | 1104 | | -------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | |
| 1101 | | `packages/contracts/src/preview.test.ts` | Schema encode/decode round-trips for inputs, snapshot, events; URL trimming; error tagged unions | | 1105 | | `packages/contracts/src/preview.test.ts` | Schema encode/decode round-trips for inputs, snapshot, events; URL trimming; error tagged unions | |
| 1102 | | `apps/server/src/preview/Layers/Manager.test.ts` | `open` creates session and emits `opened`; `navigate` updates and emits `navigated`; `close` removes and emits `closed`; subscribers receive monotonic events; `list` returns sorted snapshots | | 1106 | | `apps/server/src/preview/Layers/Manager.test.ts` | `open` creates session and emits `opened`; `navigate` updates and emits `navigated`; `close` removes and emits `closed`; subscribers receive monotonic events; `list` returns sorted snapshots | |
| 1103 | | `apps/web/src/previewStateStore.test.ts` | `applyServerEvent` reducer correctness; ring buffer cap; `closed` removes entry; `desktopOverlay` is independent of snapshot fields | | 1107 | | `apps/web/src/previewStateStore.test.ts` | `applyServerEvent` reducer correctness; ring buffer cap; `closed` removes entry; `desktopOverlay` is independent of snapshot fields | |
| 1104 | | `apps/web/src/rightPanelStore.test.ts` | `open` / `close` / `toggle` semantics; per-thread isolation; `?diff=1` sync compatibility | | 1108 | | `apps/web/src/rightPanelStore.test.ts` | `open` / `close` / `toggle` semantics; per-thread isolation; `?diff=1` sync compatibility | |
| 1105 | | `apps/web/src/components/preview/PreviewView.test.ts` (logic-only via `PreviewView.logic.ts` extraction) | URL bar input → navigation; navigation button enabled-state derivation; visibility toggle on panel hide | | 1109 | | `apps/web/src/components/preview/PreviewView.test.ts` (logic-only via `PreviewView.logic.ts` extraction) | URL bar input → navigation; navigation button enabled-state derivation; visibility toggle on panel hide | |
| 1106 | | Existing `ThreadTerminalDrawer.test.ts` | Add a case: link activation with `kind: "url"` and `previewable: true` shows the context menu (mock `localApi.contextMenu.show`) | | 1110 | | Existing `ThreadTerminalDrawer.test.ts` | Add a case: link activation with `kind: "url"` and `previewable: true` shows the context menu (mock `localApi.contextMenu.show`) | |
| 1107 | 1111 | ||
| 1108 | Manual smoke checklist (drop in `apps/desktop/test/smoke/`): | 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,15 +1124,15 @@ Manual smoke checklist (drop in `apps/desktop/test/smoke/`): |
| 1120 | 1124 | ||
| 1121 | ## 12. Risks and resolutions | 1125 | ## 12. Risks and resolutions |
| 1122 | 1126 | ||
| 1123 | | Risk | Resolution | | 1127 | | Risk | Resolution | |
| 1124 | |---|---| | 1128 | | ---------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | |
| 1125 | | Hidden `<webview>` GPU cost when keeping multiple threads' previews mounted | v1 only mounts the active thread's preview. v2 can adopt the `PersistentThreadTerminalDrawer` pattern + `setVisibility` | | 1129 | | Hidden `<webview>` GPU cost when keeping multiple threads' previews mounted | v1 only mounts the active thread's preview. v2 can adopt the `PersistentThreadTerminalDrawer` pattern + `setVisibility` | |
| 1126 | | `<webview>` keyboard capture eats `mod+shift+j` etc. | `before-input-event` forwarder in `PreviewViewManager` (mirror of ami's pattern, smaller shortcut list) | | 1130 | | `<webview>` keyboard capture eats `mod+shift+j` etc. | `before-input-event` forwarder in `PreviewViewManager` (mirror of ami's pattern, smaller shortcut list) | |
| 1127 | | URL with `X-Frame-Options: DENY` works fine in `<webview>` (good — that's why we picked `<webview>` over iframe) | n/a | | 1131 | | URL with `X-Frame-Options: DENY` works fine in `<webview>` (good — that's why we picked `<webview>` over iframe) | n/a | |
| 1128 | | Server restart while desktop is alive: `<webview>` is still loaded but server has no record | On WS reconnect, web side sends a `preview.list(threadId)` and if empty, sends a `preview.open(...)` to re‑register the current URL. Add a small `useReconciliation` effect in `PreviewView.tsx` | | 1132 | | Server restart while desktop is alive: `<webview>` is still loaded but server has no record | On WS reconnect, web side sends a `preview.list(threadId)` and if empty, sends a `preview.open(...)` to re‑register the current URL. Add a small `useReconciliation` effect in `PreviewView.tsx` | |
| 1129 | | Renderer process renders something into `<webview>` but never registered → orphaned tab in `PreviewViewManager` | `closeTab` is idempotent; on `<webview>` unmount, `unregisterWebview` is called; if that didn't fire (crash), the next `createTab` for the same `tabId` reuses the record | | 1133 | | Renderer process renders something into `<webview>` but never registered → orphaned tab in `PreviewViewManager` | `closeTab` is idempotent; on `<webview>` unmount, `unregisterWebview` is called; if that didn't fire (crash), the next `createTab` for the same `tabId` reuses the record | |
| 1130 | | `autoOpenPreview` racing with terminal output (URL might not be in stdout yet by the time the script "starts") | Two‑phase: if `script.previewUrl` is set, open eagerly with that URL. If not set but `autoOpenPreview === true`, watch terminal output via existing `terminal-links` extraction and open the first `previewable` URL within a 60s window | | 1134 | | `autoOpenPreview` racing with terminal output (URL might not be in stdout yet by the time the script "starts") | Two‑phase: if `script.previewUrl` is set, open eagerly with that URL. If not set but `autoOpenPreview === true`, watch terminal output via existing `terminal-links` extraction and open the first `previewable` URL within a 60s window | |
| 1131 | | Multiple windows of the desktop both rendering the same `<webview>` for the same thread | `<webview>` is per‑renderer; each window creates its own. The shared `persist:t3code-preview` partition keeps cookies in sync. Server records the last navigation URL but doesn't enforce single‑renderer | | 1135 | | Multiple windows of the desktop both rendering the same `<webview>` for the same thread | `<webview>` is per‑renderer; each window creates its own. The shared `persist:t3code-preview` partition keeps cookies in sync. Server records the last navigation URL but doesn't enforce single‑renderer | |
| 1132 | 1136 | ||
| 1133 | --- | 1137 | --- |
| 1134 | 1138 | ||
| ... | @@ -1155,38 +1159,43 @@ Any of these can land in follow-up PRs against the same `PreviewViewManager` + ` | ... | @@ -1155,38 +1159,43 @@ Any of these can land in follow-up PRs against the same `PreviewViewManager` + ` |
| 1155 | 1159 | ||
| 1156 | 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. | 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 | | 1162 | | Need | Primitive | File ref | Notes | |
| 1159 | |---|---|---|---| | 1163 | | ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | |
| 1160 | | Theme colors / radii | CSS vars `--background`, `--card`, `--muted`, `--muted-foreground`, `--border`, `--input`, `--ring`, `--success`, etc. | `apps/web/src/index.css:86` | Light + `@variant dark` blocks. Always reference vars via tailwind utilities (`bg-card`, `text-muted-foreground`) — never hard‑code hex | | 1164 | | Theme colors / radii | CSS vars `--background`, `--card`, `--muted`, `--muted-foreground`, `--border`, `--input`, `--ring`, `--success`, etc. | `apps/web/src/index.css:86` | Light + `@variant dark` blocks. Always reference vars via tailwind utilities (`bg-card`, `text-muted-foreground`) — never hard‑code hex | |
| 1161 | | `cn` class merger | `cn(...inputs)` from `~/lib/utils` | `apps/web/src/lib/utils.ts:8` | Wraps `cx` + `tailwind-merge` | | 1165 | | `cn` class merger | `cn(...inputs)` from `~/lib/utils` | `apps/web/src/lib/utils.ts:8` | Wraps `cx` + `tailwind-merge` | |
| 1162 | | Buttons (chrome bar back/fwd/refresh) | `Button` `variant="ghost"` `size="icon-xs"` | `apps/web/src/components/ui/button.tsx` | `icon-xs` is `size-7 rounded-md sm:size-6` — exactly the density in the screenshot | | 1166 | | Buttons (chrome bar back/fwd/refresh) | `Button` `variant="ghost"` `size="icon-xs"` | `apps/web/src/components/ui/button.tsx` | `icon-xs` is `size-7 rounded-md sm:size-6` — exactly the density in the screenshot | |
| 1163 | | Buttons (URL field submit) | `Button` `variant="outline"` `size="sm"` | same | Matches the "ProjectScript primary action" density already in `BranchToolbar` | | 1167 | | Buttons (URL field submit) | `Button` `variant="outline"` `size="sm"` | same | Matches the "ProjectScript primary action" density already in `BranchToolbar` | |
| 1164 | | Button group (back / fwd / refresh as a unit) | `Group` + implicit segmenting (no `GroupSeparator`) | `apps/web/src/components/ui/group.tsx` | Group automatically removes outer borders between adjacent `[data-slot]` children | | 1168 | | Button group (back / fwd / refresh as a unit) | `Group` + implicit segmenting (no `GroupSeparator`) | `apps/web/src/components/ui/group.tsx` | Group automatically removes outer borders between adjacent `[data-slot]` children | |
| 1165 | | URL input (chrome bar editable) | `InputGroup` + `InputGroupInput` + `InputGroupAddon align="inline-start"` (globe icon) | `apps/web/src/components/ui/input-group.tsx` | Click‑anywhere‑to‑focus already wired in `InputGroupAddon`'s `onMouseDown` | | 1169 | | URL input (chrome bar editable) | `InputGroup` + `InputGroupInput` + `InputGroupAddon align="inline-start"` (globe icon) | `apps/web/src/components/ui/input-group.tsx` | Click‑anywhere‑to‑focus already wired in `InputGroupAddon`'s `onMouseDown` | |
| 1166 | | URL input (chrome bar disabled / read‑only) | Same `InputGroup` with `disabled` on the input | same | Yields the muted look in the screenshot via `has-[input:disabled]:opacity-64` | | 1170 | | URL input (chrome bar disabled / read‑only) | Same `InputGroup` with `disabled` on the input | same | Yields the muted look in the screenshot via `has-[input:disabled]:opacity-64` | |
| 1167 | | Tab strip cells | Plain `<button>` + `cn` — **not** `Tabs` (base‑ui Tabs is over‑featured for browser tabs); `Group` semantics differ | new file `PreviewTabStrip.tsx` | Same hover/active treatment as the sidebar's `ChatPreview` rows | | 1171 | | Tab strip cells | Plain `<button>` + `cn` — **not** `Tabs` (base‑ui Tabs is over‑featured for browser tabs); `Group` semantics differ | new file `PreviewTabStrip.tsx` | Same hover/active treatment as the sidebar's `ChatPreview` rows | |
| 1168 | | "Local" recommendation card | `Card` (no `CardHeader`/`CardPanel` — flat) | `apps/web/src/components/ui/card.tsx` | `Card` already gives `rounded-2xl border bg-card text-card-foreground shadow-xs/5` plus the inset stroke via `before:` | | 1172 | | "Local" recommendation card | `Card` (no `CardHeader`/`CardPanel` — flat) | `apps/web/src/components/ui/card.tsx` | `Card` already gives `rounded-2xl border bg-card text-card-foreground shadow-xs/5` plus the inset stroke via `before:` | |
| 1169 | | Status pulse dot (running) | `<span>` with `bg-success` and `animate-pulse` | tailwind built‑in | Theme‑aware via `--success` | | 1173 | | Status pulse dot (running) | `<span>` with `bg-success` and `animate-pulse` | tailwind built‑in | Theme‑aware via `--success` | |
| 1170 | | Tooltips (back/fwd/refresh hover) | `Tooltip` + `TooltipTrigger` + `TooltipPopup` | `apps/web/src/components/ui/tooltip.tsx` | Sub‑200ms hover already configured | | 1174 | | Tooltips (back/fwd/refresh hover) | `Tooltip` + `TooltipTrigger` + `TooltipPopup` | `apps/web/src/components/ui/tooltip.tsx` | Sub‑200ms hover already configured | |
| 1171 | | Empty state title + description | `Empty`, `EmptyHeader`, `EmptyTitle`, `EmptyDescription`, `EmptyContent`, `EmptyMedia` | `apps/web/src/components/ui/empty.tsx` | `EmptyMedia variant="icon"` gives the stacked‑cards icon with side rotation we want for "no preview yet" | | 1175 | | Empty state title + description | `Empty`, `EmptyHeader`, `EmptyTitle`, `EmptyDescription`, `EmptyContent`, `EmptyMedia` | `apps/web/src/components/ui/empty.tsx` | `EmptyMedia variant="icon"` gives the stacked‑cards icon with side rotation we want for "no preview yet" | |
| 1172 | | Loading bar (top of webview while navigating) | Plain `<div>` with `bg-primary` + `transition-all` | n/a | Mirrors ami's loading bar (`browser-view.tsx:434`) — single‑pixel‑height div animating `width` | | 1176 | | Loading bar (top of webview while navigating) | Plain `<div>` with `bg-primary` + `transition-all` | n/a | Mirrors ami's loading bar (`browser-view.tsx:434`) — single‑pixel‑height div animating `width` | |
| 1173 | | Spinner (inline in URL field while loading) | `Spinner` | `apps/web/src/components/ui/spinner.tsx` | Wraps `Loader2Icon` from lucide | | 1177 | | Spinner (inline in URL field while loading) | `Spinner` | `apps/web/src/components/ui/spinner.tsx` | Wraps `Loader2Icon` from lucide | |
| 1174 | | Context menu (terminal link "Open in preview") | `localApi.contextMenu.show(...)` (already exists) | `apps/web/src/localApi.ts:58` | Native menu on desktop, fallback in `contextMenuFallback.ts` on web | | 1178 | | Context menu (terminal link "Open in preview") | `localApi.contextMenu.show(...)` (already exists) | `apps/web/src/localApi.ts:58` | Native menu on desktop, fallback in `contextMenuFallback.ts` on web | |
| 1175 | | Keybinding shortcut label in tooltips | `shortcutLabelForCommand(keybindings, "preview.toggle")` | `apps/web/src/keybindings.ts` | Renders `⌘⇧J` etc. | | 1179 | | Keybinding shortcut label in tooltips | `shortcutLabelForCommand(keybindings, "preview.toggle")` | `apps/web/src/keybindings.ts` | Renders `⌘⇧J` etc. | |
| 1176 | | Right‑panel shell sizing | Match `DiffPanelShell` exactly: `w-[42vw] min-w-[360px] max-w-[560px] shrink-0 border-l border-border` | `apps/web/src/components/DiffPanelShell.tsx:32` | Single class string, do not reinvent | | 1180 | | Right‑panel shell sizing | Match `DiffPanelShell` exactly: `w-[42vw] min-w-[360px] max-w-[560px] shrink-0 border-l border-border` | `apps/web/src/components/DiffPanelShell.tsx:32` | Single class string, do not reinvent | |
| 1177 | 1181 | ||
| 1178 | **Icons.** Use `lucide-react` everywhere (already in `package.json`). The exact icons we need: | 1182 | **Icons.** Use `lucide-react` everywhere (already in `package.json`). The exact icons we need: |
| 1179 | 1183 | ||
| 1180 | ```ts | 1184 | ```ts |
| 1181 | import { | 1185 | import { |
| 1182 | ArrowLeft, ArrowRight, // back / forward | 1186 | ArrowLeft, |
| 1183 | RotateCw, Loader2, // refresh / spinning | 1187 | ArrowRight, // back / forward |
| 1184 | Globe, // favicon fallback | 1188 | RotateCw, |
| 1185 | X, // close tab | 1189 | Loader2, // refresh / spinning |
| 1186 | Plus, // new tab | 1190 | Globe, // favicon fallback |
| 1187 | Copy, ExternalLink, // open in browser, copy URL | 1191 | X, // close tab |
| 1188 | Sidebar, Columns2, // right‑side toggles in tab strip (matches screenshot 2) | 1192 | Plus, // new tab |
| 1189 | PanelRightOpen, PanelRightClose, // alt for above | 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 | } from "lucide-react"; | 1199 | } from "lucide-react"; |
| 1191 | ``` | 1200 | ``` |
| 1192 | 1201 | ||
| ... | @@ -1200,11 +1209,11 @@ import { | ... | @@ -1200,11 +1209,11 @@ import { |
| 1200 | 1209 | ||
| 1201 | Three states render inside `PreviewView.tsx` based on the `PreviewSession` for the active thread: | 1210 | Three states render inside `PreviewView.tsx` based on the `PreviewSession` for the active thread: |
| 1202 | 1211 | ||
| 1203 | | Session state | Component rendered | | 1212 | | Session state | Component rendered | |
| 1204 | |---|---| | 1213 | | --------------------- | ----------------------------------------------------------------------- | |
| 1205 | | no tab open | `<PreviewEmptyState />` — disabled chrome row + "Local" recommendations | | 1214 | | no tab open | `<PreviewEmptyState />` — disabled chrome row + "Local" recommendations | |
| 1206 | | tab loading / loaded | `<PreviewWebview />` + chrome row + tab strip | | 1215 | | tab loading / loaded | `<PreviewWebview />` + chrome row + tab strip | |
| 1207 | | tab navigation failed | `<PreviewUnreachable />` overlay above the webview | | 1216 | | tab navigation failed | `<PreviewUnreachable />` overlay above the webview | |
| 1208 | 1217 | ||
| 1209 | ### 15.1 Empty state (`PreviewEmptyState.tsx`) — matches screenshot 1 | 1218 | ### 15.1 Empty state (`PreviewEmptyState.tsx`) — matches screenshot 1 |
| 1210 | 1219 | ||
| ... | @@ -1259,9 +1268,7 @@ export function PreviewEmptyState({ threadId, onOpen }: Props) { | ... | @@ -1259,9 +1268,7 @@ export function PreviewEmptyState({ threadId, onOpen }: Props) { |
| 1259 | return ( | 1268 | return ( |
| 1260 | <div className="flex h-full min-h-0 flex-col"> | 1269 | <div className="flex h-full min-h-0 flex-col"> |
| 1261 | <div className="px-6 pt-10 pb-4"> | 1270 | <div className="px-6 pt-10 pb-4"> |
| 1262 | <h2 className="text-xs font-medium uppercase tracking-wide text-muted-foreground"> | 1271 | <h2 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Local</h2> |
| 1263 | Local | ||
| 1264 | </h2> | ||
| 1265 | </div> | 1272 | </div> |
| 1266 | 1273 | ||
| 1267 | {servers.length === 0 ? ( | 1274 | {servers.length === 0 ? ( |
| ... | @@ -1308,10 +1315,7 @@ function LocalServerCard({ | ... | @@ -1308,10 +1315,7 @@ function LocalServerCard({ |
| 1308 | {server.processName ?? `${server.host}:${server.port}`} | 1315 | {server.processName ?? `${server.host}:${server.port}`} |
| 1309 | </span> | 1316 | </span> |
| 1310 | </div> | 1317 | </div> |
| 1311 | <span | 1318 | <span className="size-2 shrink-0 rounded-full bg-success" aria-label="Listening"> |
| 1312 | className="size-2 shrink-0 rounded-full bg-success" | ||
| 1313 | aria-label="Listening" | ||
| 1314 | > | ||
| 1315 | <span className="block size-2 rounded-full bg-success animate-ping opacity-60" /> | 1319 | <span className="block size-2 rounded-full bg-success animate-ping opacity-60" /> |
| 1316 | </span> | 1320 | </span> |
| 1317 | </Card> | 1321 | </Card> |
| ... | @@ -1362,8 +1366,8 @@ The chrome row in the empty state — note the `disabled` props on every interac | ... | @@ -1362,8 +1366,8 @@ The chrome row in the empty state — note the `disabled` props on every interac |
| 1362 | onForward={NOOP} | 1366 | onForward={NOOP} |
| 1363 | onRefresh={NOOP} | 1367 | onRefresh={NOOP} |
| 1364 | onSubmitUrl={(url) => api.preview.open({ threadId, url })} | 1368 | onSubmitUrl={(url) => api.preview.open({ threadId, url })} |
| 1365 | inputDisabled={false} // we still want to allow paste-to-open | 1369 | inputDisabled={false} // we still want to allow paste-to-open |
| 1366 | buttonsDisabled // back/fwd/refresh are visually disabled | 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,17 +1572,17 @@ When `navStatus._tag === "LoadFailed"`, render this instead of the failed `<webv |
| 1568 | 1572 | ||
| 1569 | Color mapping (Chromium → t3code theme): | 1573 | Color mapping (Chromium → t3code theme): |
| 1570 | 1574 | ||
| 1571 | | Chromium | Tailwind/t3code | | 1575 | | Chromium | Tailwind/t3code | |
| 1572 | |---|---| | 1576 | | ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | |
| 1573 | | `--background-color: #fff` / `--google-gray-900` (dark) | `bg-background` | | 1577 | | `--background-color: #fff` / `--google-gray-900` (dark) | `bg-background` | |
| 1574 | | `--text-color: --google-gray-700` / `--google-gray-500` (dark) | `text-muted-foreground` | | 1578 | | `--text-color: --google-gray-700` / `--google-gray-500` (dark) | `text-muted-foreground` | |
| 1575 | | `--heading-color: --google-gray-900` / `--google-gray-500` (dark) | `text-foreground` | | 1579 | | `--heading-color: --google-gray-900` / `--google-gray-500` (dark) | `text-foreground` | |
| 1576 | | `--error-code-color: --google-gray-700` / `--google-gray-500` (dark) | `text-muted-foreground/70` | | 1580 | | `--error-code-color: --google-gray-700` / `--google-gray-500` (dark) | `text-muted-foreground/70` | |
| 1577 | | `--quiet-background-color: rgb(247,247,247)` / `--background` (dark) | `bg-muted/40` | | 1581 | | `--quiet-background-color: rgb(247,247,247)` / `--background` (dark) | `bg-muted/40` | |
| 1578 | | `--primary-button-fill-color: --google-blue-600` / `--google-blue-300` (dark) | `bg-primary text-primary-foreground` (theme primary is `oklch(0.488 0.217 264)` light / `oklch(0.588 0.217 264)` dark — a very close blue) | | 1582 | | `--primary-button-fill-color: --google-blue-600` / `--google-blue-300` (dark) | `bg-primary text-primary-foreground` (theme primary is `oklch(0.488 0.217 264)` light / `oklch(0.588 0.217 264)` dark — a very close blue) | |
| 1579 | | `--secondary-button-*` | `Button variant="outline"` | | 1583 | | `--secondary-button-*` | `Button variant="outline"` | |
| 1580 | | `--link-color: rgb(88,88,88)` / `--google-blue-300` (dark) | `text-primary underline-offset-4 hover:underline` | | 1584 | | `--link-color: rgb(88,88,88)` / `--google-blue-300` (dark) | `text-primary underline-offset-4 hover:underline` | |
| 1581 | | Body font `system-ui, sans-serif; font-size: 75%` | We keep DM Sans (already in `apps/web/src/index.css:148`) and `text-sm` — the Chromium font tweak just compensates for their `html { font-size: 125% }` — we don't replicate that | | 1585 | | Body font `system-ui, sans-serif; font-size: 75%` | We keep DM Sans (already in `apps/web/src/index.css:148`) and `text-sm` — the Chromium font tweak just compensates for their `html { font-size: 125% }` — we don't replicate that | |
| 1582 | 1586 | ||
| 1583 | Skeleton: | 1587 | Skeleton: |
| 1584 | 1588 | ||
| ... | @@ -1592,7 +1596,7 @@ import { cn } from "~/lib/utils"; | ... | @@ -1592,7 +1596,7 @@ import { cn } from "~/lib/utils"; |
| 1592 | 1596 | ||
| 1593 | interface Props { | 1597 | interface Props { |
| 1594 | url: string; | 1598 | url: string; |
| 1595 | errorCode: string; // e.g. "ERR_NAME_NOT_RESOLVED", "ERR_CONNECTION_REFUSED" | 1599 | errorCode: string; // e.g. "ERR_NAME_NOT_RESOLVED", "ERR_CONNECTION_REFUSED" |
| 1596 | description: string; | 1600 | description: string; |
| 1597 | onReload: () => void; | 1601 | onReload: () => void; |
| 1598 | } | 1602 | } |
| ... | @@ -1601,7 +1605,13 @@ const ICON_GENERIC = ( | ... | @@ -1601,7 +1605,13 @@ const ICON_GENERIC = ( |
| 1601 | // Replace with an inline SVG that matches the Chromium "icon-generic" | 1605 | // Replace with an inline SVG that matches the Chromium "icon-generic" |
| 1602 | // (a stylized broken page). For v1 a Lucide MapPinOff is a fine stand-in — | 1606 | // (a stylized broken page). For v1 a Lucide MapPinOff is a fine stand-in — |
| 1603 | // we want a visual that reads "destination unreachable". | 1607 | // we want a visual that reads "destination unreachable". |
| 1604 | <svg viewBox="0 0 64 64" className="size-10 text-muted-foreground/70" fill="none" stroke="currentColor" strokeWidth="2.5"> | 1608 | <svg |
| 1609 | viewBox="0 0 64 64" | ||
| 1610 | className="size-10 text-muted-foreground/70" | ||
| 1611 | fill="none" | ||
| 1612 | stroke="currentColor" | ||
| 1613 | strokeWidth="2.5" | ||
| 1614 | > | ||
| 1605 | <path d="M16 12 L48 12 L48 52 L16 52 Z" /> | 1615 | <path d="M16 12 L48 12 L48 52 L16 52 Z" /> |
| 1606 | <path d="M22 22 L42 22 M22 30 L36 30 M22 38 L40 38" strokeLinecap="round" /> | 1616 | <path d="M22 22 L42 22 M22 30 L36 30 M22 38 L40 38" strokeLinecap="round" /> |
| 1607 | <path d="M52 8 L12 56" strokeLinecap="round" /> | 1617 | <path d="M52 8 L12 56" strokeLinecap="round" /> |
| ... | @@ -1626,7 +1636,13 @@ export function PreviewUnreachable({ url, errorCode, description, onReload }: Pr | ... | @@ -1626,7 +1636,13 @@ export function PreviewUnreachable({ url, errorCode, description, onReload }: Pr |
| 1626 | {/* Summary — uses dangerouslySetInnerHTML only for the bold host treatment */} | 1636 | {/* Summary — uses dangerouslySetInnerHTML only for the bold host treatment */} |
| 1627 | <p className="text-sm leading-relaxed text-muted-foreground"> | 1637 | <p className="text-sm leading-relaxed text-muted-foreground"> |
| 1628 | <strong className="font-semibold text-foreground">{host}</strong> | 1638 | <strong className="font-semibold text-foreground">{host}</strong> |
| 1629 | &rsquo;s server <abbr title="Domain Name System" className="cursor-help underline decoration-dotted underline-offset-2">DNS address</abbr>{" "} | 1639 | &rsquo;s server{" "} |
| 1640 | <abbr | ||
| 1641 | title="Domain Name System" | ||
| 1642 | className="cursor-help underline decoration-dotted underline-offset-2" | ||
| 1643 | > | ||
| 1644 | DNS address | ||
| 1645 | </abbr>{" "} | ||
| 1630 | could not be found. | 1646 | could not be found. |
| 1631 | </p> | 1647 | </p> |
| 1632 | 1648 | ||
| ... | @@ -1668,11 +1684,16 @@ export function PreviewUnreachable({ url, errorCode, description, onReload }: Pr | ... | @@ -1668,11 +1684,16 @@ export function PreviewUnreachable({ url, errorCode, description, onReload }: Pr |
| 1668 | } | 1684 | } |
| 1669 | 1685 | ||
| 1670 | function safeHost(url: string): string | null { | 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 | The component intentionally: | 1695 | The component intentionally: |
| 1696 | |||
| 1676 | - Uses theme tokens only — looks correct in light + dark with no extra wiring. | 1697 | - Uses theme tokens only — looks correct in light + dark with no extra wiring. |
| 1677 | - Drops Chromium's "Diagnose connection" / "Portal sign-in" buttons (irrelevant in our shell). | 1698 | - Drops Chromium's "Diagnose connection" / "Portal sign-in" buttons (irrelevant in our shell). |
| 1678 | - Drops the dinosaur game (RIP). | 1699 | - Drops the dinosaur game (RIP). |
| ... | @@ -1683,15 +1704,17 @@ The component intentionally: | ... | @@ -1683,15 +1704,17 @@ The component intentionally: |
| 1683 | 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`): | 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 | ```tsx | 1706 | ```tsx |
| 1686 | {loadProgress > 0 && ( | 1707 | { |
| 1687 | <div | 1708 | loadProgress > 0 && ( |
| 1688 | className="pointer-events-none absolute bottom-0 left-0 z-10 h-0.5 rounded-full bg-primary transition-all duration-150 ease-out" | 1709 | <div |
| 1689 | style={{ | 1710 | className="pointer-events-none absolute bottom-0 left-0 z-10 h-0.5 rounded-full bg-primary transition-all duration-150 ease-out" |
| 1690 | width: `${loadProgress}%`, | 1711 | style={{ |
| 1691 | boxShadow: "0 0 6px 1px var(--ring)", | 1712 | width: `${loadProgress}%`, |
| 1692 | }} | 1713 | boxShadow: "0 0 6px 1px var(--ring)", |
| 1693 | /> | 1714 | }} |
| 1694 | )} | 1715 | /> |
| 1716 | ); | ||
| 1717 | } | ||
| 1695 | ``` | 1718 | ``` |
| 1696 | 1719 | ||
| 1697 | Progress is computed locally with `useLoadingProgress(isLoading)` — same hook signature as ami's, ~30 lines. | 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,9 +1731,9 @@ The "Local" recommendations in §15.1 need a feed. New backend service. |
| 1708 | import { Context, Effect } from "effect"; | 1731 | import { Context, Effect } from "effect"; |
| 1709 | 1732 | ||
| 1710 | export interface DiscoveredLocalServer { | 1733 | export interface DiscoveredLocalServer { |
| 1711 | host: string; // "localhost" | 1734 | host: string; // "localhost" |
| 1712 | port: number; // 5175 | 1735 | port: number; // 5175 |
| 1713 | url: string; // "http://localhost:5175" | 1736 | url: string; // "http://localhost:5175" |
| 1714 | processName: string | null; // "node", "vite", "next-server" | 1737 | processName: string | null; // "node", "vite", "next-server" |
| 1715 | pid: number | null; | 1738 | pid: number | null; |
| 1716 | } | 1739 | } |
| ... | @@ -1740,6 +1763,7 @@ Two strategies, picked at startup: | ... | @@ -1740,6 +1763,7 @@ Two strategies, picked at startup: |
| 1740 | 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. | 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 | Polling cadence: | 1765 | Polling cadence: |
| 1766 | |||
| 1743 | - When `retain()` count is 0 → not polling. | 1767 | - When `retain()` count is 0 → not polling. |
| 1744 | - When ≥1 → poll every 3s. Diff against last result; only emit `subscribe` callbacks when the set differs. | 1768 | - When ≥1 → poll every 3s. Diff against last result; only emit `subscribe` callbacks when the set differs. |
| 1745 | - `retain()` returns a release fn that decrements the counter; goes idle automatically when the empty state is hidden. | 1769 | - `retain()` returns a release fn that decrements the counter; goes idle automatically when the empty state is hidden. |
| ... | @@ -1762,20 +1786,20 @@ Deduped by URL string. Sort: configured > listening > recent. This yields the "s | ... | @@ -1762,20 +1786,20 @@ Deduped by URL string. Sort: configured > listening > recent. This yields the "s |
| 1762 | 1786 | ||
| 1763 | Append these to the file list: | 1787 | Append these to the file list: |
| 1764 | 1788 | ||
| 1765 | | File | Purpose | | 1789 | | File | Purpose | |
| 1766 | |---|---| | 1790 | | -------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | |
| 1767 | | `apps/server/src/preview/Services/PortScanner.ts` | Service tag + interface | | 1791 | | `apps/server/src/preview/Services/PortScanner.ts` | Service tag + interface | |
| 1768 | | `apps/server/src/preview/Layers/PortScanner.ts` | `lsof` strategy + TCP probe fallback + polling | | 1792 | | `apps/server/src/preview/Layers/PortScanner.ts` | `lsof` strategy + TCP probe fallback + polling | |
| 1769 | | `apps/server/src/preview/Layers/PortScanner.test.ts` | Parser tests for `lsof -F pcn` output | | 1793 | | `apps/server/src/preview/Layers/PortScanner.test.ts` | Parser tests for `lsof -F pcn` output | |
| 1770 | | `apps/web/src/lib/favicon.ts` | `faviconUrlForOrigin(url, size)` helper | | 1794 | | `apps/web/src/lib/favicon.ts` | `faviconUrlForOrigin(url, size)` helper | |
| 1771 | | `apps/web/src/components/preview/PreviewTabStrip.tsx` | Tab strip (screenshot 2) | | 1795 | | `apps/web/src/components/preview/PreviewTabStrip.tsx` | Tab strip (screenshot 2) | |
| 1772 | | `apps/web/src/components/preview/TabFavicon.tsx` | Favicon `<img>` w/ `<Globe>` fallback | | 1796 | | `apps/web/src/components/preview/TabFavicon.tsx` | Favicon `<img>` w/ `<Globe>` fallback | |
| 1773 | | `apps/web/src/components/preview/BrowserMockup.tsx` | Tiny tailwind browser thumbnail icon | | 1797 | | `apps/web/src/components/preview/BrowserMockup.tsx` | Tiny tailwind browser thumbnail icon | |
| 1774 | | `apps/web/src/components/preview/PreviewLocalServerCard.tsx` | Card row for a discovered server | | 1798 | | `apps/web/src/components/preview/PreviewLocalServerCard.tsx` | Card row for a discovered server | |
| 1775 | | `apps/web/src/components/preview/PreviewUnreachable.tsx` | 404.html rewritten in tailwind | | 1799 | | `apps/web/src/components/preview/PreviewUnreachable.tsx` | 404.html rewritten in tailwind | |
| 1776 | | `apps/web/src/components/preview/useDiscoveredLocalServers.ts` | Hook subscribing to `EnvironmentApi.preview.subscribePorts` + merging in `recentUrlsFromTerminal` and `ProjectScript.previewUrl` | | 1800 | | `apps/web/src/components/preview/useDiscoveredLocalServers.ts` | Hook subscribing to `EnvironmentApi.preview.subscribePorts` + merging in `recentUrlsFromTerminal` and `ProjectScript.previewUrl` | |
| 1777 | | `apps/web/src/components/preview/useLoadingProgress.ts` | 30-line progress simulator (port of ami's) | | 1801 | | `apps/web/src/components/preview/useLoadingProgress.ts` | 30-line progress simulator (port of ami's) | |
| 1778 | | `apps/web/src/components/preview/errorCodeMessages.ts` | `ERR_*` → human-readable description map | | 1802 | | `apps/web/src/components/preview/errorCodeMessages.ts` | `ERR_*` → human-readable description map | |
| 1779 | 1803 | ||
| 1780 | Update the contract additions in §4 to add: | 1804 | Update the contract additions in §4 to add: |
| 1781 | 1805 |
apps/desktop/src/ipc/DesktopIpcHandlers.ts+4| ... | @@ -48,6 +48,7 @@ import { | ... | @@ -48,6 +48,7 @@ import { |
| 48 | setTheme, | 48 | setTheme, |
| 49 | showContextMenu, | 49 | showContextMenu, |
| 50 | } from "./methods/window.ts"; | 50 | } from "./methods/window.ts"; |
| 51 | import { previewMethods } from "./methods/preview.ts"; | ||
| 51 | 52 | ||
| 52 | export const installDesktopIpcHandlers = Effect.gen(function* () { | 53 | export const installDesktopIpcHandlers = Effect.gen(function* () { |
| 53 | const ipc = yield* DesktopIpc.DesktopIpc; | 54 | const ipc = yield* DesktopIpc.DesktopIpc; |
| ... | @@ -92,4 +93,7 @@ export const installDesktopIpcHandlers = Effect.gen(function* () { | ... | @@ -92,4 +93,7 @@ export const installDesktopIpcHandlers = Effect.gen(function* () { |
| 92 | yield* ipc.handle(downloadUpdate); | 93 | yield* ipc.handle(downloadUpdate); |
| 93 | yield* ipc.handle(installUpdate); | 94 | yield* ipc.handle(installUpdate); |
| 94 | yield* ipc.handle(checkForUpdate); | 95 | yield* ipc.handle(checkForUpdate); |
| 96 | for (const previewMethod of previewMethods) { | ||
| 97 | yield* ipc.handle(previewMethod); | ||
| 98 | } | ||
| 95 | }).pipe(Effect.withSpan("desktop.ipc.installHandlers")); | 99 | }).pipe(Effect.withSpan("desktop.ipc.installHandlers")); |
apps/desktop/src/ipc/channels.ts+16| ... | @@ -39,3 +39,19 @@ export const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mod | ... | @@ -39,3 +39,19 @@ export const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mod |
| 39 | export const SET_TAILSCALE_SERVE_ENABLED_CHANNEL = "desktop:set-tailscale-serve-enabled"; | 39 | export const SET_TAILSCALE_SERVE_ENABLED_CHANNEL = "desktop:set-tailscale-serve-enabled"; |
| 40 | export const GET_ADVERTISED_ENDPOINTS_CHANNEL = "desktop:get-advertised-endpoints"; | 40 | export const GET_ADVERTISED_ENDPOINTS_CHANNEL = "desktop:get-advertised-endpoints"; |
| 41 | export const SSH_PASSWORD_PROMPT_CANCELLED_RESULT = "ssh-password-prompt-cancelled"; | 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,4 +1,4 @@ |
| 1 | import type { DesktopBridge } from "@t3tools/contracts"; | 1 | import type { DesktopBridge, DesktopPreviewTabState } from "@t3tools/contracts"; |
| 2 | import { contextBridge, ipcRenderer } from "electron"; | 2 | import { contextBridge, ipcRenderer } from "electron"; |
| 3 | 3 | ||
| 4 | import * as IpcChannels from "./ipc/channels.ts"; | 4 | import * as IpcChannels from "./ipc/channels.ts"; |
| ... | @@ -141,4 +141,37 @@ contextBridge.exposeInMainWorld("desktopBridge", { | ... | @@ -141,4 +141,37 @@ contextBridge.exposeInMainWorld("desktopBridge", { |
| 141 | ipcRenderer.removeListener(IpcChannels.UPDATE_STATE_CHANNEL, wrappedListener); | 141 | ipcRenderer.removeListener(IpcChannels.UPDATE_STATE_CHANNEL, wrappedListener); |
| 142 | }; | 142 | }; |
| 143 | }, | 143 | }, |
| 144 | preview: { | ||
| 145 | createTab: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_CREATE_TAB_CHANNEL, { tabId }), | ||
| 146 | closeTab: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_CLOSE_TAB_CHANNEL, { tabId }), | ||
| 147 | registerWebview: (tabId, webContentsId) => | ||
| 148 | ipcRenderer.invoke(IpcChannels.PREVIEW_REGISTER_WEBVIEW_CHANNEL, { tabId, webContentsId }), | ||
| 149 | navigate: (tabId, url) => | ||
| 150 | ipcRenderer.invoke(IpcChannels.PREVIEW_NAVIGATE_CHANNEL, { tabId, url }), | ||
| 151 | goBack: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_GO_BACK_CHANNEL, { tabId }), | ||
| 152 | goForward: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_GO_FORWARD_CHANNEL, { tabId }), | ||
| 153 | refresh: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_REFRESH_CHANNEL, { tabId }), | ||
| 154 | zoomIn: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_ZOOM_IN_CHANNEL, { tabId }), | ||
| 155 | zoomOut: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_ZOOM_OUT_CHANNEL, { tabId }), | ||
| 156 | resetZoom: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_RESET_ZOOM_CHANNEL, { tabId }), | ||
| 157 | hardReload: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_HARD_RELOAD_CHANNEL, { tabId }), | ||
| 158 | openDevTools: (tabId) => | ||
| 159 | ipcRenderer.invoke(IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, { tabId }), | ||
| 160 | clearCookies: () => ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL), | ||
| 161 | clearCache: () => ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_CACHE_CHANNEL), | ||
| 162 | getBrowserPartition: () => | ||
| 163 | ipcRenderer.invoke(IpcChannels.PREVIEW_GET_BROWSER_PARTITION_CHANNEL), | ||
| 164 | onStateChange: (listener) => { | ||
| 165 | const wrappedListener = ( | ||
| 166 | _event: Electron.IpcRendererEvent, | ||
| 167 | tabId: unknown, | ||
| 168 | state: unknown, | ||
| 169 | ) => { | ||
| 170 | if (typeof tabId !== "string" || typeof state !== "object" || state === null) return; | ||
| 171 | listener(tabId, state as DesktopPreviewTabState); | ||
| 172 | }; | ||
| 173 | ipcRenderer.on(IpcChannels.PREVIEW_STATE_CHANGE_CHANNEL, wrappedListener); | ||
| 174 | return () => ipcRenderer.removeListener(IpcChannels.PREVIEW_STATE_CHANGE_CHANNEL, wrappedListener); | ||
| 175 | }, | ||
| 176 | }, | ||
| 144 | } satisfies DesktopBridge); | 177 | } satisfies DesktopBridge); |
apps/desktop/src/preview-view-manager.ts created+455| ... | @@ -0,0 +1,455 @@ | ||
| 1 | /** | ||
| 2 | * PreviewViewManager — desktop side of the in-app browser preview. | ||
| 3 | * | ||
| 4 | * Hosts per-tab Chromium WebContents references (the actual <webview> | ||
| 5 | * elements live in the renderer; we only attach listeners and forward state | ||
| 6 | * here). Single layer-scoped browser session partition. | ||
| 7 | */ | ||
| 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,6 +11,7 @@ import * as DesktopAssets from "../app/DesktopAssets.ts"; |
| 11 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; | 11 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; |
| 12 | import * as DesktopObservability from "../app/DesktopObservability.ts"; | 12 | import * as DesktopObservability from "../app/DesktopObservability.ts"; |
| 13 | import * as DesktopState from "../app/DesktopState.ts"; | 13 | import * as DesktopState from "../app/DesktopState.ts"; |
| 14 | import { previewViewManager } from "../preview-view-manager.ts"; | ||
| 14 | import * as ElectronMenu from "../electron/ElectronMenu.ts"; | 15 | import * as ElectronMenu from "../electron/ElectronMenu.ts"; |
| 15 | import * as ElectronShell from "../electron/ElectronShell.ts"; | 16 | import * as ElectronShell from "../electron/ElectronShell.ts"; |
| 16 | import * as ElectronTheme from "../electron/ElectronTheme.ts"; | 17 | import * as ElectronTheme from "../electron/ElectronTheme.ts"; |
| ... | @@ -192,9 +193,12 @@ const make = Effect.gen(function* () { | ... | @@ -192,9 +193,12 @@ const make = Effect.gen(function* () { |
| 192 | contextIsolation: true, | 193 | contextIsolation: true, |
| 193 | nodeIntegration: false, | 194 | nodeIntegration: false, |
| 194 | sandbox: true, | 195 | sandbox: true, |
| 196 | webviewTag: true, | ||
| 195 | }, | 197 | }, |
| 196 | }); | 198 | }); |
| 197 | 199 | ||
| 200 | previewViewManager.setMainWindow(window); | ||
| 201 | |||
| 198 | window.webContents.on("context-menu", (event, params) => { | 202 | window.webContents.on("context-menu", (event, params) => { |
| 199 | event.preventDefault(); | 203 | event.preventDefault(); |
| 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,6 +20,7 @@ import { |
| 20 | type OrchestrationCommand, | 20 | type OrchestrationCommand, |
| 21 | type OrchestrationEvent, | 21 | type OrchestrationEvent, |
| 22 | ORCHESTRATION_WS_METHODS, | 22 | ORCHESTRATION_WS_METHODS, |
| 23 | type PreviewEvent, | ||
| 23 | ProjectId, | 24 | ProjectId, |
| 24 | ProviderDriverKind, | 25 | ProviderDriverKind, |
| 25 | ProviderInstanceId, | 26 | ProviderInstanceId, |
| ... | @@ -48,6 +49,7 @@ import * as Layer from "effect/Layer"; | ... | @@ -48,6 +49,7 @@ import * as Layer from "effect/Layer"; |
| 48 | import * as ManagedRuntime from "effect/ManagedRuntime"; | 49 | import * as ManagedRuntime from "effect/ManagedRuntime"; |
| 49 | import * as Option from "effect/Option"; | 50 | import * as Option from "effect/Option"; |
| 50 | import * as Path from "effect/Path"; | 51 | import * as Path from "effect/Path"; |
| 52 | import * as PubSub from "effect/PubSub"; | ||
| 51 | import * as Stream from "effect/Stream"; | 53 | import * as Stream from "effect/Stream"; |
| 52 | import { ChildProcessSpawner } from "effect/unstable/process"; | 54 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 53 | import { | 55 | import { |
| ... | @@ -97,6 +99,8 @@ import { ServerLifecycleEvents, type ServerLifecycleEventsShape } from "./server | ... | @@ -97,6 +99,8 @@ import { ServerLifecycleEvents, type ServerLifecycleEventsShape } from "./server |
| 97 | import { ServerRuntimeStartup, type ServerRuntimeStartupShape } from "./serverRuntimeStartup.ts"; | 99 | import { ServerRuntimeStartup, type ServerRuntimeStartupShape } from "./serverRuntimeStartup.ts"; |
| 98 | import { ServerSettingsService, type ServerSettingsShape } from "./serverSettings.ts"; | 100 | import { ServerSettingsService, type ServerSettingsShape } from "./serverSettings.ts"; |
| 99 | import { TerminalManager, type TerminalManagerShape } from "./terminal/Services/Manager.ts"; | 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 | import { | 104 | import { |
| 101 | BrowserTraceCollector, | 105 | BrowserTraceCollector, |
| 102 | type BrowserTraceCollectorShape, | 106 | type BrowserTraceCollectorShape, |
| ... | @@ -663,6 +667,27 @@ const buildAppUnderTest = (options?: { | ... | @@ -663,6 +667,27 @@ const buildAppUnderTest = (options?: { |
| 663 | ...options?.layers?.terminalManager, | 667 | ...options?.layers?.terminalManager, |
| 664 | }), | 668 | }), |
| 665 | ), | 669 | ), |
| 670 | Layer.provide( | ||
| 671 | Layer.mock(PreviewManager)({ | ||
| 672 | open: () => Effect.die("PreviewManager not stubbed in this test"), | ||
| 673 | navigate: () => Effect.die("PreviewManager not stubbed in this test"), | ||
| 674 | reportStatus: () => Effect.void, | ||
| 675 | refresh: () => Effect.void, | ||
| 676 | close: () => Effect.void, | ||
| 677 | list: () => Effect.succeed({ sessions: [] }), | ||
| 678 | events: Stream.empty, | ||
| 679 | subscribeEvents: Effect.flatMap(PubSub.unbounded<PreviewEvent>(), (pubsub) => | ||
| 680 | PubSub.subscribe(pubsub), | ||
| 681 | ), | ||
| 682 | }), | ||
| 683 | ), | ||
| 684 | Layer.provide( | ||
| 685 | Layer.mock(PreviewPortScanner)({ | ||
| 686 | scan: () => Effect.succeed([]), | ||
| 687 | subscribe: () => Effect.succeed(() => {}), | ||
| 688 | retain: () => Effect.succeed(() => {}), | ||
| 689 | }), | ||
| 690 | ), | ||
| 666 | Layer.provide( | 691 | Layer.provide( |
| 667 | Layer.mock(OrchestrationEngineService)({ | 692 | Layer.mock(OrchestrationEngineService)({ |
| 668 | readEvents: () => Stream.empty, | 693 | readEvents: () => Stream.empty, |
apps/server/src/server.ts+5| ... | @@ -35,6 +35,8 @@ import * as GitLabCli from "./sourceControl/GitLabCli.ts"; | ... | @@ -35,6 +35,8 @@ import * as GitLabCli from "./sourceControl/GitLabCli.ts"; |
| 35 | import * as TextGeneration from "./textGeneration/TextGeneration.ts"; | 35 | import * as TextGeneration from "./textGeneration/TextGeneration.ts"; |
| 36 | import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/ProviderInstanceRegistryHydration.ts"; | 36 | import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/ProviderInstanceRegistryHydration.ts"; |
| 37 | import { TerminalManagerLive } from "./terminal/Layers/Manager.ts"; | 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 | import * as GitManager from "./git/GitManager.ts"; | 40 | import * as GitManager from "./git/GitManager.ts"; |
| 39 | import { KeybindingsLive } from "./keybindings.ts"; | 41 | import { KeybindingsLive } from "./keybindings.ts"; |
| 40 | import { ServerRuntimeStartup, ServerRuntimeStartupLive } from "./serverRuntimeStartup.ts"; | 42 | import { ServerRuntimeStartup, ServerRuntimeStartupLive } from "./serverRuntimeStartup.ts"; |
| ... | @@ -233,6 +235,8 @@ const CheckpointingLayerLive = Layer.empty.pipe( | ... | @@ -233,6 +235,8 @@ const CheckpointingLayerLive = Layer.empty.pipe( |
| 233 | 235 | ||
| 234 | const TerminalLayerLive = TerminalManagerLive.pipe(Layer.provide(PtyAdapterLive)); | 236 | const TerminalLayerLive = TerminalManagerLive.pipe(Layer.provide(PtyAdapterLive)); |
| 235 | 237 | ||
| 238 | const PreviewLayerLive = Layer.mergeAll(PreviewManagerLive, PreviewPortScannerLive); | ||
| 239 | |||
| 236 | const WorkspaceEntriesLayerLive = WorkspaceEntriesLive.pipe( | 240 | const WorkspaceEntriesLayerLive = WorkspaceEntriesLive.pipe( |
| 237 | Layer.provide(WorkspacePathsLive), | 241 | Layer.provide(WorkspacePathsLive), |
| 238 | Layer.provideMerge(VcsDriverRegistryLayerLive), | 242 | Layer.provideMerge(VcsDriverRegistryLayerLive), |
| ... | @@ -275,6 +279,7 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( | ... | @@ -275,6 +279,7 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( |
| 275 | Layer.provideMerge(VcsLayerLive), | 279 | Layer.provideMerge(VcsLayerLive), |
| 276 | Layer.provideMerge(ProviderRuntimeLayerLive), | 280 | Layer.provideMerge(ProviderRuntimeLayerLive), |
| 277 | Layer.provideMerge(TerminalLayerLive), | 281 | Layer.provideMerge(TerminalLayerLive), |
| 282 | Layer.provideMerge(PreviewLayerLive), | ||
| 278 | Layer.provideMerge(PersistenceLayerLive), | 283 | Layer.provideMerge(PersistenceLayerLive), |
| 279 | Layer.provideMerge(KeybindingsLive), | 284 | Layer.provideMerge(KeybindingsLive), |
| 280 | Layer.provideMerge(ProviderRegistryLive), | 285 | Layer.provideMerge(ProviderRegistryLive), |
apps/server/src/ws.ts+62| ... | @@ -22,6 +22,7 @@ import { | ... | @@ -22,6 +22,7 @@ import { |
| 22 | type AuthEnvironmentScope, | 22 | type AuthEnvironmentScope, |
| 23 | AuthSessionId, | 23 | AuthSessionId, |
| 24 | CommandId, | 24 | CommandId, |
| 25 | type DiscoveredLocalServerList, | ||
| 25 | EventId, | 26 | EventId, |
| 26 | type OrchestrationCommand, | 27 | type OrchestrationCommand, |
| 27 | type GitActionProgressEvent, | 28 | type GitActionProgressEvent, |
| ... | @@ -70,6 +71,8 @@ import { ServerLifecycleEvents } from "./serverLifecycleEvents.ts"; | ... | @@ -70,6 +71,8 @@ import { ServerLifecycleEvents } from "./serverLifecycleEvents.ts"; |
| 70 | import { ServerRuntimeStartup } from "./serverRuntimeStartup.ts"; | 71 | import { ServerRuntimeStartup } from "./serverRuntimeStartup.ts"; |
| 71 | import { redactServerSettingsForClient, ServerSettingsService } from "./serverSettings.ts"; | 72 | import { redactServerSettingsForClient, ServerSettingsService } from "./serverSettings.ts"; |
| 72 | import { TerminalManager } from "./terminal/Services/Manager.ts"; | 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 | import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries.ts"; | 76 | import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries.ts"; |
| 74 | import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem.ts"; | 77 | import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem.ts"; |
| 75 | import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePaths.ts"; | 78 | import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePaths.ts"; |
| ... | @@ -240,6 +243,8 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => | ... | @@ -240,6 +243,8 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => |
| 240 | const vcsProvisioning = yield* VcsProvisioningService; | 243 | const vcsProvisioning = yield* VcsProvisioningService; |
| 241 | const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; | 244 | const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; |
| 242 | const terminalManager = yield* TerminalManager; | 245 | const terminalManager = yield* TerminalManager; |
| 246 | const previewManager = yield* PreviewManager; | ||
| 247 | const previewPortScanner = yield* PreviewPortScanner; | ||
| 243 | const providerRegistry = yield* ProviderRegistry; | 248 | const providerRegistry = yield* ProviderRegistry; |
| 244 | const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner; | 249 | const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner; |
| 245 | const config = yield* ServerConfig; | 250 | const config = yield* ServerConfig; |
| ... | @@ -1350,6 +1355,63 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => | ... | @@ -1350,6 +1355,63 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => |
| 1350 | ), | 1355 | ), |
| 1351 | { "rpc.aggregate": "terminal" }, | 1356 | { "rpc.aggregate": "terminal" }, |
| 1352 | ), | 1357 | ), |
| 1358 | [WS_METHODS.previewOpen]: (input) => | ||
| 1359 | observeRpcEffect(WS_METHODS.previewOpen, previewManager.open(input), { | ||
| 1360 | "rpc.aggregate": "preview", | ||
| 1361 | }), | ||
| 1362 | [WS_METHODS.previewNavigate]: (input) => | ||
| 1363 | observeRpcEffect(WS_METHODS.previewNavigate, previewManager.navigate(input), { | ||
| 1364 | "rpc.aggregate": "preview", | ||
| 1365 | }), | ||
| 1366 | [WS_METHODS.previewRefresh]: (input) => | ||
| 1367 | observeRpcEffect(WS_METHODS.previewRefresh, previewManager.refresh(input), { | ||
| 1368 | "rpc.aggregate": "preview", | ||
| 1369 | }), | ||
| 1370 | [WS_METHODS.previewClose]: (input) => | ||
| 1371 | observeRpcEffect(WS_METHODS.previewClose, previewManager.close(input), { | ||
| 1372 | "rpc.aggregate": "preview", | ||
| 1373 | }), | ||
| 1374 | [WS_METHODS.previewList]: (input) => | ||
| 1375 | observeRpcEffect(WS_METHODS.previewList, previewManager.list(input), { | ||
| 1376 | "rpc.aggregate": "preview", | ||
| 1377 | }), | ||
| 1378 | [WS_METHODS.previewReportStatus]: (input) => | ||
| 1379 | observeRpcEffect(WS_METHODS.previewReportStatus, previewManager.reportStatus(input), { | ||
| 1380 | "rpc.aggregate": "preview", | ||
| 1381 | }), | ||
| 1382 | [WS_METHODS.subscribePreviewEvents]: (_input) => | ||
| 1383 | observeRpcStream(WS_METHODS.subscribePreviewEvents, previewManager.events, { | ||
| 1384 | "rpc.aggregate": "preview", | ||
| 1385 | }), | ||
| 1386 | [WS_METHODS.subscribeDiscoveredLocalServers]: (_input) => | ||
| 1387 | observeRpcStream( | ||
| 1388 | WS_METHODS.subscribeDiscoveredLocalServers, | ||
| 1389 | Stream.callback<DiscoveredLocalServerList>((queue) => | ||
| 1390 | Effect.acquireRelease( | ||
| 1391 | Effect.gen(function* () { | ||
| 1392 | const release = yield* previewPortScanner.retain(); | ||
| 1393 | const initial = yield* previewPortScanner.scan(); | ||
| 1394 | yield* Queue.offer(queue, { | ||
| 1395 | servers: initial, | ||
| 1396 | scannedAt: new Date().toISOString(), | ||
| 1397 | }); | ||
| 1398 | const unsubscribe = yield* previewPortScanner.subscribe((servers) => | ||
| 1399 | Queue.offer(queue, { | ||
| 1400 | servers, | ||
| 1401 | scannedAt: new Date().toISOString(), | ||
| 1402 | }), | ||
| 1403 | ); | ||
| 1404 | return { unsubscribe, release }; | ||
| 1405 | }), | ||
| 1406 | ({ unsubscribe, release }) => | ||
| 1407 | Effect.sync(() => { | ||
| 1408 | unsubscribe(); | ||
| 1409 | release(); | ||
| 1410 | }), | ||
| 1411 | ), | ||
| 1412 | ), | ||
| 1413 | { "rpc.aggregate": "preview" }, | ||
| 1414 | ), | ||
| 1353 | [WS_METHODS.subscribeServerConfig]: (_input) => | 1415 | [WS_METHODS.subscribeServerConfig]: (_input) => |
| 1354 | observeRpcStreamEffect( | 1416 | observeRpcStreamEffect( |
| 1355 | WS_METHODS.subscribeServerConfig, | 1417 | WS_METHODS.subscribeServerConfig, |
apps/web/package.json+1| ... | @@ -64,6 +64,7 @@ | ... | @@ -64,6 +64,7 @@ |
| 64 | "babel-plugin-react-compiler": "1.0.0", | 64 | "babel-plugin-react-compiler": "1.0.0", |
| 65 | "msw": "2.12.11", | 65 | "msw": "2.12.11", |
| 66 | "playwright": "^1.58.2", | 66 | "playwright": "^1.58.2", |
| 67 | "react-grab": "^0.1.32", | ||
| 67 | "tailwindcss": "^4.0.0", | 68 | "tailwindcss": "^4.0.0", |
| 68 | "vite": "catalog:", | 69 | "vite": "catalog:", |
| 69 | "vite-plus": "catalog:", | 70 | "vite-plus": "catalog:", |
apps/web/src/components/ChatView.browser.tsx+20| ... | @@ -268,6 +268,26 @@ function createMockEnvironmentApi(input: { | ... | @@ -268,6 +268,26 @@ function createMockEnvironmentApi(input: { |
| 268 | subscribeThread: (() => () => | 268 | subscribeThread: (() => () => |
| 269 | undefined) as EnvironmentApi["orchestration"]["subscribeThread"], | 269 | undefined) as EnvironmentApi["orchestration"]["subscribeThread"], |
| 270 | }, | 270 | }, |
| 271 | preview: { | ||
| 272 | open: () => { | ||
| 273 | throw new Error("Not implemented in browser test."); | ||
| 274 | }, | ||
| 275 | navigate: () => { | ||
| 276 | throw new Error("Not implemented in browser test."); | ||
| 277 | }, | ||
| 278 | refresh: () => { | ||
| 279 | throw new Error("Not implemented in browser test."); | ||
| 280 | }, | ||
| 281 | close: () => { | ||
| 282 | throw new Error("Not implemented in browser test."); | ||
| 283 | }, | ||
| 284 | list: () => Promise.resolve({ sessions: [] }), | ||
| 285 | reportStatus: () => { | ||
| 286 | throw new Error("Not implemented in browser test."); | ||
| 287 | }, | ||
| 288 | onEvent: () => () => undefined, | ||
| 289 | subscribePorts: () => () => undefined, | ||
| 290 | } as EnvironmentApi["preview"], | ||
| 271 | }; | 291 | }; |
| 272 | } | 292 | } |
| 273 | 293 |
apps/web/src/components/ChatView.tsx+130-24| ... | @@ -36,7 +36,7 @@ import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/proje | ... | @@ -36,7 +36,7 @@ import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/proje |
| 36 | import { truncate } from "@t3tools/shared/String"; | 36 | import { truncate } from "@t3tools/shared/String"; |
| 37 | import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels"; | 37 | import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels"; |
| 38 | import { Debouncer } from "@tanstack/react-pacer"; | 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 | import { useNavigate, useSearch } from "@tanstack/react-router"; | 40 | import { useNavigate, useSearch } from "@tanstack/react-router"; |
| 41 | import { useShallow } from "zustand/react/shallow"; | 41 | import { useShallow } from "zustand/react/shallow"; |
| 42 | import { useVcsStatus } from "~/lib/vcsStatusState"; | 42 | import { useVcsStatus } from "~/lib/vcsStatusState"; |
| ... | @@ -98,6 +98,14 @@ import { useCommandPaletteStore } from "../commandPaletteStore"; | ... | @@ -98,6 +98,14 @@ import { useCommandPaletteStore } from "../commandPaletteStore"; |
| 98 | import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; | 98 | import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; |
| 99 | import { useMediaQuery } from "../hooks/useMediaQuery"; | 99 | import { useMediaQuery } from "../hooks/useMediaQuery"; |
| 100 | import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout"; | 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 | import { BranchToolbar } from "./BranchToolbar"; | 109 | import { BranchToolbar } from "./BranchToolbar"; |
| 102 | import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; | 110 | import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; |
| 103 | import PlanSidebar from "./PlanSidebar"; | 111 | import PlanSidebar from "./PlanSidebar"; |
| ... | @@ -910,7 +918,6 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -910,7 +918,6 @@ export default function ChatView(props: ChatViewProps) { |
| 910 | >({}); | 918 | >({}); |
| 911 | const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] = | 919 | const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] = |
| 912 | useState<Record<string, number>>({}); | 920 | useState<Record<string, number>>({}); |
| 913 | const [planSidebarOpen, setPlanSidebarOpen] = useState(false); | ||
| 914 | const shouldUsePlanSidebarSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY); | 921 | const shouldUsePlanSidebarSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY); |
| 915 | // Tracks whether the user explicitly dismissed the sidebar for the active turn. | 922 | // Tracks whether the user explicitly dismissed the sidebar for the active turn. |
| 916 | const planSidebarDismissedForTurnRef = useRef<string | null>(null); | 923 | const planSidebarDismissedForTurnRef = useRef<string | null>(null); |
| ... | @@ -1045,7 +1052,6 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -1045,7 +1052,6 @@ export default function ChatView(props: ChatViewProps) { |
| 1045 | [activeThread], | 1052 | [activeThread], |
| 1046 | ); | 1053 | ); |
| 1047 | const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; | 1054 | const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; |
| 1048 | |||
| 1049 | useEffect(() => { | 1055 | useEffect(() => { |
| 1050 | if (!activeThreadRef) { | 1056 | if (!activeThreadRef) { |
| 1051 | return; | 1057 | return; |
| ... | @@ -1068,7 +1074,11 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -1068,7 +1074,11 @@ export default function ChatView(props: ChatViewProps) { |
| 1068 | reconcileTerminalIds, | 1074 | reconcileTerminalIds, |
| 1069 | terminalUiState.terminalIds, | 1075 | terminalUiState.terminalIds, |
| 1070 | ]); | 1076 | ]); |
| 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 | const existingOpenTerminalThreadKeys = useMemo(() => { | 1082 | const existingOpenTerminalThreadKeys = useMemo(() => { |
| 1073 | const existingThreadKeys = new Set<string>([...serverThreadKeys, ...draftThreadKeys]); | 1083 | const existingThreadKeys = new Set<string>([...serverThreadKeys, ...draftThreadKeys]); |
| 1074 | return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey)); | 1084 | return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey)); |
| ... | @@ -1925,12 +1935,47 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -1925,12 +1935,47 @@ export default function ChatView(props: ChatViewProps) { |
| 1925 | () => shortcutLabelForCommand(keybindings, "diff.toggle", nonTerminalShortcutLabelOptions), | 1935 | () => shortcutLabelForCommand(keybindings, "diff.toggle", nonTerminalShortcutLabelOptions), |
| 1926 | [keybindings, nonTerminalShortcutLabelOptions], | 1936 | [keybindings, nonTerminalShortcutLabelOptions], |
| 1927 | ); | 1937 | ); |
| 1938 | const previewPanelShortcutLabel = useMemo( | ||
| 1939 | () => shortcutLabelForCommand(keybindings, "preview.toggle", nonTerminalShortcutLabelOptions), | ||
| 1940 | [keybindings, nonTerminalShortcutLabelOptions], | ||
| 1941 | ); | ||
| 1942 | // Right-panel arbitration: | ||
| 1943 | // - The diff panel's openness is mirrored by the `?diff=1` URL search | ||
| 1944 | // param so it deep-links cleanly. The store still records preview/plan | ||
| 1945 | // openness; when both fight, `selectActiveRightPanelKindWithUrl` lets | ||
| 1946 | // diff win (URL is truth). | ||
| 1947 | // - The two toggles below treat the panels as mutually exclusive: opening | ||
| 1948 | // one always tears down the other in BOTH the URL and the store. Without | ||
| 1949 | // this, e.g. clicking the browser button while diff is URL-pinned would | ||
| 1950 | // just toggle the (overridden) store value and look like a no-op. | ||
| 1951 | const onTogglePreview = useCallback(() => { | ||
| 1952 | if (!activeThreadRef) return; | ||
| 1953 | if (previewPanelOpen) { | ||
| 1954 | useRightPanelStore.getState().close(activeThreadRef); | ||
| 1955 | return; | ||
| 1956 | } | ||
| 1957 | if (diffOpen) { | ||
| 1958 | void navigate({ | ||
| 1959 | to: "/$environmentId/$threadId", | ||
| 1960 | params: { environmentId, threadId }, | ||
| 1961 | replace: true, | ||
| 1962 | search: (previous) => ({ ...stripDiffSearchParams(previous), diff: undefined }), | ||
| 1963 | }); | ||
| 1964 | } | ||
| 1965 | useRightPanelStore.getState().open(activeThreadRef, "preview"); | ||
| 1966 | }, [activeThreadRef, diffOpen, environmentId, navigate, previewPanelOpen, threadId]); | ||
| 1928 | const onToggleDiff = useCallback(() => { | 1967 | const onToggleDiff = useCallback(() => { |
| 1929 | if (!isServerThread) { | 1968 | if (!isServerThread) { |
| 1930 | return; | 1969 | return; |
| 1931 | } | 1970 | } |
| 1932 | if (!diffOpen) { | 1971 | if (!diffOpen) { |
| 1933 | onDiffPanelOpen?.(); | 1972 | onDiffPanelOpen?.(); |
| 1973 | // Switching to diff: drop whatever the store had (e.g. preview) so | ||
| 1974 | // that when the user later closes diff, an old store entry doesn't | ||
| 1975 | // unexpectedly resurface. | ||
| 1976 | if (activeThreadRef) { | ||
| 1977 | useRightPanelStore.getState().close(activeThreadRef); | ||
| 1978 | } | ||
| 1934 | } | 1979 | } |
| 1935 | void navigate({ | 1980 | void navigate({ |
| 1936 | to: "/$environmentId/$threadId", | 1981 | to: "/$environmentId/$threadId", |
| ... | @@ -1944,7 +1989,25 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -1944,7 +1989,25 @@ export default function ChatView(props: ChatViewProps) { |
| 1944 | return diffOpen ? { ...rest, diff: undefined } : { ...rest, diff: "1" }; | 1989 | return diffOpen ? { ...rest, diff: undefined } : { ...rest, diff: "1" }; |
| 1945 | }, | 1990 | }, |
| 1946 | }); | 1991 | }); |
| 1947 | }, [diffOpen, environmentId, isServerThread, navigate, onDiffPanelOpen, threadId]); | 1992 | }, [ |
| 1993 | activeThreadRef, | ||
| 1994 | diffOpen, | ||
| 1995 | environmentId, | ||
| 1996 | isServerThread, | ||
| 1997 | navigate, | ||
| 1998 | onDiffPanelOpen, | ||
| 1999 | threadId, | ||
| 2000 | ]); | ||
| 2001 | |||
| 2002 | // Route the global mod+shift+J shortcut (dispatched from `routes/_chat.tsx` | ||
| 2003 | // via `previewActionBus`) through the URL-aware toggle defined above. | ||
| 2004 | useEffect(() => { | ||
| 2005 | return subscribePreviewAction((action) => { | ||
| 2006 | if (action !== "toggle-panel") return; | ||
| 2007 | if (!isPreviewSupportedInRuntime()) return; | ||
| 2008 | onTogglePreview(); | ||
| 2009 | }); | ||
| 2010 | }, [onTogglePreview]); | ||
| 1948 | 2011 | ||
| 1949 | const envLocked = Boolean( | 2012 | const envLocked = Boolean( |
| 1950 | activeThread && | 2013 | activeThread && |
| ... | @@ -2223,6 +2286,19 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -2223,6 +2286,19 @@ export default function ChatView(props: ChatViewProps) { |
| 2223 | terminalId: targetTerminalId, | 2286 | terminalId: targetTerminalId, |
| 2224 | data: `${script.command}\r`, | 2287 | data: `${script.command}\r`, |
| 2225 | }); | 2288 | }); |
| 2289 | if ( | ||
| 2290 | script.autoOpenPreview && | ||
| 2291 | script.previewUrl && | ||
| 2292 | isPreviewSupportedInRuntime() && | ||
| 2293 | activeThreadRef | ||
| 2294 | ) { | ||
| 2295 | try { | ||
| 2296 | await api.preview.open({ threadId: activeThreadId, url: script.previewUrl }); | ||
| 2297 | useRightPanelStore.getState().open(activeThreadRef, "preview"); | ||
| 2298 | } catch { | ||
| 2299 | // Preview open failures are surfaced via the panel itself. | ||
| 2300 | } | ||
| 2301 | } | ||
| 2226 | } catch (error) { | 2302 | } catch (error) { |
| 2227 | setThreadError( | 2303 | setThreadError( |
| 2228 | activeThreadId, | 2304 | activeThreadId, |
| ... | @@ -2295,6 +2371,8 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -2295,6 +2371,8 @@ export default function ChatView(props: ChatViewProps) { |
| 2295 | command: input.command, | 2371 | command: input.command, |
| 2296 | icon: input.icon, | 2372 | icon: input.icon, |
| 2297 | runOnWorktreeCreate: input.runOnWorktreeCreate, | 2373 | runOnWorktreeCreate: input.runOnWorktreeCreate, |
| 2374 | ...(input.previewUrl ? { previewUrl: input.previewUrl } : {}), | ||
| 2375 | ...(input.autoOpenPreview ? { autoOpenPreview: input.autoOpenPreview } : {}), | ||
| 2298 | }; | 2376 | }; |
| 2299 | const nextScripts = input.runOnWorktreeCreate | 2377 | const nextScripts = input.runOnWorktreeCreate |
| 2300 | ? [ | 2378 | ? [ |
| ... | @@ -2330,6 +2408,10 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -2330,6 +2408,10 @@ export default function ChatView(props: ChatViewProps) { |
| 2330 | command: input.command, | 2408 | command: input.command, |
| 2331 | icon: input.icon, | 2409 | icon: input.icon, |
| 2332 | runOnWorktreeCreate: input.runOnWorktreeCreate, | 2410 | runOnWorktreeCreate: input.runOnWorktreeCreate, |
| 2411 | ...(input.previewUrl ? { previewUrl: input.previewUrl } : { previewUrl: undefined }), | ||
| 2412 | ...(input.autoOpenPreview | ||
| 2413 | ? { autoOpenPreview: input.autoOpenPreview } | ||
| 2414 | : { autoOpenPreview: undefined }), | ||
| 2333 | }; | 2415 | }; |
| 2334 | const nextScripts = activeProject.scripts.map((script) => | 2416 | const nextScripts = activeProject.scripts.map((script) => |
| 2335 | script.id === scriptId | 2417 | script.id === scriptId |
| ... | @@ -2424,21 +2506,25 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -2424,21 +2506,25 @@ export default function ChatView(props: ChatViewProps) { |
| 2424 | handleInteractionModeChange(interactionMode === "plan" ? "default" : "plan"); | 2506 | handleInteractionModeChange(interactionMode === "plan" ? "default" : "plan"); |
| 2425 | }, [handleInteractionModeChange, interactionMode]); | 2507 | }, [handleInteractionModeChange, interactionMode]); |
| 2426 | const togglePlanSidebar = useCallback(() => { | 2508 | const togglePlanSidebar = useCallback(() => { |
| 2427 | setPlanSidebarOpen((open) => { | 2509 | if (!activeThreadRef) return; |
| 2428 | if (open) { | 2510 | if (planSidebarOpen) { |
| 2429 | planSidebarDismissedForTurnRef.current = | 2511 | planSidebarDismissedForTurnRef.current = |
| 2430 | activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__"; | 2512 | activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__"; |
| 2431 | } else { | 2513 | } else { |
| 2432 | planSidebarDismissedForTurnRef.current = null; | 2514 | planSidebarDismissedForTurnRef.current = null; |
| 2433 | } | 2515 | } |
| 2434 | return !open; | 2516 | useRightPanelStore.getState().toggle(activeThreadRef, "plan"); |
| 2435 | }); | 2517 | }, [activePlan?.turnId, activeThreadRef, planSidebarOpen, sidebarProposedPlan?.turnId]); |
| 2436 | }, [activePlan?.turnId, sidebarProposedPlan?.turnId]); | ||
| 2437 | const closePlanSidebar = useCallback(() => { | 2518 | const closePlanSidebar = useCallback(() => { |
| 2438 | setPlanSidebarOpen(false); | 2519 | if (!activeThreadRef) return; |
| 2520 | useRightPanelStore.getState().close(activeThreadRef); | ||
| 2439 | planSidebarDismissedForTurnRef.current = | 2521 | planSidebarDismissedForTurnRef.current = |
| 2440 | activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__"; | 2522 | activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__"; |
| 2441 | }, [activePlan?.turnId, sidebarProposedPlan?.turnId]); | 2523 | }, [activePlan?.turnId, activeThreadRef, sidebarProposedPlan?.turnId]); |
| 2524 | const closePreviewPanel = useCallback(() => { | ||
| 2525 | if (!activeThreadRef) return; | ||
| 2526 | useRightPanelStore.getState().close(activeThreadRef); | ||
| 2527 | }, [activeThreadRef]); | ||
| 2442 | 2528 | ||
| 2443 | const persistThreadSettingsForNextTurn = useCallback( | 2529 | const persistThreadSettingsForNextTurn = useCallback( |
| 2444 | async (input: { | 2530 | async (input: { |
| ... | @@ -2523,12 +2609,12 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -2523,12 +2609,12 @@ export default function ChatView(props: ChatViewProps) { |
| 2523 | setShowScrollToBottom(false); | 2609 | setShowScrollToBottom(false); |
| 2524 | if (planSidebarOpenOnNextThreadRef.current) { | 2610 | if (planSidebarOpenOnNextThreadRef.current) { |
| 2525 | planSidebarOpenOnNextThreadRef.current = false; | 2611 | planSidebarOpenOnNextThreadRef.current = false; |
| 2526 | setPlanSidebarOpen(true); | 2612 | if (activeThreadRef) { |
| 2527 | } else { | 2613 | useRightPanelStore.getState().open(activeThreadRef, "plan"); |
| 2528 | planSidebarOpenOnNextThreadRef.current = false; | 2614 | } |
| 2529 | setPlanSidebarOpen(false); | ||
| 2530 | } | 2615 | } |
| 2531 | planSidebarDismissedForTurnRef.current = null; | 2616 | planSidebarDismissedForTurnRef.current = null; |
| 2617 | // eslint-disable-next-line react-hooks/exhaustive-deps -- activeThreadRef is reset transitively | ||
| 2532 | }, [activeThread?.id]); | 2618 | }, [activeThread?.id]); |
| 2533 | 2619 | ||
| 2534 | // Auto-open the plan sidebar when plan/todo steps arrive for the current turn. | 2620 | // Auto-open the plan sidebar when plan/todo steps arrive for the current turn. |
| ... | @@ -2541,10 +2627,13 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -2541,10 +2627,13 @@ export default function ChatView(props: ChatViewProps) { |
| 2541 | if (latestTurnId && activePlan.turnId !== latestTurnId) return; | 2627 | if (latestTurnId && activePlan.turnId !== latestTurnId) return; |
| 2542 | const turnKey = activePlan.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__"; | 2628 | const turnKey = activePlan.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__"; |
| 2543 | if (planSidebarDismissedForTurnRef.current === turnKey) return; | 2629 | if (planSidebarDismissedForTurnRef.current === turnKey) return; |
| 2544 | setPlanSidebarOpen(true); | 2630 | if (activeThreadRef) { |
| 2631 | useRightPanelStore.getState().open(activeThreadRef, "plan"); | ||
| 2632 | } | ||
| 2545 | }, [ | 2633 | }, [ |
| 2546 | activePlan, | 2634 | activePlan, |
| 2547 | activeLatestTurn?.turnId, | 2635 | activeLatestTurn?.turnId, |
| 2636 | activeThreadRef, | ||
| 2548 | autoOpenPlanSidebar, | 2637 | autoOpenPlanSidebar, |
| 2549 | planSidebarOpen, | 2638 | planSidebarOpen, |
| 2550 | sidebarProposedPlan?.turnId, | 2639 | sidebarProposedPlan?.turnId, |
| ... | @@ -3462,7 +3551,9 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -3462,7 +3551,9 @@ export default function ChatView(props: ChatViewProps) { |
| 3462 | // step-tracking activities that the sidebar will display. | 3551 | // step-tracking activities that the sidebar will display. |
| 3463 | if (nextInteractionMode === "default" && autoOpenPlanSidebar) { | 3552 | if (nextInteractionMode === "default" && autoOpenPlanSidebar) { |
| 3464 | planSidebarDismissedForTurnRef.current = null; | 3553 | planSidebarDismissedForTurnRef.current = null; |
| 3465 | setPlanSidebarOpen(true); | 3554 | if (activeThreadRef) { |
| 3555 | useRightPanelStore.getState().open(activeThreadRef, "plan"); | ||
| 3556 | } | ||
| 3466 | } | 3557 | } |
| 3467 | sendInFlightRef.current = false; | 3558 | sendInFlightRef.current = false; |
| 3468 | } catch (err) { | 3559 | } catch (err) { |
| ... | @@ -3828,6 +3919,9 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -3828,6 +3919,9 @@ export default function ChatView(props: ChatViewProps) { |
| 3828 | terminalOpen={terminalUiState.terminalOpen} | 3919 | terminalOpen={terminalUiState.terminalOpen} |
| 3829 | terminalToggleShortcutLabel={terminalToggleShortcutLabel} | 3920 | terminalToggleShortcutLabel={terminalToggleShortcutLabel} |
| 3830 | diffToggleShortcutLabel={diffPanelShortcutLabel} | 3921 | diffToggleShortcutLabel={diffPanelShortcutLabel} |
| 3922 | previewAvailable={isPreviewSupportedInRuntime()} | ||
| 3923 | previewOpen={previewPanelOpen} | ||
| 3924 | previewToggleShortcutLabel={previewPanelShortcutLabel} | ||
| 3831 | gitCwd={gitCwd} | 3925 | gitCwd={gitCwd} |
| 3832 | diffOpen={diffOpen} | 3926 | diffOpen={diffOpen} |
| 3833 | onRunProjectScript={runProjectScript} | 3927 | onRunProjectScript={runProjectScript} |
| ... | @@ -3835,6 +3929,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -3835,6 +3929,7 @@ export default function ChatView(props: ChatViewProps) { |
| 3835 | onUpdateProjectScript={updateProjectScript} | 3929 | onUpdateProjectScript={updateProjectScript} |
| 3836 | onDeleteProjectScript={deleteProjectScript} | 3930 | onDeleteProjectScript={deleteProjectScript} |
| 3837 | onToggleTerminal={toggleTerminalVisibility} | 3931 | onToggleTerminal={toggleTerminalVisibility} |
| 3932 | onTogglePreview={onTogglePreview} | ||
| 3838 | onToggleDiff={onToggleDiff} | 3933 | onToggleDiff={onToggleDiff} |
| 3839 | /> | 3934 | /> |
| 3840 | </header> | 3935 | </header> |
| ... | @@ -4020,7 +4115,6 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -4020,7 +4115,6 @@ export default function ChatView(props: ChatViewProps) { |
| 4020 | </div> | 4115 | </div> |
| 4021 | {/* end chat column */} | 4116 | {/* end chat column */} |
| 4022 | 4117 | ||
| 4023 | {/* Plan sidebar */} | ||
| 4024 | {planSidebarOpen && !shouldUsePlanSidebarSheet ? ( | 4118 | {planSidebarOpen && !shouldUsePlanSidebarSheet ? ( |
| 4025 | <PlanSidebar | 4119 | <PlanSidebar |
| 4026 | activePlan={activePlan} | 4120 | activePlan={activePlan} |
| ... | @@ -4034,6 +4128,11 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -4034,6 +4128,11 @@ export default function ChatView(props: ChatViewProps) { |
| 4034 | onClose={closePlanSidebar} | 4128 | onClose={closePlanSidebar} |
| 4035 | /> | 4129 | /> |
| 4036 | ) : null} | 4130 | ) : null} |
| 4131 | {previewPanelOpen && !shouldUsePlanSidebarSheet && activeThreadRef ? ( | ||
| 4132 | <Suspense fallback={null}> | ||
| 4133 | <PreviewPanel mode="inline" threadRef={activeThreadRef} visible /> | ||
| 4134 | </Suspense> | ||
| 4135 | ) : null} | ||
| 4037 | </div> | 4136 | </div> |
| 4038 | {/* end horizontal flex container */} | 4137 | {/* end horizontal flex container */} |
| 4039 | 4138 | ||
| ... | @@ -4054,6 +4153,13 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -4054,6 +4153,13 @@ export default function ChatView(props: ChatViewProps) { |
| 4054 | onAddTerminalContext={addTerminalContextToDraft} | 4153 | onAddTerminalContext={addTerminalContextToDraft} |
| 4055 | /> | 4154 | /> |
| 4056 | ))} | 4155 | ))} |
| 4156 | {shouldUsePlanSidebarSheet && previewPanelOpen && activeThreadRef ? ( | ||
| 4157 | <RightPanelSheet open onClose={closePreviewPanel}> | ||
| 4158 | <Suspense fallback={null}> | ||
| 4159 | <PreviewPanel mode="sheet" threadRef={activeThreadRef} visible /> | ||
| 4160 | </Suspense> | ||
| 4161 | </RightPanelSheet> | ||
| 4162 | ) : null} | ||
| 4057 | {shouldUsePlanSidebarSheet ? ( | 4163 | {shouldUsePlanSidebarSheet ? ( |
| 4058 | <RightPanelSheet open={planSidebarOpen} onClose={closePlanSidebar}> | 4164 | <RightPanelSheet open={planSidebarOpen} onClose={closePlanSidebar}> |
| 4059 | <PlanSidebar | 4165 | <PlanSidebar |
apps/web/src/components/ProjectScriptsControl.tsx+39| ... | @@ -85,6 +85,10 @@ export interface NewProjectScriptInput { | ... | @@ -85,6 +85,10 @@ export interface NewProjectScriptInput { |
| 85 | icon: ProjectScriptIcon; | 85 | icon: ProjectScriptIcon; |
| 86 | runOnWorktreeCreate: boolean; | 86 | runOnWorktreeCreate: boolean; |
| 87 | keybinding: string | null; | 87 | keybinding: string | null; |
| 88 | /** Optional URL to open in the in-app preview when this script runs. */ | ||
| 89 | previewUrl: string | null; | ||
| 90 | /** When true, automatically open the preview panel pointed at `previewUrl`. */ | ||
| 91 | autoOpenPreview: boolean; | ||
| 88 | } | 92 | } |
| 89 | 93 | ||
| 90 | interface ProjectScriptsControlProps { | 94 | interface ProjectScriptsControlProps { |
| ... | @@ -115,6 +119,8 @@ export default function ProjectScriptsControl({ | ... | @@ -115,6 +119,8 @@ export default function ProjectScriptsControl({ |
| 115 | const [iconPickerOpen, setIconPickerOpen] = useState(false); | 119 | const [iconPickerOpen, setIconPickerOpen] = useState(false); |
| 116 | const [runOnWorktreeCreate, setRunOnWorktreeCreate] = useState(false); | 120 | const [runOnWorktreeCreate, setRunOnWorktreeCreate] = useState(false); |
| 117 | const [keybinding, setKeybinding] = useState(""); | 121 | const [keybinding, setKeybinding] = useState(""); |
| 122 | const [previewUrl, setPreviewUrl] = useState(""); | ||
| 123 | const [autoOpenPreview, setAutoOpenPreview] = useState(false); | ||
| 118 | const [validationError, setValidationError] = useState<string | null>(null); | 124 | const [validationError, setValidationError] = useState<string | null>(null); |
| 119 | const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); | 125 | const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); |
| 120 | 126 | ||
| ... | @@ -166,12 +172,15 @@ export default function ProjectScriptsControl({ | ... | @@ -166,12 +172,15 @@ export default function ProjectScriptsControl({ |
| 166 | keybinding, | 172 | keybinding, |
| 167 | command: commandForProjectScript(scriptIdForValidation), | 173 | command: commandForProjectScript(scriptIdForValidation), |
| 168 | }); | 174 | }); |
| 175 | const trimmedPreviewUrl = previewUrl.trim(); | ||
| 169 | const payload = { | 176 | const payload = { |
| 170 | name: trimmedName, | 177 | name: trimmedName, |
| 171 | command: trimmedCommand, | 178 | command: trimmedCommand, |
| 172 | icon, | 179 | icon, |
| 173 | runOnWorktreeCreate, | 180 | runOnWorktreeCreate, |
| 174 | keybinding: keybindingRule?.key ?? null, | 181 | keybinding: keybindingRule?.key ?? null, |
| 182 | previewUrl: trimmedPreviewUrl.length > 0 ? trimmedPreviewUrl : null, | ||
| 183 | autoOpenPreview: trimmedPreviewUrl.length > 0 ? autoOpenPreview : false, | ||
| 175 | } satisfies NewProjectScriptInput; | 184 | } satisfies NewProjectScriptInput; |
| 176 | if (editingScriptId) { | 185 | if (editingScriptId) { |
| 177 | await onUpdateScript(editingScriptId, payload); | 186 | await onUpdateScript(editingScriptId, payload); |
| ... | @@ -193,6 +202,8 @@ export default function ProjectScriptsControl({ | ... | @@ -193,6 +202,8 @@ export default function ProjectScriptsControl({ |
| 193 | setIconPickerOpen(false); | 202 | setIconPickerOpen(false); |
| 194 | setRunOnWorktreeCreate(false); | 203 | setRunOnWorktreeCreate(false); |
| 195 | setKeybinding(""); | 204 | setKeybinding(""); |
| 205 | setPreviewUrl(""); | ||
| 206 | setAutoOpenPreview(false); | ||
| 196 | setValidationError(null); | 207 | setValidationError(null); |
| 197 | setDialogOpen(true); | 208 | setDialogOpen(true); |
| 198 | }; | 209 | }; |
| ... | @@ -205,6 +216,8 @@ export default function ProjectScriptsControl({ | ... | @@ -205,6 +216,8 @@ export default function ProjectScriptsControl({ |
| 205 | setIconPickerOpen(false); | 216 | setIconPickerOpen(false); |
| 206 | setRunOnWorktreeCreate(script.runOnWorktreeCreate); | 217 | setRunOnWorktreeCreate(script.runOnWorktreeCreate); |
| 207 | setKeybinding(keybindingValueForCommand(keybindings, commandForProjectScript(script.id)) ?? ""); | 218 | setKeybinding(keybindingValueForCommand(keybindings, commandForProjectScript(script.id)) ?? ""); |
| 219 | setPreviewUrl(script.previewUrl ?? ""); | ||
| 220 | setAutoOpenPreview(script.autoOpenPreview ?? false); | ||
| 208 | setValidationError(null); | 221 | setValidationError(null); |
| 209 | setDialogOpen(true); | 222 | setDialogOpen(true); |
| 210 | }; | 223 | }; |
| ... | @@ -327,6 +340,8 @@ export default function ProjectScriptsControl({ | ... | @@ -327,6 +340,8 @@ export default function ProjectScriptsControl({ |
| 327 | setIcon("play"); | 340 | setIcon("play"); |
| 328 | setRunOnWorktreeCreate(false); | 341 | setRunOnWorktreeCreate(false); |
| 329 | setKeybinding(""); | 342 | setKeybinding(""); |
| 343 | setPreviewUrl(""); | ||
| 344 | setAutoOpenPreview(false); | ||
| 330 | setValidationError(null); | 345 | setValidationError(null); |
| 331 | }} | 346 | }} |
| 332 | open={dialogOpen} | 347 | open={dialogOpen} |
| ... | @@ -413,6 +428,18 @@ export default function ProjectScriptsControl({ | ... | @@ -413,6 +428,18 @@ export default function ProjectScriptsControl({ |
| 413 | onChange={(event) => setCommand(event.target.value)} | 428 | onChange={(event) => setCommand(event.target.value)} |
| 414 | /> | 429 | /> |
| 415 | </div> | 430 | </div> |
| 431 | <div className="space-y-1.5"> | ||
| 432 | <Label htmlFor="script-preview-url">Preview URL (optional)</Label> | ||
| 433 | <Input | ||
| 434 | id="script-preview-url" | ||
| 435 | placeholder="http://localhost:5173" | ||
| 436 | value={previewUrl} | ||
| 437 | onChange={(event) => setPreviewUrl(event.target.value)} | ||
| 438 | /> | ||
| 439 | <p className="text-xs text-muted-foreground"> | ||
| 440 | Open this URL in the in-app preview when this action runs. | ||
| 441 | </p> | ||
| 442 | </div> | ||
| 416 | <label className="flex items-center justify-between gap-3 rounded-md border border-border/70 px-3 py-2 text-sm"> | 443 | <label className="flex items-center justify-between gap-3 rounded-md border border-border/70 px-3 py-2 text-sm"> |
| 417 | <span>Run automatically on worktree creation</span> | 444 | <span>Run automatically on worktree creation</span> |
| 418 | <Switch | 445 | <Switch |
| ... | @@ -420,6 +447,18 @@ export default function ProjectScriptsControl({ | ... | @@ -420,6 +447,18 @@ export default function ProjectScriptsControl({ |
| 420 | onCheckedChange={(checked) => setRunOnWorktreeCreate(Boolean(checked))} | 447 | onCheckedChange={(checked) => setRunOnWorktreeCreate(Boolean(checked))} |
| 421 | /> | 448 | /> |
| 422 | </label> | 449 | </label> |
| 450 | <label | ||
| 451 | className={`flex items-center justify-between gap-3 rounded-md border border-border/70 px-3 py-2 text-sm ${ | ||
| 452 | previewUrl.trim().length === 0 ? "opacity-60" : "" | ||
| 453 | }`} | ||
| 454 | > | ||
| 455 | <span>Open preview automatically when this action runs</span> | ||
| 456 | <Switch | ||
| 457 | checked={autoOpenPreview} | ||
| 458 | disabled={previewUrl.trim().length === 0} | ||
| 459 | onCheckedChange={(checked) => setAutoOpenPreview(Boolean(checked))} | ||
| 460 | /> | ||
| 461 | </label> | ||
| 423 | {validationError && <p className="text-sm text-destructive">{validationError}</p>} | 462 | {validationError && <p className="text-sm text-destructive">{validationError}</p>} |
| 424 | </form> | 463 | </form> |
| 425 | </DialogPanel> | 464 | </DialogPanel> |
apps/web/src/components/ThreadTerminalDrawer.tsx+21-5| ... | @@ -48,6 +48,7 @@ import { | ... | @@ -48,6 +48,7 @@ import { |
| 48 | import { readEnvironmentApi } from "~/environmentApi"; | 48 | import { readEnvironmentApi } from "~/environmentApi"; |
| 49 | import { readLocalApi } from "~/localApi"; | 49 | import { readLocalApi } from "~/localApi"; |
| 50 | import { attachTerminalSession } from "../terminalSessionState"; | 50 | import { attachTerminalSession } from "../terminalSessionState"; |
| 51 | import { openTerminalLinkInPreview } from "./preview/openTerminalLinkInPreview"; | ||
| 51 | 52 | ||
| 52 | const MIN_DRAWER_HEIGHT = 180; | 53 | const MIN_DRAWER_HEIGHT = 180; |
| 53 | const MAX_DRAWER_HEIGHT_RATIO = 0.75; | 54 | const MAX_DRAWER_HEIGHT_RATIO = 0.75; |
| ... | @@ -509,11 +510,26 @@ export function TerminalViewport({ | ... | @@ -509,11 +510,26 @@ export function TerminalViewport({ |
| 509 | } | 510 | } |
| 510 | 511 | ||
| 511 | if (match.kind === "url") { | 512 | if (match.kind === "url") { |
| 512 | void localApi.shell.openExternal(match.text).catch((error: unknown) => { | 513 | const fallbackToBrowser = () => { |
| 513 | writeSystemMessage( | 514 | void localApi.shell.openExternal(match.text).catch((error: unknown) => { |
| 514 | latestTerminal, | 515 | writeSystemMessage( |
| 515 | error instanceof Error ? error.message : "Unable to open link", | 516 | latestTerminal, |
| 516 | ); | 517 | error instanceof Error ? error.message : "Unable to open link", |
| 518 | ); | ||
| 519 | }); | ||
| 520 | }; | ||
| 521 | const api = readEnvironmentApi(threadRef.environmentId); | ||
| 522 | if (!api) { | ||
| 523 | fallbackToBrowser(); | ||
| 524 | return; | ||
| 525 | } | ||
| 526 | void openTerminalLinkInPreview({ | ||
| 527 | url: match.text, | ||
| 528 | position: { x: event.clientX, y: event.clientY }, | ||
| 529 | threadRef, | ||
| 530 | api, | ||
| 531 | localApi, | ||
| 532 | fallbackToBrowser, | ||
| 517 | }); | 533 | }); |
| 518 | return; | 534 | return; |
| 519 | } | 535 | } |
apps/web/src/components/chat/ChatHeader.tsx+34-1| ... | @@ -9,7 +9,8 @@ import { scopeThreadRef } from "@t3tools/client-runtime"; | ... | @@ -9,7 +9,8 @@ import { scopeThreadRef } from "@t3tools/client-runtime"; |
| 9 | import { memo } from "react"; | 9 | import { memo } from "react"; |
| 10 | import GitActionsControl from "../GitActionsControl"; | 10 | import GitActionsControl from "../GitActionsControl"; |
| 11 | import { type DraftId } from "~/composerDraftStore"; | 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 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 14 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 14 | import ProjectScriptsControl, { type NewProjectScriptInput } from "../ProjectScriptsControl"; | 15 | import ProjectScriptsControl, { type NewProjectScriptInput } from "../ProjectScriptsControl"; |
| 15 | import { Toggle } from "../ui/toggle"; | 16 | import { Toggle } from "../ui/toggle"; |
| ... | @@ -33,6 +34,10 @@ interface ChatHeaderProps { | ... | @@ -33,6 +34,10 @@ interface ChatHeaderProps { |
| 33 | terminalOpen: boolean; | 34 | terminalOpen: boolean; |
| 34 | terminalToggleShortcutLabel: string | null; | 35 | terminalToggleShortcutLabel: string | null; |
| 35 | diffToggleShortcutLabel: string | null; | 36 | diffToggleShortcutLabel: string | null; |
| 37 | /** False on the web build; true only when the desktop preview bridge is present. */ | ||
| 38 | previewAvailable: boolean; | ||
| 39 | previewOpen: boolean; | ||
| 40 | previewToggleShortcutLabel: string | null; | ||
| 36 | gitCwd: string | null; | 41 | gitCwd: string | null; |
| 37 | diffOpen: boolean; | 42 | diffOpen: boolean; |
| 38 | onRunProjectScript: (script: ProjectScript) => void; | 43 | onRunProjectScript: (script: ProjectScript) => void; |
| ... | @@ -40,6 +45,7 @@ interface ChatHeaderProps { | ... | @@ -40,6 +45,7 @@ interface ChatHeaderProps { |
| 40 | onUpdateProjectScript: (scriptId: string, input: NewProjectScriptInput) => Promise<void>; | 45 | onUpdateProjectScript: (scriptId: string, input: NewProjectScriptInput) => Promise<void>; |
| 41 | onDeleteProjectScript: (scriptId: string) => Promise<void>; | 46 | onDeleteProjectScript: (scriptId: string) => Promise<void>; |
| 42 | onToggleTerminal: () => void; | 47 | onToggleTerminal: () => void; |
| 48 | onTogglePreview: () => void; | ||
| 43 | onToggleDiff: () => void; | 49 | onToggleDiff: () => void; |
| 44 | } | 50 | } |
| 45 | 51 | ||
| ... | @@ -71,6 +77,9 @@ export const ChatHeader = memo(function ChatHeader({ | ... | @@ -71,6 +77,9 @@ export const ChatHeader = memo(function ChatHeader({ |
| 71 | terminalOpen, | 77 | terminalOpen, |
| 72 | terminalToggleShortcutLabel, | 78 | terminalToggleShortcutLabel, |
| 73 | diffToggleShortcutLabel, | 79 | diffToggleShortcutLabel, |
| 80 | previewAvailable, | ||
| 81 | previewOpen, | ||
| 82 | previewToggleShortcutLabel, | ||
| 74 | gitCwd, | 83 | gitCwd, |
| 75 | diffOpen, | 84 | diffOpen, |
| 76 | onRunProjectScript, | 85 | onRunProjectScript, |
| ... | @@ -78,6 +87,7 @@ export const ChatHeader = memo(function ChatHeader({ | ... | @@ -78,6 +87,7 @@ export const ChatHeader = memo(function ChatHeader({ |
| 78 | onUpdateProjectScript, | 87 | onUpdateProjectScript, |
| 79 | onDeleteProjectScript, | 88 | onDeleteProjectScript, |
| 80 | onToggleTerminal, | 89 | onToggleTerminal, |
| 90 | onTogglePreview, | ||
| 81 | onToggleDiff, | 91 | onToggleDiff, |
| 82 | }: ChatHeaderProps) { | 92 | }: ChatHeaderProps) { |
| 83 | const primaryEnvironmentId = usePrimaryEnvironmentId(); | 93 | const primaryEnvironmentId = usePrimaryEnvironmentId(); |
| ... | @@ -155,6 +165,29 @@ export const ChatHeader = memo(function ChatHeader({ | ... | @@ -155,6 +165,29 @@ export const ChatHeader = memo(function ChatHeader({ |
| 155 | : "Toggle terminal drawer"} | 165 | : "Toggle terminal drawer"} |
| 156 | </TooltipPopup> | 166 | </TooltipPopup> |
| 157 | </Tooltip> | 167 | </Tooltip> |
| 168 | {previewAvailable ? ( | ||
| 169 | <Tooltip> | ||
| 170 | <TooltipTrigger | ||
| 171 | render={ | ||
| 172 | <Toggle | ||
| 173 | className="shrink-0" | ||
| 174 | pressed={previewOpen} | ||
| 175 | onPressedChange={onTogglePreview} | ||
| 176 | aria-label="Toggle preview browser" | ||
| 177 | variant="outline" | ||
| 178 | size="xs" | ||
| 179 | > | ||
| 180 | <Globe className="size-3" /> | ||
| 181 | </Toggle> | ||
| 182 | } | ||
| 183 | /> | ||
| 184 | <TooltipPopup side="bottom"> | ||
| 185 | {previewToggleShortcutLabel | ||
| 186 | ? `Toggle preview browser (${previewToggleShortcutLabel})` | ||
| 187 | : "Toggle preview browser"} | ||
| 188 | </TooltipPopup> | ||
| 189 | </Tooltip> | ||
| 190 | ) : null} | ||
| 158 | <Tooltip> | 191 | <Tooltip> |
| 159 | <TooltipTrigger | 192 | <TooltipTrigger |
| 160 | render={ | 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,6 +58,16 @@ export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi { |
| 58 | subscribeThread: (input, callback, options) => | 58 | subscribeThread: (input, callback, options) => |
| 59 | rpcClient.orchestration.subscribeThread(input, callback, options), | 59 | rpcClient.orchestration.subscribeThread(input, callback, options), |
| 60 | }, | 60 | }, |
| 61 | preview: { | ||
| 62 | open: (input) => rpcClient.preview.open(input as never), | ||
| 63 | navigate: (input) => rpcClient.preview.navigate(input as never), | ||
| 64 | refresh: (input) => rpcClient.preview.refresh(input as never), | ||
| 65 | close: (input) => rpcClient.preview.close(input as never), | ||
| 66 | list: (input) => rpcClient.preview.list(input as never), | ||
| 67 | reportStatus: (input) => rpcClient.preview.reportStatus(input as never), | ||
| 68 | onEvent: (callback) => rpcClient.preview.onEvent(callback), | ||
| 69 | subscribePorts: (callback, options) => rpcClient.preview.subscribePorts(callback, options), | ||
| 70 | }, | ||
| 61 | }; | 71 | }; |
| 62 | } | 72 | } |
| 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,6 +30,8 @@ export interface ShortcutModifierStateLike { |
| 30 | export interface ShortcutMatchContext { | 30 | export interface ShortcutMatchContext { |
| 31 | terminalFocus: boolean; | 31 | terminalFocus: boolean; |
| 32 | terminalOpen: boolean; | 32 | terminalOpen: boolean; |
| 33 | previewFocus: boolean; | ||
| 34 | previewOpen: boolean; | ||
| 33 | [key: string]: boolean; | 35 | [key: string]: boolean; |
| 34 | } | 36 | } |
| 35 | 37 | ||
| ... | @@ -112,6 +114,8 @@ function resolveContext(options: ShortcutMatchOptions | undefined): ShortcutMatc | ... | @@ -112,6 +114,8 @@ function resolveContext(options: ShortcutMatchOptions | undefined): ShortcutMatc |
| 112 | return { | 114 | return { |
| 113 | terminalFocus: false, | 115 | terminalFocus: false, |
| 114 | terminalOpen: false, | 116 | terminalOpen: false, |
| 117 | previewFocus: false, | ||
| 118 | previewOpen: false, | ||
| 115 | ...options?.context, | 119 | ...options?.context, |
| 116 | }; | 120 | }; |
| 117 | } | 121 | } |
| ... | @@ -379,6 +383,30 @@ export function isDiffToggleShortcut( | ... | @@ -379,6 +383,30 @@ export function isDiffToggleShortcut( |
| 379 | return matchesCommandShortcut(event, keybindings, "diff.toggle", options); | 383 | return matchesCommandShortcut(event, keybindings, "diff.toggle", options); |
| 380 | } | 384 | } |
| 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 | export function isChatNewShortcut( | 410 | export function isChatNewShortcut( |
| 383 | event: ShortcutEventLike, | 411 | event: ShortcutEventLike, |
| 384 | keybindings: ResolvedKeybindingsConfig, | 412 | keybindings: ResolvedKeybindingsConfig, |
apps/web/src/lib/favicon.ts created+20| ... | @@ -0,0 +1,20 @@ | ||
| 1 | /** | ||
| 2 | * Favicon helpers for the preview tab strip. | ||
| 3 | * | ||
| 4 | * Uses Google's s2 favicon endpoint (same approach as ami's tab strip). | ||
| 5 | * Callers should always render a `<Globe />` fallback when the returned URL | ||
| 6 | * fails to load via an `onError` handler. | ||
| 7 | */ | ||
| 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,6 +18,10 @@ import { getRouter } from "./router"; |
| 18 | import { APP_DISPLAY_NAME } from "./branding"; | 18 | import { APP_DISPLAY_NAME } from "./branding"; |
| 19 | import { syncDocumentWindowControlsOverlayClass } from "./lib/windowControlsOverlay"; | 19 | import { syncDocumentWindowControlsOverlayClass } from "./lib/windowControlsOverlay"; |
| 20 | 20 | ||
| 21 | if (import.meta.env.DEV) { | ||
| 22 | void import("react-grab"); | ||
| 23 | } | ||
| 24 | |||
| 21 | // Electron loads the app from a file-backed shell, so hash history avoids path resolution issues. | 25 | // Electron loads the app from a file-backed shell, so hash history avoids path resolution issues. |
| 22 | const history = isElectron ? createHashHistory() : createBrowserHistory(); | 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,16 +2,21 @@ import { Outlet, createFileRoute, redirect } from "@tanstack/react-router"; |
| 2 | import { useEffect } from "react"; | 2 | import { useEffect } from "react"; |
| 3 | 3 | ||
| 4 | import { useCommandPaletteStore } from "../commandPaletteStore"; | 4 | import { useCommandPaletteStore } from "../commandPaletteStore"; |
| 5 | import { dispatchPreviewAction } from "../components/preview/previewActionBus"; | ||
| 5 | import { useHandleNewThread } from "../hooks/useHandleNewThread"; | 6 | import { useHandleNewThread } from "../hooks/useHandleNewThread"; |
| 6 | import { | 7 | import { |
| 7 | startNewLocalThreadFromContext, | 8 | startNewLocalThreadFromContext, |
| 8 | startNewThreadFromContext, | 9 | startNewThreadFromContext, |
| 9 | } from "../lib/chatThreadActions"; | 10 | } from "../lib/chatThreadActions"; |
| 11 | import { isPreviewFocused } from "../lib/previewFocus"; | ||
| 10 | import { isTerminalFocused } from "../lib/terminalFocus"; | 12 | import { isTerminalFocused } from "../lib/terminalFocus"; |
| 11 | import { resolveShortcutCommand } from "../keybindings"; | 13 | import { resolveShortcutCommand } from "../keybindings"; |
| 12 | import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; | 14 | import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; |
| 15 | import { isPreviewSupportedInRuntime } from "../previewStateStore"; | ||
| 16 | import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore"; | ||
| 13 | import { useThreadSelectionStore } from "../threadSelectionStore"; | 17 | import { useThreadSelectionStore } from "../threadSelectionStore"; |
| 14 | import { resolveSidebarNewThreadEnvMode } from "~/components/Sidebar.logic"; | 18 | import { resolveSidebarNewThreadEnvMode } from "~/components/Sidebar.logic"; |
| 19 | import { stackedThreadToast, toastManager } from "~/components/ui/toast"; | ||
| 15 | import { useSettings } from "~/hooks/useSettings"; | 20 | import { useSettings } from "~/hooks/useSettings"; |
| 16 | import { useServerKeybindings } from "~/rpc/serverState"; | 21 | import { useServerKeybindings } from "~/rpc/serverState"; |
| 17 | 22 | ||
| ... | @@ -26,6 +31,14 @@ function ChatRouteGlobalShortcuts() { | ... | @@ -26,6 +31,14 @@ function ChatRouteGlobalShortcuts() { |
| 26 | ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen | 31 | ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen |
| 27 | : false, | 32 | : false, |
| 28 | ); | 33 | ); |
| 34 | // The `previewOpen` shortcut-context flag here uses the store-only value; | ||
| 35 | // the URL-aware arbitration lives inside ChatView's `onTogglePreview`, | ||
| 36 | // which we invoke via the action bus to avoid duplicating the rule. | ||
| 37 | const previewOpen = useRightPanelStore((state) => | ||
| 38 | routeThreadRef | ||
| 39 | ? selectActiveRightPanel(state.byThreadKey, routeThreadRef) === "preview" | ||
| 40 | : false, | ||
| 41 | ); | ||
| 29 | const appSettings = useSettings(); | 42 | const appSettings = useSettings(); |
| 30 | 43 | ||
| 31 | useEffect(() => { | 44 | useEffect(() => { |
| ... | @@ -35,6 +48,8 @@ function ChatRouteGlobalShortcuts() { | ... | @@ -35,6 +48,8 @@ function ChatRouteGlobalShortcuts() { |
| 35 | context: { | 48 | context: { |
| 36 | terminalFocus: isTerminalFocused(), | 49 | terminalFocus: isTerminalFocused(), |
| 37 | terminalOpen, | 50 | terminalOpen, |
| 51 | previewFocus: isPreviewFocused(), | ||
| 52 | previewOpen, | ||
| 38 | }, | 53 | }, |
| 39 | }); | 54 | }); |
| 40 | 55 | ||
| ... | @@ -75,6 +90,50 @@ function ChatRouteGlobalShortcuts() { | ... | @@ -75,6 +90,50 @@ function ChatRouteGlobalShortcuts() { |
| 75 | }), | 90 | }), |
| 76 | handleNewThread, | 91 | handleNewThread, |
| 77 | }); | 92 | }); |
| 93 | return; | ||
| 94 | } | ||
| 95 | |||
| 96 | if (command === "preview.toggle") { | ||
| 97 | event.preventDefault(); | ||
| 98 | event.stopPropagation(); | ||
| 99 | if (!routeThreadRef) return; | ||
| 100 | if (!isPreviewSupportedInRuntime()) { | ||
| 101 | toastManager.add( | ||
| 102 | stackedThreadToast({ | ||
| 103 | type: "info", | ||
| 104 | title: "Preview is desktop-only", | ||
| 105 | description: "Open T3 Code in the desktop app to use the in-app preview.", | ||
| 106 | }), | ||
| 107 | ); | ||
| 108 | return; | ||
| 109 | } | ||
| 110 | dispatchPreviewAction("toggle-panel"); | ||
| 111 | return; | ||
| 112 | } | ||
| 113 | |||
| 114 | // The remaining preview commands only fire when the panel is the | ||
| 115 | // currently-focused tenant. The `when: previewFocus` rule already | ||
| 116 | // gates this, but defend against the keybinding being misconfigured. | ||
| 117 | if ( | ||
| 118 | command === "preview.refresh" || | ||
| 119 | command === "preview.focusUrl" || | ||
| 120 | command === "preview.zoomIn" || | ||
| 121 | command === "preview.zoomOut" || | ||
| 122 | command === "preview.resetZoom" | ||
| 123 | ) { | ||
| 124 | event.preventDefault(); | ||
| 125 | event.stopPropagation(); | ||
| 126 | const action = | ||
| 127 | command === "preview.refresh" | ||
| 128 | ? "refresh" | ||
| 129 | : command === "preview.focusUrl" | ||
| 130 | ? "focus-url" | ||
| 131 | : command === "preview.zoomIn" | ||
| 132 | ? "zoom-in" | ||
| 133 | : command === "preview.zoomOut" | ||
| 134 | ? "zoom-out" | ||
| 135 | : "reset-zoom"; | ||
| 136 | dispatchPreviewAction(action); | ||
| 78 | } | 137 | } |
| 79 | }; | 138 | }; |
| 80 | 139 | ||
| ... | @@ -89,6 +148,8 @@ function ChatRouteGlobalShortcuts() { | ... | @@ -89,6 +148,8 @@ function ChatRouteGlobalShortcuts() { |
| 89 | handleNewThread, | 148 | handleNewThread, |
| 90 | keybindings, | 149 | keybindings, |
| 91 | defaultProjectRef, | 150 | defaultProjectRef, |
| 151 | previewOpen, | ||
| 152 | routeThreadRef, | ||
| 92 | selectedThreadKeysSize, | 153 | selectedThreadKeysSize, |
| 93 | terminalOpen, | 154 | terminalOpen, |
| 94 | appSettings.defaultThreadEnvMode, | 155 | appSettings.defaultThreadEnvMode, |
docs/user/keybindings.md+14| ... | @@ -23,6 +23,12 @@ See the full schema for more details: [`packages/contracts/src/keybindings.ts`]( | ... | @@ -23,6 +23,12 @@ See the full schema for more details: [`packages/contracts/src/keybindings.ts`]( |
| 23 | { "key": "mod+d", "command": "terminal.split", "when": "terminalFocus" }, | 23 | { "key": "mod+d", "command": "terminal.split", "when": "terminalFocus" }, |
| 24 | { "key": "mod+n", "command": "terminal.new", "when": "terminalFocus" }, | 24 | { "key": "mod+n", "command": "terminal.new", "when": "terminalFocus" }, |
| 25 | { "key": "mod+w", "command": "terminal.close", "when": "terminalFocus" }, | 25 | { "key": "mod+w", "command": "terminal.close", "when": "terminalFocus" }, |
| 26 | { "key": "mod+shift+j", "command": "preview.toggle" }, | ||
| 27 | { "key": "mod+r", "command": "preview.refresh", "when": "previewFocus" }, | ||
| 28 | { "key": "mod+l", "command": "preview.focusUrl", "when": "previewFocus" }, | ||
| 29 | { "key": "mod+=", "command": "preview.zoomIn", "when": "previewFocus" }, | ||
| 30 | { "key": "mod+-", "command": "preview.zoomOut", "when": "previewFocus" }, | ||
| 31 | { "key": "mod+0", "command": "preview.resetZoom", "when": "previewFocus" }, | ||
| 26 | { "key": "mod+k", "command": "commandPalette.toggle", "when": "!terminalFocus" }, | 32 | { "key": "mod+k", "command": "commandPalette.toggle", "when": "!terminalFocus" }, |
| 27 | { "key": "mod+n", "command": "chat.new", "when": "!terminalFocus" }, | 33 | { "key": "mod+n", "command": "chat.new", "when": "!terminalFocus" }, |
| 28 | { "key": "mod+shift+o", "command": "chat.new", "when": "!terminalFocus" }, | 34 | { "key": "mod+shift+o", "command": "chat.new", "when": "!terminalFocus" }, |
| ... | @@ -51,6 +57,12 @@ Invalid rules are ignored. Invalid config files are ignored. Warnings are logged | ... | @@ -51,6 +57,12 @@ Invalid rules are ignored. Invalid config files are ignored. Warnings are logged |
| 51 | - `terminal.split`: split terminal (in focused terminal context by default) | 57 | - `terminal.split`: split terminal (in focused terminal context by default) |
| 52 | - `terminal.new`: create new terminal (in focused terminal context by default) | 58 | - `terminal.new`: create new terminal (in focused terminal context by default) |
| 53 | - `terminal.close`: close/kill the focused terminal (in focused terminal context by default) | 59 | - `terminal.close`: close/kill the focused terminal (in focused terminal context by default) |
| 60 | - `preview.toggle`: open/close the in-app browser preview panel (desktop app only) | ||
| 61 | - `preview.refresh`: reload the active preview tab (in focused preview context by default) | ||
| 62 | - `preview.focusUrl`: focus the URL input of the preview panel (in focused preview context by default) | ||
| 63 | - `preview.zoomIn`: zoom the preview viewport in one step (in focused preview context by default) | ||
| 64 | - `preview.zoomOut`: zoom the preview viewport out one step (in focused preview context by default) | ||
| 65 | - `preview.resetZoom`: reset the preview zoom to 100% (in focused preview context by default) | ||
| 54 | - `commandPalette.toggle`: open or close the global command palette | 66 | - `commandPalette.toggle`: open or close the global command palette |
| 55 | - `chat.new`: create a new chat thread preserving the active thread's branch/worktree state | 67 | - `chat.new`: create a new chat thread preserving the active thread's branch/worktree state |
| 56 | - `chat.newLocal`: create a new chat thread for the active project in a new environment (local/worktree determined by app settings (default `local`)) | 68 | - `chat.newLocal`: create a new chat thread for the active project in a new environment (local/worktree determined by app settings (default `local`)) |
| ... | @@ -80,6 +92,8 @@ Currently available context keys: | ... | @@ -80,6 +92,8 @@ Currently available context keys: |
| 80 | 92 | ||
| 81 | - `terminalFocus` | 93 | - `terminalFocus` |
| 82 | - `terminalOpen` | 94 | - `terminalOpen` |
| 95 | - `previewFocus` | ||
| 96 | - `previewOpen` | ||
| 83 | 97 | ||
| 84 | Supported operators: | 98 | Supported operators: |
| 85 | 99 |
packages/client-runtime/src/wsRpcClient.ts+31| ... | @@ -79,6 +79,16 @@ export interface WsRpcClient { | ... | @@ -79,6 +79,16 @@ export interface WsRpcClient { |
| 79 | readonly onEvent: RpcStreamMethod<typeof WS_METHODS.subscribeTerminalEvents>; | 79 | readonly onEvent: RpcStreamMethod<typeof WS_METHODS.subscribeTerminalEvents>; |
| 80 | readonly onMetadata: RpcStreamMethod<typeof WS_METHODS.subscribeTerminalMetadata>; | 80 | readonly onMetadata: RpcStreamMethod<typeof WS_METHODS.subscribeTerminalMetadata>; |
| 81 | }; | 81 | }; |
| 82 | readonly preview: { | ||
| 83 | readonly open: RpcUnaryMethod<typeof WS_METHODS.previewOpen>; | ||
| 84 | readonly navigate: RpcUnaryMethod<typeof WS_METHODS.previewNavigate>; | ||
| 85 | readonly refresh: RpcUnaryMethod<typeof WS_METHODS.previewRefresh>; | ||
| 86 | readonly close: RpcUnaryMethod<typeof WS_METHODS.previewClose>; | ||
| 87 | readonly list: RpcUnaryMethod<typeof WS_METHODS.previewList>; | ||
| 88 | readonly reportStatus: RpcUnaryMethod<typeof WS_METHODS.previewReportStatus>; | ||
| 89 | readonly onEvent: RpcStreamMethod<typeof WS_METHODS.subscribePreviewEvents>; | ||
| 90 | readonly subscribePorts: RpcStreamMethod<typeof WS_METHODS.subscribeDiscoveredLocalServers>; | ||
| 91 | }; | ||
| 82 | readonly projects: { | 92 | readonly projects: { |
| 83 | readonly searchEntries: RpcUnaryMethod<typeof WS_METHODS.projectsSearchEntries>; | 93 | readonly searchEntries: RpcUnaryMethod<typeof WS_METHODS.projectsSearchEntries>; |
| 84 | readonly writeFile: RpcUnaryMethod<typeof WS_METHODS.projectsWriteFile>; | 94 | readonly writeFile: RpcUnaryMethod<typeof WS_METHODS.projectsWriteFile>; |
| ... | @@ -212,6 +222,27 @@ export function createWsRpcClient( | ... | @@ -212,6 +222,27 @@ export function createWsRpcClient( |
| 212 | subscriptionOptions(options, WS_METHODS.subscribeTerminalMetadata), | 222 | subscriptionOptions(options, WS_METHODS.subscribeTerminalMetadata), |
| 213 | ), | 223 | ), |
| 214 | }, | 224 | }, |
| 225 | preview: { | ||
| 226 | open: (input) => transport.request((client) => client[WS_METHODS.previewOpen](input)), | ||
| 227 | navigate: (input) => transport.request((client) => client[WS_METHODS.previewNavigate](input)), | ||
| 228 | refresh: (input) => transport.request((client) => client[WS_METHODS.previewRefresh](input)), | ||
| 229 | close: (input) => transport.request((client) => client[WS_METHODS.previewClose](input)), | ||
| 230 | list: (input) => transport.request((client) => client[WS_METHODS.previewList](input)), | ||
| 231 | reportStatus: (input) => | ||
| 232 | transport.request((client) => client[WS_METHODS.previewReportStatus](input)), | ||
| 233 | onEvent: (listener, options) => | ||
| 234 | transport.subscribe( | ||
| 235 | (client) => client[WS_METHODS.subscribePreviewEvents]({}), | ||
| 236 | listener, | ||
| 237 | options, | ||
| 238 | ), | ||
| 239 | subscribePorts: (listener, options) => | ||
| 240 | transport.subscribe( | ||
| 241 | (client) => client[WS_METHODS.subscribeDiscoveredLocalServers]({}), | ||
| 242 | listener, | ||
| 243 | options, | ||
| 244 | ), | ||
| 245 | }, | ||
| 215 | projects: { | 246 | projects: { |
| 216 | searchEntries: (input) => | 247 | searchEntries: (input) => |
| 217 | transport.request((client) => client[WS_METHODS.projectsSearchEntries](input)), | 248 | transport.request((client) => client[WS_METHODS.projectsSearchEntries](input)), |
packages/contracts/src/index.ts+1| ... | @@ -22,4 +22,5 @@ export * from "./editor.ts"; | ... | @@ -22,4 +22,5 @@ export * from "./editor.ts"; |
| 22 | export * from "./project.ts"; | 22 | export * from "./project.ts"; |
| 23 | export * from "./filesystem.ts"; | 23 | export * from "./filesystem.ts"; |
| 24 | export * from "./review.ts"; | 24 | export * from "./review.ts"; |
| 25 | export * from "./preview.ts"; | ||
| 25 | export * from "./rpc.ts"; | 26 | export * from "./rpc.ts"; |
packages/contracts/src/ipc.ts+80| ... | @@ -54,6 +54,18 @@ import type { | ... | @@ -54,6 +54,18 @@ import type { |
| 54 | } from "./terminal.ts"; | 54 | } from "./terminal.ts"; |
| 55 | import type { ServerRemoveKeybindingInput, ServerUpsertKeybindingInput } from "./server.ts"; | 55 | import type { ServerRemoveKeybindingInput, ServerUpsertKeybindingInput } from "./server.ts"; |
| 56 | import * as Schema from "effect/Schema"; | 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 | import type { | 69 | import type { |
| 58 | ClientOrchestrationCommand, | 70 | ClientOrchestrationCommand, |
| 59 | OrchestrationGetFullThreadDiffInput, | 71 | OrchestrationGetFullThreadDiffInput, |
| ... | @@ -402,6 +414,33 @@ export const DesktopCloudAuthFetchResultSchema = Schema.Struct({ | ... | @@ -402,6 +414,33 @@ export const DesktopCloudAuthFetchResultSchema = Schema.Struct({ |
| 402 | }); | 414 | }); |
| 403 | export type DesktopCloudAuthFetchResult = typeof DesktopCloudAuthFetchResultSchema.Type; | 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 | export interface DesktopBridge { | 444 | export interface DesktopBridge { |
| 406 | getAppBranding: () => DesktopAppBranding | null; | 445 | getAppBranding: () => DesktopAppBranding | null; |
| 407 | getLocalEnvironmentBootstrap: () => DesktopEnvironmentBootstrap | null; | 446 | getLocalEnvironmentBootstrap: () => DesktopEnvironmentBootstrap | null; |
| ... | @@ -460,6 +499,34 @@ export interface DesktopBridge { | ... | @@ -460,6 +499,34 @@ export interface DesktopBridge { |
| 460 | downloadUpdate: () => Promise<DesktopUpdateActionResult>; | 499 | downloadUpdate: () => Promise<DesktopUpdateActionResult>; |
| 461 | installUpdate: () => Promise<DesktopUpdateActionResult>; | 500 | installUpdate: () => Promise<DesktopUpdateActionResult>; |
| 462 | onUpdateState: (listener: (state: DesktopUpdateState) => void) => () => void; | 501 | onUpdateState: (listener: (state: DesktopUpdateState) => void) => () => void; |
| 502 | /** | ||
| 503 | * Desktop-only preview surface. Present iff the renderer is hosted by the | ||
| 504 | * Electron desktop build; web builds have `preview === undefined`. | ||
| 505 | */ | ||
| 506 | preview?: DesktopPreviewBridge; | ||
| 507 | } | ||
| 508 | |||
| 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,4 +686,17 @@ export interface EnvironmentApi { |
| 619 | }, | 686 | }, |
| 620 | ) => () => void; | 687 | ) => () => void; |
| 621 | }; | 688 | }; |
| 689 | preview: { | ||
| 690 | open: (input: typeof PreviewOpenInput.Encoded) => Promise<PreviewSessionSnapshot>; | ||
| 691 | navigate: (input: typeof PreviewNavigateInput.Encoded) => Promise<PreviewSessionSnapshot>; | ||
| 692 | refresh: (input: typeof PreviewRefreshInput.Encoded) => Promise<void>; | ||
| 693 | close: (input: typeof PreviewCloseInput.Encoded) => Promise<void>; | ||
| 694 | list: (input: typeof PreviewListInput.Encoded) => Promise<PreviewListResult>; | ||
| 695 | reportStatus: (input: typeof PreviewReportStatusInput.Encoded) => Promise<void>; | ||
| 696 | onEvent: (callback: (event: PreviewEvent) => void) => () => void; | ||
| 697 | subscribePorts: ( | ||
| 698 | callback: (servers: DiscoveredLocalServerList) => void, | ||
| 699 | options?: { onResubscribe?: () => void }, | ||
| 700 | ) => () => void; | ||
| 701 | }; | ||
| 622 | } | 702 | } |
packages/contracts/src/keybindings.ts+6| ... | @@ -53,6 +53,12 @@ const STATIC_KEYBINDING_COMMANDS = [ | ... | @@ -53,6 +53,12 @@ const STATIC_KEYBINDING_COMMANDS = [ |
| 53 | "terminal.new", | 53 | "terminal.new", |
| 54 | "terminal.close", | 54 | "terminal.close", |
| 55 | "diff.toggle", | 55 | "diff.toggle", |
| 56 | "preview.toggle", | ||
| 57 | "preview.refresh", | ||
| 58 | "preview.focusUrl", | ||
| 59 | "preview.zoomIn", | ||
| 60 | "preview.zoomOut", | ||
| 61 | "preview.resetZoom", | ||
| 56 | "commandPalette.toggle", | 62 | "commandPalette.toggle", |
| 57 | "chat.new", | 63 | "chat.new", |
| 58 | "chat.newLocal", | 64 | "chat.newLocal", |
packages/contracts/src/orchestration.ts+11| ... | @@ -194,6 +194,17 @@ export const ProjectScript = Schema.Struct({ | ... | @@ -194,6 +194,17 @@ export const ProjectScript = Schema.Struct({ |
| 194 | command: TrimmedNonEmptyString, | 194 | command: TrimmedNonEmptyString, |
| 195 | icon: ProjectScriptIcon, | 195 | icon: ProjectScriptIcon, |
| 196 | runOnWorktreeCreate: Schema.Boolean, | 196 | runOnWorktreeCreate: Schema.Boolean, |
| 197 | /** | ||
| 198 | * URL to open in the in-app browser preview when this script runs (or | ||
| 199 | * when the user explicitly requests a preview). Optional; only honored on | ||
| 200 | * the desktop build. | ||
| 201 | */ | ||
| 202 | previewUrl: Schema.optional(TrimmedNonEmptyString), | ||
| 203 | /** | ||
| 204 | * When true, automatically open the preview panel pointed at `previewUrl` | ||
| 205 | * the moment this script starts. Ignored without `previewUrl` or on web. | ||
| 206 | */ | ||
| 207 | autoOpenPreview: Schema.optional(Schema.Boolean), | ||
| 197 | }); | 208 | }); |
| 198 | export type ProjectScript = typeof ProjectScript.Type; | 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,6 +85,19 @@ import { |
| 85 | TerminalSessionSnapshot, | 85 | TerminalSessionSnapshot, |
| 86 | TerminalWriteInput, | 86 | TerminalWriteInput, |
| 87 | } from "./terminal.ts"; | 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 | import { | 101 | import { |
| 89 | ServerConfigStreamEvent, | 102 | ServerConfigStreamEvent, |
| 90 | ServerConfig, | 103 | ServerConfig, |
| ... | @@ -157,6 +170,14 @@ export const WS_METHODS = { | ... | @@ -157,6 +170,14 @@ export const WS_METHODS = { |
| 157 | terminalRestart: "terminal.restart", | 170 | terminalRestart: "terminal.restart", |
| 158 | terminalClose: "terminal.close", | 171 | terminalClose: "terminal.close", |
| 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 | // Server meta | 181 | // Server meta |
| 161 | serverGetConfig: "server.getConfig", | 182 | serverGetConfig: "server.getConfig", |
| 162 | serverRefreshProviders: "server.refreshProviders", | 183 | serverRefreshProviders: "server.refreshProviders", |
| ... | @@ -184,6 +205,8 @@ export const WS_METHODS = { | ... | @@ -184,6 +205,8 @@ export const WS_METHODS = { |
| 184 | subscribeVcsStatus: "subscribeVcsStatus", | 205 | subscribeVcsStatus: "subscribeVcsStatus", |
| 185 | subscribeTerminalEvents: "subscribeTerminalEvents", | 206 | subscribeTerminalEvents: "subscribeTerminalEvents", |
| 186 | subscribeTerminalMetadata: "subscribeTerminalMetadata", | 207 | subscribeTerminalMetadata: "subscribeTerminalMetadata", |
| 208 | subscribePreviewEvents: "subscribePreviewEvents", | ||
| 209 | subscribeDiscoveredLocalServers: "subscribeDiscoveredLocalServers", | ||
| 187 | subscribeServerConfig: "subscribeServerConfig", | 210 | subscribeServerConfig: "subscribeServerConfig", |
| 188 | subscribeServerLifecycle: "subscribeServerLifecycle", | 211 | subscribeServerLifecycle: "subscribeServerLifecycle", |
| 189 | subscribeAuthAccess: "subscribeAuthAccess", | 212 | subscribeAuthAccess: "subscribeAuthAccess", |
| ... | @@ -454,6 +477,53 @@ export const WsTerminalCloseRpc = Rpc.make(WS_METHODS.terminalClose, { | ... | @@ -454,6 +477,53 @@ export const WsTerminalCloseRpc = Rpc.make(WS_METHODS.terminalClose, { |
| 454 | error: Schema.Union([TerminalError, EnvironmentAuthorizationError]), | 477 | error: Schema.Union([TerminalError, EnvironmentAuthorizationError]), |
| 455 | }); | 478 | }); |
| 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 | export const WsOrchestrationDispatchCommandRpc = Rpc.make( | 527 | export const WsOrchestrationDispatchCommandRpc = Rpc.make( |
| 458 | ORCHESTRATION_WS_METHODS.dispatchCommand, | 528 | ORCHESTRATION_WS_METHODS.dispatchCommand, |
| 459 | { | 529 | { |
| ... | @@ -589,6 +659,14 @@ export const WsRpcGroup = RpcGroup.make( | ... | @@ -589,6 +659,14 @@ export const WsRpcGroup = RpcGroup.make( |
| 589 | WsTerminalCloseRpc, | 659 | WsTerminalCloseRpc, |
| 590 | WsSubscribeTerminalEventsRpc, | 660 | WsSubscribeTerminalEventsRpc, |
| 591 | WsSubscribeTerminalMetadataRpc, | 661 | WsSubscribeTerminalMetadataRpc, |
| 662 | WsPreviewOpenRpc, | ||
| 663 | WsPreviewNavigateRpc, | ||
| 664 | WsPreviewRefreshRpc, | ||
| 665 | WsPreviewCloseRpc, | ||
| 666 | WsPreviewListRpc, | ||
| 667 | WsPreviewReportStatusRpc, | ||
| 668 | WsSubscribePreviewEventsRpc, | ||
| 669 | WsSubscribeDiscoveredLocalServersRpc, | ||
| 592 | WsSubscribeServerConfigRpc, | 670 | WsSubscribeServerConfigRpc, |
| 593 | WsSubscribeServerLifecycleRpc, | 671 | WsSubscribeServerLifecycleRpc, |
| 594 | WsSubscribeAuthAccessRpc, | 672 | WsSubscribeAuthAccessRpc, |
packages/shared/package.json+4| ... | @@ -150,6 +150,10 @@ | ... | @@ -150,6 +150,10 @@ |
| 150 | "./relayClient": { | 150 | "./relayClient": { |
| 151 | "types": "./src/relayClient.ts", | 151 | "types": "./src/relayClient.ts", |
| 152 | "import": "./src/relayClient.ts" | 152 | "import": "./src/relayClient.ts" |
| 153 | }, | ||
| 154 | "./preview": { | ||
| 155 | "types": "./src/preview.ts", | ||
| 156 | "import": "./src/preview.ts" | ||
| 153 | } | 157 | } |
| 154 | }, | 158 | }, |
| 155 | "scripts": { | 159 | "scripts": { |
packages/shared/src/keybindings.ts+7| ... | @@ -24,6 +24,13 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [ | ... | @@ -24,6 +24,13 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [ |
| 24 | { key: "mod+n", command: "terminal.new", when: "terminalFocus" }, | 24 | { key: "mod+n", command: "terminal.new", when: "terminalFocus" }, |
| 25 | { key: "mod+w", command: "terminal.close", when: "terminalFocus" }, | 25 | { key: "mod+w", command: "terminal.close", when: "terminalFocus" }, |
| 26 | { key: "mod+d", command: "diff.toggle", when: "!terminalFocus" }, | 26 | { key: "mod+d", command: "diff.toggle", when: "!terminalFocus" }, |
| 27 | { key: "mod+shift+j", command: "preview.toggle" }, | ||
| 28 | { key: "mod+r", command: "preview.refresh", when: "previewFocus" }, | ||
| 29 | { key: "mod+l", command: "preview.focusUrl", when: "previewFocus" }, | ||
| 30 | { key: "mod+=", command: "preview.zoomIn", when: "previewFocus" }, | ||
| 31 | { key: "mod++", command: "preview.zoomIn", when: "previewFocus" }, | ||
| 32 | { key: "mod+-", command: "preview.zoomOut", when: "previewFocus" }, | ||
| 33 | { key: "mod+0", command: "preview.resetZoom", when: "previewFocus" }, | ||
| 27 | { key: "mod+k", command: "commandPalette.toggle", when: "!terminalFocus" }, | 34 | { key: "mod+k", command: "commandPalette.toggle", when: "!terminalFocus" }, |
| 28 | { key: "mod+n", command: "chat.new", when: "!terminalFocus" }, | 35 | { key: "mod+n", command: "chat.new", when: "!terminalFocus" }, |
| 29 | { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, | 36 | { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, |
packages/shared/src/preview.test.ts created+75| ... | @@ -0,0 +1,75 @@ | ||
| 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 | } | ||