Check any cables and reboot any routers, modems, or other network
+ devices you may be using.
+
+
+
+
Check your DNS settings
+
Contact your network administrator if you're not sure what this means.
+
+
+
+
Try disabling network prediction
+
Go to
+ the Helium menu >
+ Settings
+ >
+ Show advanced settings…
+ and deselect "Use a prediction service to load pages more quickly."
+ If this does not resolve the issue, we recommend selecting this option
+ again for improved performance.
+
+
+
+
Allow Helium to access the network in your firewall or antivirus
+ settings.
+
If it is already listed as a program allowed to access the network, try
+ removing it from the list and adding it again.
+
+
+
+
If you use a proxy server…
+
Go to Applications > System Settings > Network, select the
+ active network, click the Details… button, and deselect any proxies
+ that may have been selected.
+
+
+
+
+
+
+
+
+
+
asdasddddd.com’s server IP address could not be found.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app-preview-browser-plan.md b/app-preview-browser-plan.md
new file mode 100644
index 0000000000000000000000000000000000000000..078c1cc93e9dd7c1b3b62d5eab01181dbcb3bf7c
--- /dev/null
+++ b/app-preview-browser-plan.md
@@ -0,0 +1,1829 @@
+# In‑App Preview Browser — Implementation Plan
+
+> **Scope**: A Chromium‑backed preview browser slot in the chat workspace, available **only in the desktop build**. Web build shows nothing for this feature (no iframe fallback). Server tracks per‑thread preview session metadata so it survives reconnects and multi‑window/multi‑client. Reachable via three vectors: a keybinding, a "Open in preview" affordance on terminal URLs, and a `ProjectScript.previewUrl/autoOpenPreview` extension.
+>
+> **Reference implementation we're modelling on**: ami's `packages/desktop/src/browser-view-manager.ts` + `packages/interface/src/components/browser-view/`. We're porting a deliberately smaller subset.
+>
+> **Done in one shot**: this is a single multi‑PR‑sized landing. Below is the file‑by‑file checklist.
+
+---
+
+## 1. Architecture
+
+### Three‑actor model
+
+```
+┌────────────────────────────────────────────────────────────────────────────┐
+│ apps/server (Node, Effect) │
+│ ┌──────────────────────────────────────────────────────────────────────┐ │
+│ │ PreviewManager — Map │ │
+│ │ • metadata only: { tabId, url, title, navStatus, lastError, … } │ │
+│ │ • broadcasts PreviewEvent over WS │ │
+│ │ • survives client disconnect, replays on reconnect │ │
+│ └──────────────────────────────────────────────────────────────────────┘ │
+└────────────────────────────────────────────────────────────────────────────┘
+ ▲ │
+ EnvironmentApi.preview │ preview.onEvent push
+ (WS RPC) ▼
+┌────────────────────────────────────────────────────────────────────────────┐
+│ apps/desktop (Electron renderer = apps/web) │
+│ ┌──────────────────────────────────────────────────────────────────────┐ │
+│ │ PreviewView (React) — chrome (URL bar, back/fwd/refresh) │ │
+│ │ ┌────────────────────────────────────────────────────────────────┐ │ │
+│ │ │ │ │ │
+│ │ └────────────────────────────────────────────────────────────────┘ │ │
+│ │ zustand: previewStateStore (mirrors terminalStateStore) │ │
+│ └──────────────────────────────────────────────────────────────────────┘ │
+└────────────────────────────────────────────────────────────────────────────┘
+ ▲ │
+ desktopBridge.preview.* │ desktopBridge.preview.onStateChange
+ (Electron IPC) ▼
+┌────────────────────────────────────────────────────────────────────────────┐
+│ apps/desktop main process (Node) │
+│ ┌──────────────────────────────────────────────────────────────────────┐ │
+│ │ PreviewViewManager — Map │ │
+│ │ • createTab/closeTab/navigate/registerWebview/setVisibility │ │
+│ │ • attaches did-navigate / did-fail-load / page-title-updated │ │
+│ │ • partitioned session: persist:t3code-preview │ │
+│ │ • forwards app shortcuts to mainWindow (mod+w, mod+, mod+1..9, …) │ │
+│ └──────────────────────────────────────────────────────────────────────┘ │
+└────────────────────────────────────────────────────────────────────────────┘
+```
+
+### Why the server even cares
+
+The desktop already has the `` and could be the sole source of truth. We route through the server anyway because:
+
+1. Reconnect/restart resilience matches the rest of t3code — terminal sessions, orchestration, etc. all use snapshot+replay.
+2. A future second window or a remote viewer (e.g. mobile observer) sees the same URL the desktop is on.
+3. Agent‑facing tooling (later) is RPC‑shaped and lives on the server, not the desktop bridge.
+
+### Why the renderer subscribes to two streams
+
+- **Server `preview.onEvent`** — authoritative for `url`, `title`, `lastError`. Replays on WS reconnect.
+- **`desktopBridge.preview.onStateChange`** — authoritative for low‑latency `canGoBack`, `canGoForward`, `loading`. Cheaper than round‑tripping through the server.
+
+The web side merges them in `previewStateStore.applyServerEvent` / `applyDesktopState`.
+
+### Web build behaviour
+
+When `window.desktopBridge?.preview == null`:
+
+- `previewStateStore` selectors return a frozen "unsupported" shape.
+- `PreviewPanel.tsx` short‑circuits to `null` (panel never renders).
+- `rightPanelStore` rejects `kind: "preview"` writes.
+- `preview.toggle` keybinding fires a toast: _"Preview is only available in the T3 Code desktop app."_
+- Terminal‑link "Open in preview" menu item is hidden (only "Open in browser" shows).
+- `ProjectScript.previewUrl` is still **stored** (so it round‑trips between web/desktop users of the same project) but ignored.
+
+---
+
+## 2. Right‑panel arbiter (prerequisite refactor)
+
+Today the right side has two implicit tenants and no arbiter:
+
+- **Diff panel** — driven by URL `?diff=1` (`apps/web/src/diffRouteSearch.ts`). Toggle wired to `diff.toggle` keybinding.
+- **Plan sidebar** — driven by local component state `planSidebarOpen` in `ChatView.tsx:688`. Renders inline (sibling div) when wide and as `` when narrow.
+
+They co‑exist by accident. With a third panel we need an explicit arbiter.
+
+### New: `apps/web/src/rightPanelStore.ts`
+
+```ts
+import { create } from "zustand";
+import { persist, createJSONStorage } from "zustand/middleware";
+import { type ScopedThreadRef, scopedThreadKey } from "@t3tools/client-runtime";
+import { resolveStorage } from "./lib/storage";
+
+export type RightPanelKind = "plan" | "diff" | "preview";
+
+interface ThreadRightPanelState {
+ /** null = closed; otherwise the active panel for this thread */
+ active: RightPanelKind | null;
+}
+
+const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v1";
+
+interface RightPanelStoreState {
+ byThreadKey: Record;
+ open: (ref: ScopedThreadRef, kind: RightPanelKind) => void;
+ close: (ref: ScopedThreadRef) => void;
+ toggle: (ref: ScopedThreadRef, kind: RightPanelKind) => void;
+}
+
+export const useRightPanelStore = create()(
+ persist(/* … */, {
+ name: RIGHT_PANEL_STORAGE_KEY,
+ storage: createJSONStorage(() => resolveStorage(window.localStorage)),
+ version: 1,
+ }),
+);
+
+export function selectActiveRightPanel(
+ store: RightPanelStoreState,
+ ref: ScopedThreadRef | null,
+): RightPanelKind | null {
+ if (!ref) return null;
+ return store.byThreadKey[scopedThreadKey(ref)]?.active ?? null;
+}
+```
+
+### Diff panel migration
+
+`?diff=1` stays the URL source of truth (deep‑linking matters for diff). On router navigation, mirror it into `rightPanelStore`:
+
+- `parseDiffRouteSearch(...).diff === "1"` → `rightPanelStore.open(activeThreadRef, "diff")` in a `useEffect`.
+- Opening plan/preview removes the `?diff` param via `stripDiffSearchParams` (already exists in `apps/web/src/diffRouteSearch.ts`).
+- Closing diff via the X button does both: navigate to strip `?diff=1` **and** call `rightPanelStore.close(...)`.
+
+### Plan sidebar migration
+
+`apps/web/src/components/ChatView.tsx`:
+
+- Remove the local `planSidebarOpen` state (`:688`).
+- Replace `setPlanSidebarOpen(true)` callsites with `rightPanelStore.open(activeThreadRef, "plan")`.
+- Replace `closePlanSidebar` with `rightPanelStore.close(activeThreadRef)`.
+- Existing `planSidebarDismissedForTurnRef`/`planSidebarOpenOnNextThreadRef` logic stays local — it only governs whether to *call* `open()`/`close()` on turn change.
+
+### Render arbitration
+
+The single render decision in `ChatView.tsx`:
+
+```tsx
+const activeRightPanel = useRightPanelStore((s) =>
+ selectActiveRightPanel(s, activeThreadRef),
+);
+const useSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY);
+
+// Inline (wide):
+{activeRightPanel === "plan" && !useSheet && }
+{activeRightPanel === "preview" && !useSheet && }
+{/* DiffPanel inline rendering already lives in DiffPanelShell, just gate it on activeRightPanel === "diff" */}
+
+// Sheet (narrow):
+{useSheet && activeRightPanel !== null && (
+ rightPanelStore.close(activeThreadRef)}>
+ {activeRightPanel === "plan" && }
+ {activeRightPanel === "diff" && }
+ {activeRightPanel === "preview" && }
+
+)}
+```
+
+---
+
+## 3. File map
+
+### NEW files
+
+| File | Purpose |
+|---|---|
+| `packages/contracts/src/preview.ts` | Effect/Schema schemas: inputs, snapshot, events, errors |
+| `packages/contracts/src/preview.test.ts` | Schema round‑trip tests |
+| `apps/server/src/preview/Services/Manager.ts` | `PreviewManager` Service tag + interface |
+| `apps/server/src/preview/Layers/Manager.ts` | Implementation: in‑memory map + event subject |
+| `apps/server/src/preview/Layers/Manager.test.ts` | Lifecycle, snapshot, event ordering |
+| `apps/desktop/src/preview-view-manager.ts` | Plain‑Node Electron port of ami's BrowserManager (subset) |
+| `apps/desktop/src/preview-preload.ts` | Webview preload (no‑op v1) |
+| `apps/web/src/previewStateStore.ts` | Per‑thread zustand store (mirrors `terminalStateStore.ts`) |
+| `apps/web/src/previewStateStore.test.ts` | Reducer tests |
+| `apps/web/src/rightPanelStore.ts` | Right‑panel arbiter |
+| `apps/web/src/rightPanelStore.test.ts` | Arbiter tests |
+| `apps/web/src/components/preview/PreviewPanel.tsx` | Right‑panel entry (wraps `PreviewPanelShell`) |
+| `apps/web/src/components/preview/PreviewPanelShell.tsx` | Shell mirroring `DiffPanelShell` (`mode: "inline"\|"sheet"\|"sidebar"`) |
+| `apps/web/src/components/preview/PreviewView.tsx` | Chrome bar (URL/back/fwd/refresh) + `` |
+| `apps/web/src/components/preview/PreviewWebview.tsx` | Electron `` host; null on web build |
+| `apps/web/src/components/preview/PreviewEmptyState.tsx` | Pre‑URL empty state |
+| `apps/web/src/components/preview/PreviewUnsupportedToast.ts` | `"Preview is only available in the desktop app"` toast |
+
+### MODIFIED files
+
+| File | Change |
+|---|---|
+| `packages/contracts/src/keybindings.ts` | Add `preview.toggle`, `preview.refresh`, `preview.focusUrl` to `STATIC_KEYBINDING_COMMANDS`; add `previewFocus`, `previewOpen` to context keys |
+| `packages/contracts/src/project.ts` | Add `previewUrl?: string` and `autoOpenPreview?: boolean` to `ProjectScript` schema |
+| `packages/contracts/src/server.ts` | Extend `EnvironmentApi` with `preview` namespace |
+| `packages/contracts/src/index.ts` | Re‑export new types |
+| `apps/server/src/keybindings.ts` | Add defaults: `mod+shift+j` → `preview.toggle`, `mod+shift+r` → `preview.refresh` (when `previewFocus`) |
+| `apps/server/src/ws.ts` | Route `preview.open`, `preview.navigate`, `preview.refresh`, `preview.close`, `preview.list`, `preview.onEvent` |
+| `apps/server/src/orchestration/runtimeLayer.ts` (or equivalent) | Provide `PreviewManager.Default` |
+| `apps/web/src/environmentApi.ts` | Wire `preview` slot in `createEnvironmentApi` |
+| `apps/web/src/keybindings.ts` | Add `isPreviewToggleShortcut`, `isPreviewRefreshShortcut` helpers |
+| `apps/web/src/routes/_chat.tsx` | Handle `preview.toggle` in global shortcut handler |
+| `apps/web/src/components/ChatView.tsx` | Replace local `planSidebarOpen` with `rightPanelStore`; render `PreviewPanel` |
+| `apps/web/src/components/ThreadTerminalDrawer.tsx` | At terminal link activation, when `match.kind === "url"` and link looks like a dev URL, show context menu with "Open in preview" / "Open in browser"; pass through to `localApi.preview.openTab(...)` when chosen |
+| `apps/web/src/components/ProjectScriptsControl.tsx` | Add `previewUrl` + `autoOpenPreview` form fields in the Add/Edit dialog |
+| `apps/web/src/projectScripts.ts` | Carry the new fields through `commandForProjectScript` / serialization |
+| `apps/web/src/types.ts` | Add `PreviewSession` mirror types (or re‑export from contracts) |
+| `apps/web/src/lib/desktopBridge.d.ts` (or wherever bridge types live) | Add `preview` namespace shape |
+| `apps/desktop/src/main.ts` | Register `preview:*` IPC handlers; instantiate `previewViewManager`; wire `mainWindow` injection |
+| `apps/desktop/src/preload.ts` | Expose `desktopBridge.preview.*` |
+| `KEYBINDINGS.md` | Document new commands and `previewFocus`/`previewOpen` `when` keys |
+
+### NOT changed
+
+- `apps/web/src/components/DiffPanel.tsx` — unchanged, its open/close just becomes mediated by `rightPanelStore`. The `?diff=1` URL truth is preserved via a sync effect.
+- `apps/web/src/components/PlanSidebar.tsx` — unchanged surface; consumer in `ChatView.tsx` is what changes.
+- `packages/contracts/src/terminal.ts` — terminal stays single‑tab, no schema changes.
+
+---
+
+## 4. Schemas (`packages/contracts/src/preview.ts`)
+
+```ts
+import { Schema } from "effect";
+import { TrimmedNonEmptyString } from "./baseSchemas.ts";
+
+export const PreviewTabId = TrimmedNonEmptyString.check(Schema.isMaxLength(128));
+export type PreviewTabId = typeof PreviewTabId.Type;
+
+export const PreviewNavStatus = Schema.Union([
+ Schema.Struct({ _tag: Schema.Literal("Idle") }),
+ Schema.Struct({
+ _tag: Schema.Literal("Loading"),
+ url: TrimmedNonEmptyString,
+ title: Schema.String,
+ }),
+ Schema.Struct({
+ _tag: Schema.Literal("Success"),
+ url: TrimmedNonEmptyString,
+ title: Schema.String,
+ }),
+ Schema.Struct({
+ _tag: Schema.Literal("LoadFailed"),
+ url: TrimmedNonEmptyString,
+ title: Schema.String,
+ code: Schema.Int,
+ description: Schema.String,
+ }),
+]);
+export type PreviewNavStatus = typeof PreviewNavStatus.Type;
+
+export const PreviewSessionSnapshot = Schema.Struct({
+ threadId: TrimmedNonEmptyString,
+ tabId: PreviewTabId,
+ navStatus: PreviewNavStatus,
+ canGoBack: Schema.Boolean,
+ canGoForward: Schema.Boolean,
+ updatedAt: Schema.String,
+});
+export type PreviewSessionSnapshot = typeof PreviewSessionSnapshot.Type;
+
+export const PreviewOpenInput = Schema.Struct({
+ threadId: TrimmedNonEmptyString,
+ url: TrimmedNonEmptyString,
+});
+export const PreviewNavigateInput = Schema.Struct({
+ threadId: TrimmedNonEmptyString,
+ tabId: PreviewTabId,
+ url: TrimmedNonEmptyString,
+});
+export const PreviewRefreshInput = Schema.Struct({
+ threadId: TrimmedNonEmptyString,
+ tabId: PreviewTabId,
+});
+export const PreviewCloseInput = Schema.Struct({
+ threadId: TrimmedNonEmptyString,
+ tabId: Schema.optional(PreviewTabId),
+});
+
+const PreviewEventBase = Schema.Struct({
+ threadId: TrimmedNonEmptyString,
+ tabId: PreviewTabId,
+ createdAt: Schema.String,
+});
+
+export const PreviewEvent = Schema.Union([
+ Schema.Struct({
+ ...PreviewEventBase.fields,
+ type: Schema.Literal("opened"),
+ snapshot: PreviewSessionSnapshot,
+ }),
+ Schema.Struct({
+ ...PreviewEventBase.fields,
+ type: Schema.Literal("navigated"),
+ snapshot: PreviewSessionSnapshot,
+ }),
+ Schema.Struct({
+ ...PreviewEventBase.fields,
+ type: Schema.Literal("failed"),
+ code: Schema.Int,
+ description: Schema.String,
+ }),
+ Schema.Struct({
+ ...PreviewEventBase.fields,
+ type: Schema.Literal("closed"),
+ }),
+]);
+export type PreviewEvent = typeof PreviewEvent.Type;
+
+export class PreviewSessionLookupError extends Schema.TaggedErrorClass()(
+ "PreviewSessionLookupError",
+ { threadId: Schema.String, tabId: Schema.String },
+) {
+ override get message() {
+ return `Unknown preview session: thread=${this.threadId}, tab=${this.tabId}`;
+ }
+}
+
+export class PreviewInvalidUrlError extends Schema.TaggedErrorClass()(
+ "PreviewInvalidUrlError",
+ { rawUrl: Schema.String },
+) {
+ override get message() {
+ return `Invalid preview URL: ${this.rawUrl}`;
+ }
+}
+
+export const PreviewError = Schema.Union([
+ PreviewSessionLookupError,
+ PreviewInvalidUrlError,
+]);
+export type PreviewError = typeof PreviewError.Type;
+```
+
+### `ProjectScript` extension (`packages/contracts/src/project.ts`)
+
+Add to the existing `ProjectScript` struct (additive, default both fields to undefined/false at runtime so existing persisted scripts decode unchanged):
+
+```ts
+previewUrl: Schema.optional(TrimmedNonEmptyString),
+autoOpenPreview: Schema.optional(Schema.Boolean),
+```
+
+### Keybindings (`packages/contracts/src/keybindings.ts:50`)
+
+```ts
+const STATIC_KEYBINDING_COMMANDS = [
+ "terminal.toggle",
+ "terminal.split",
+ "terminal.new",
+ "terminal.close",
+ "diff.toggle",
+ "preview.toggle", // NEW
+ "preview.refresh", // NEW
+ "preview.focusUrl", // NEW
+ "commandPalette.toggle",
+ "chat.new",
+ "chat.newLocal",
+ "editor.openFavorite",
+] as const;
+```
+
+Add `previewFocus` and `previewOpen` to the `ShortcutMatchContext` union (`apps/web/src/keybindings.ts:30`).
+
+### Defaults (`apps/server/src/keybindings.ts`)
+
+```ts
+{ key: "mod+shift+j", command: "preview.toggle" },
+{ key: "mod+r", command: "preview.refresh", when: "previewFocus" },
+{ key: "mod+l", command: "preview.focusUrl", when: "previewFocus" },
+```
+
+---
+
+## 5. Server: PreviewManager
+
+### `apps/server/src/preview/Services/Manager.ts`
+
+```ts
+import { Context, Effect } from "effect";
+import {
+ PreviewCloseInput,
+ PreviewError,
+ PreviewEvent,
+ PreviewNavigateInput,
+ PreviewOpenInput,
+ PreviewRefreshInput,
+ PreviewSessionSnapshot,
+} from "@t3tools/contracts";
+
+export interface PreviewManagerShape {
+ readonly open: (
+ input: PreviewOpenInput,
+ ) => Effect.Effect;
+ readonly navigate: (
+ input: PreviewNavigateInput,
+ ) => Effect.Effect;
+ readonly refresh: (
+ input: PreviewRefreshInput,
+ ) => Effect.Effect;
+ readonly close: (input: PreviewCloseInput) => Effect.Effect;
+ readonly list: (
+ threadId: string,
+ ) => Effect.Effect>;
+ readonly subscribe: (
+ listener: (event: PreviewEvent) => Effect.Effect,
+ ) => Effect.Effect<() => void>;
+}
+
+export class PreviewManager extends Context.Service<
+ PreviewManager,
+ PreviewManagerShape
+>()("t3/preview/Services/Manager/PreviewManager") {}
+```
+
+### `apps/server/src/preview/Layers/Manager.ts`
+
+In‑memory `Map` keyed by `threadId` (single tab per thread for v1; the schema has `tabId` so we can grow to multi‑tab without a migration). Maintains a subscriber set; emits events with monotonic `createdAt` from `Date.now().toISOString()`.
+
+URL normalization mirrors ami's helper (`browser-view-manager.ts:655`):
+
+```ts
+const normalizeUrl = (input: string) =>
+ Effect.try(() => {
+ const trimmed = input.trim();
+ if (!trimmed) throw new Error("empty");
+ // localhost stays http unless explicitly https
+ const useHttp =
+ /^(localhost|127\.0\.0\.1|0\.0\.0\.0)(:|\/|$)/.test(trimmed);
+ const parsed = urlParseLax(trimmed, { https: !useHttp });
+ if (!parsed?.href) throw new Error("unparseable");
+ return parsed.href;
+ }).pipe(
+ Effect.catchAll((cause) =>
+ Effect.fail(new PreviewInvalidUrlError({ rawUrl: input, cause })),
+ ),
+ );
+```
+
+### `apps/server/src/ws.ts` routes
+
+Following the existing terminal route pattern, expose:
+
+```ts
+preview: {
+ open: (input) => Effect.runPromise(previewManager.open(input)),
+ navigate: (input) => Effect.runPromise(previewManager.navigate(input)),
+ refresh: (input) => Effect.runPromise(previewManager.refresh(input)),
+ close: (input) => Effect.runPromise(previewManager.close(input)),
+ list: (threadId) => Effect.runPromise(previewManager.list(threadId)),
+ onEvent: (callback) => /* subscribe + return unsubscribe */,
+}
+```
+
+---
+
+## 6. Desktop: `PreviewViewManager`
+
+### Style
+
+`apps/desktop/src/main.ts` is plain Node/Electron with no Effect — for parity, **drop Effect in `preview-view-manager.ts`**. Use plain async/await + a small typed‑error class style consistent with the rest of `apps/desktop`.
+
+### `apps/desktop/src/preview-view-manager.ts`
+
+Subset of ami's `BrowserManager`:
+
+```ts
+import * as path from "node:path";
+import { type BrowserWindow, type Session, session, webContents } from "electron";
+
+const PREVIEW_PARTITION = "persist:t3code-preview";
+
+export type NavStatus =
+ | { kind: "Idle" }
+ | { kind: "Loading"; url: string; title: string }
+ | { kind: "Success"; url: string; title: string }
+ | { kind: "LoadFailed"; url: string; title: string; code: number; description: string };
+
+export interface TabState {
+ tabId: string;
+ webContentsId: number | null;
+ navStatus: NavStatus;
+ canGoBack: boolean;
+ canGoForward: boolean;
+ visible: boolean;
+ updatedAt: string;
+}
+
+type Listener = (tabId: string, state: TabState) => void;
+
+export class PreviewViewManager {
+ private mainWindow: BrowserWindow | null = null;
+ private readonly tabs = new Map();
+ private browserSession: Session | null = null;
+ private readonly listeners = new Set();
+ private readonly preloadPath: string;
+
+ constructor() {
+ this.preloadPath = path.join(__dirname, "preview-preload.cjs");
+ }
+
+ setMainWindow(window: BrowserWindow): void {
+ this.mainWindow = window;
+ }
+
+ getPreloadPath(): string { return this.preloadPath; }
+ getBrowserPartition(): string { return PREVIEW_PARTITION; }
+
+ getBrowserSession(): Session {
+ if (this.browserSession) return this.browserSession;
+ const sess = session.fromPartition(PREVIEW_PARTITION);
+ // strip electron/t3code from UA so dev preview doesn't trip bot detection
+ const ua = sess.getUserAgent()
+ .replace(/Electron\/[\d.]+ /, "")
+ .replace(/\s*t3code\/[\d.]+/, "");
+ sess.setUserAgent(ua);
+ sess.setPermissionRequestHandler((_wc, perm, callback) => {
+ const allow = ["clipboard-read", "clipboard-write", "notifications", "geolocation"];
+ callback(allow.includes(perm));
+ });
+ this.browserSession = sess;
+ return sess;
+ }
+
+ createTab(tabId: string): TabState {
+ if (this.tabs.has(tabId)) return this.tabs.get(tabId)!;
+ const initial: TabState = {
+ tabId,
+ webContentsId: null,
+ navStatus: { kind: "Idle" },
+ canGoBack: false,
+ canGoForward: false,
+ visible: true,
+ updatedAt: new Date().toISOString(),
+ };
+ this.tabs.set(tabId, initial);
+ this.emit(tabId, initial);
+ return initial;
+ }
+
+ closeTab(tabId: string): void {
+ if (!this.tabs.delete(tabId)) return;
+ this.emit(tabId, { ...this.tabs.get(tabId)!, navStatus: { kind: "Idle" } });
+ }
+
+ setVisibility(tabId: string, visible: boolean): void {
+ const tab = this.tabs.get(tabId);
+ if (!tab) return;
+ if (tab.visible === visible) return;
+ this.update(tabId, { visible });
+ }
+
+ registerWebview(tabId: string, webContentsId: number): void {
+ const tab = this.tabs.get(tabId);
+ if (!tab) throw new PreviewTabNotFoundError(tabId);
+ const wc = webContents.fromId(webContentsId);
+ if (!wc) throw new PreviewWebContentsNotFoundError(tabId, webContentsId);
+
+ this.attachListeners(tabId, wc);
+ this.update(tabId, {
+ webContentsId,
+ navStatus: this.computeNavStatus(wc),
+ canGoBack: wc.navigationHistory.canGoBack(),
+ canGoForward: wc.navigationHistory.canGoForward(),
+ });
+ }
+
+ unregisterWebview(tabId: string): void {
+ const tab = this.tabs.get(tabId);
+ if (!tab) return;
+ this.update(tabId, {
+ webContentsId: null,
+ navStatus: { kind: "Idle" },
+ canGoBack: false,
+ canGoForward: false,
+ });
+ }
+
+ async navigate(tabId: string, rawUrl: string): Promise {
+ const wc = this.requireWebContents(tabId);
+ const url = this.normalizeUrl(rawUrl);
+ if (wc.getURL() === url) return;
+ await wc.loadURL(url);
+ }
+
+ goBack(tabId: string): void { this.requireWebContents(tabId).navigationHistory.goBack(); }
+ goForward(tabId: string): void { this.requireWebContents(tabId).navigationHistory.goForward(); }
+ refresh(tabId: string): void { this.requireWebContents(tabId).reload(); }
+
+ onStateChange(listener: Listener): () => void {
+ this.listeners.add(listener);
+ return () => this.listeners.delete(listener);
+ }
+
+ private attachListeners(tabId: string, wc: Electron.WebContents): void {
+ const sync = () => {
+ this.update(tabId, {
+ navStatus: this.computeNavStatus(wc),
+ canGoBack: wc.navigationHistory.canGoBack(),
+ canGoForward: wc.navigationHistory.canGoForward(),
+ });
+ };
+ wc.on("did-navigate", sync);
+ wc.on("did-navigate-in-page", sync);
+ wc.on("page-title-updated", sync);
+ wc.on("did-start-loading", sync);
+ wc.on("did-stop-loading", sync);
+ wc.on("did-fail-load", (_event, code, description) => {
+ if (code === -3) return; // user aborted
+ this.update(tabId, {
+ navStatus: {
+ kind: "LoadFailed",
+ url: wc.getURL(),
+ title: wc.getTitle(),
+ code,
+ description,
+ },
+ });
+ });
+
+ // External link policy: load in same view (matches ami)
+ wc.setWindowOpenHandler(({ url }) => {
+ void wc.loadURL(url);
+ return { action: "deny" };
+ });
+
+ // Forward app shortcuts to the main window so mod+shift+j etc still work
+ wc.on("before-input-event", (event, input) => {
+ if (this.isAppShortcut(input) && this.mainWindow && !this.mainWindow.isDestroyed()) {
+ event.preventDefault();
+ this.mainWindow.webContents.sendInputEvent({
+ type: "keyDown",
+ keyCode: input.key,
+ modifiers: [
+ ...(input.meta ? ["meta" as const] : []),
+ ...(input.shift ? ["shift" as const] : []),
+ ...(input.control ? ["control" as const] : []),
+ ...(input.alt ? ["alt" as const] : []),
+ ],
+ });
+ }
+ });
+ }
+
+ private isAppShortcut(input: Electron.Input): boolean {
+ if (input.type !== "keyDown") return false;
+ // Mirror the t3code keybinding defaults that should always reach the main window.
+ const SHORTCUTS = [
+ { key: "j", meta: true, shift: true }, // preview.toggle
+ { key: "k", meta: true, shift: false }, // commandPalette.toggle
+ { key: ",", meta: true, shift: false }, // settings
+ { key: "w", meta: true, shift: false }, // close
+ // future: terminal.* if user wants them while preview focused
+ ];
+ return SHORTCUTS.some((s) =>
+ s.key.toLowerCase() === input.key.toLowerCase()
+ && s.meta === input.meta
+ && s.shift === input.shift,
+ );
+ }
+
+ private computeNavStatus(wc: Electron.WebContents): NavStatus {
+ const url = wc.getURL();
+ const title = wc.getTitle();
+ if (url === "" || url === "about:blank") return { kind: "Idle" };
+ if (wc.isLoading()) return { kind: "Loading", url, title };
+ return { kind: "Success", url, title };
+ }
+
+ private requireWebContents(tabId: string): Electron.WebContents {
+ const tab = this.tabs.get(tabId);
+ if (!tab) throw new PreviewTabNotFoundError(tabId);
+ if (tab.webContentsId == null) throw new PreviewWebviewNotInitializedError(tabId);
+ const wc = webContents.fromId(tab.webContentsId);
+ if (!wc) throw new PreviewWebContentsNotFoundError(tabId, tab.webContentsId);
+ return wc;
+ }
+
+ private update(tabId: string, patch: Partial): void {
+ const current = this.tabs.get(tabId);
+ if (!current) return;
+ const next: TabState = { ...current, ...patch, updatedAt: new Date().toISOString() };
+ this.tabs.set(tabId, next);
+ this.emit(tabId, next);
+ }
+
+ private emit(tabId: string, state: TabState): void {
+ for (const listener of this.listeners) listener(tabId, state);
+ }
+
+ private normalizeUrl(input: string): string {
+ // Same heuristics as server-side normalization.
+ // Returns "https://..." or throws.
+ const trimmed = input.trim();
+ if (!trimmed) throw new PreviewInvalidUrlError(input);
+ const useHttp = /^(localhost|127\.0\.0\.1|0\.0\.0\.0)(:|\/|$)/.test(trimmed);
+ const parsed = new URL(trimmed.includes("://")
+ ? trimmed
+ : `${useHttp ? "http" : "https"}://${trimmed}`);
+ return parsed.href;
+ }
+}
+
+export class PreviewTabNotFoundError extends Error {
+ constructor(public readonly tabId: string) {
+ super(`Preview tab not found: ${tabId}`);
+ }
+}
+export class PreviewWebContentsNotFoundError extends Error { /* … */ }
+export class PreviewWebviewNotInitializedError extends Error { /* … */ }
+export class PreviewInvalidUrlError extends Error { /* … */ }
+
+export const previewViewManager = new PreviewViewManager();
+```
+
+### `apps/desktop/src/main.ts` additions
+
+Right after `mainWindow = createWindow();` in `bootstrap()` and after every recreation:
+
+```ts
+previewViewManager.setMainWindow(mainWindow);
+```
+
+Register IPC handlers (in `registerIpcHandlers()`):
+
+```ts
+ipcMain.handle("preview:createTab", (_e, tabId: string) =>
+ previewViewManager.createTab(tabId));
+ipcMain.handle("preview:closeTab", (_e, tabId: string) =>
+ previewViewManager.closeTab(tabId));
+ipcMain.handle("preview:setVisibility", (_e, tabId: string, visible: boolean) =>
+ previewViewManager.setVisibility(tabId, visible));
+ipcMain.handle("preview:registerWebview", (_e, tabId: string, wcId: number) =>
+ previewViewManager.registerWebview(tabId, wcId));
+ipcMain.handle("preview:unregisterWebview", (_e, tabId: string) =>
+ previewViewManager.unregisterWebview(tabId));
+ipcMain.handle("preview:navigate", (_e, tabId: string, url: string) =>
+ previewViewManager.navigate(tabId, url));
+ipcMain.handle("preview:goBack", (_e, tabId: string) => previewViewManager.goBack(tabId));
+ipcMain.handle("preview:goForward", (_e, tabId: string) => previewViewManager.goForward(tabId));
+ipcMain.handle("preview:refresh", (_e, tabId: string) => previewViewManager.refresh(tabId));
+ipcMain.handle("preview:getPreloadPath", () => previewViewManager.getPreloadPath());
+ipcMain.handle("preview:getBrowserPartition", () => previewViewManager.getBrowserPartition());
+```
+
+State change broadcast (push to all renderer windows):
+
+```ts
+previewViewManager.onStateChange((tabId, state) => {
+ for (const win of BrowserWindow.getAllWindows()) {
+ if (win.isDestroyed()) continue;
+ win.webContents.send("preview:state-change", tabId, state);
+ }
+});
+```
+
+### `apps/desktop/src/preload.ts` additions
+
+```ts
+contextBridge.exposeInMainWorld("desktopBridge", {
+ // … existing fields …
+ preview: {
+ createTab: (tabId: string) => ipcRenderer.invoke("preview:createTab", tabId),
+ closeTab: (tabId: string) => ipcRenderer.invoke("preview:closeTab", tabId),
+ setVisibility: (tabId: string, visible: boolean) =>
+ ipcRenderer.invoke("preview:setVisibility", tabId, visible),
+ registerWebview: (tabId: string, wcId: number) =>
+ ipcRenderer.invoke("preview:registerWebview", tabId, wcId),
+ unregisterWebview: (tabId: string) =>
+ ipcRenderer.invoke("preview:unregisterWebview", tabId),
+ navigate: (tabId: string, url: string) =>
+ ipcRenderer.invoke("preview:navigate", tabId, url),
+ goBack: (tabId: string) => ipcRenderer.invoke("preview:goBack", tabId),
+ goForward: (tabId: string) => ipcRenderer.invoke("preview:goForward", tabId),
+ refresh: (tabId: string) => ipcRenderer.invoke("preview:refresh", tabId),
+ getPreloadPath: (): Promise => ipcRenderer.invoke("preview:getPreloadPath"),
+ getBrowserPartition: (): Promise =>
+ ipcRenderer.invoke("preview:getBrowserPartition"),
+ onStateChange: (cb: (tabId: string, state: DesktopPreviewTabState) => void) => {
+ const listener = (_e: unknown, tabId: string, state: DesktopPreviewTabState) =>
+ cb(tabId, state);
+ ipcRenderer.on("preview:state-change", listener);
+ return () => ipcRenderer.removeListener("preview:state-change", listener);
+ },
+ },
+});
+```
+
+### `apps/desktop/src/preview-preload.ts`
+
+```ts
+// Intentionally empty for v1.
+// Future: forward console.error to main, expose a tiny window.t3preview API.
+```
+
+Build config: add `preview-preload.ts` to `apps/desktop/tsdown.config.ts` outputs so it ships as `preview-preload.cjs` next to `preload.cjs`.
+
+---
+
+## 7. Web: state stores
+
+### `apps/web/src/previewStateStore.ts`
+
+Direct mirror of `terminalStateStore.ts` shape (one tab per thread for v1; structured to grow into multi‑tab the same way terminal grew into groups).
+
+```ts
+import { type ScopedThreadRef, scopedThreadKey } from "@t3tools/client-runtime";
+import { create } from "zustand";
+import { createJSONStorage, persist } from "zustand/middleware";
+import type { PreviewEvent, PreviewSessionSnapshot } from "@t3tools/contracts";
+import { resolveStorage } from "./lib/storage";
+
+interface ThreadPreviewState {
+ /** present if a preview tab exists for this thread */
+ snapshot: PreviewSessionSnapshot | null;
+ /** desktop-side immediate nav button state (overrides snapshot when fresher) */
+ desktopOverlay: {
+ canGoBack: boolean;
+ canGoForward: boolean;
+ visible: boolean;
+ } | null;
+ /** local UI: is the URL bar focused? */
+ urlBarFocused: boolean;
+ recentEventIds: number[];
+}
+
+interface PreviewEventEntry { id: number; event: PreviewEvent }
+
+interface PreviewStateStore {
+ byThreadKey: Record;
+ recentEvents: Record>;
+ nextEventId: number;
+ applyServerEvent: (ref: ScopedThreadRef, event: PreviewEvent) => void;
+ applyDesktopState: (
+ ref: ScopedThreadRef,
+ overlay: ThreadPreviewState["desktopOverlay"],
+ ) => void;
+ setUrlBarFocused: (ref: ScopedThreadRef, focused: boolean) => void;
+ removeThread: (ref: ScopedThreadRef) => void;
+}
+
+const PERSISTED_FIELDS = ["byThreadKey"] as const;
+const STORAGE_KEY = "t3code:preview-state:v1";
+
+export const usePreviewStateStore = create()(
+ persist(/* … */, {
+ name: STORAGE_KEY,
+ storage: createJSONStorage(() =>
+ resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined),
+ ),
+ version: 1,
+ partialize: (s) => Object.fromEntries(PERSISTED_FIELDS.map((k) => [k, s[k]])),
+ }),
+);
+
+export function selectThreadPreviewState(
+ byThreadKey: Record,
+ ref: ScopedThreadRef | null,
+): ThreadPreviewState {
+ if (!ref) return EMPTY_THREAD_STATE;
+ return byThreadKey[scopedThreadKey(ref)] ?? EMPTY_THREAD_STATE;
+}
+```
+
+Key invariants (test in `previewStateStore.test.ts`):
+
+- `applyServerEvent("opened" | "navigated" | "failed")` updates `snapshot`; pushes into `recentEvents` ring buffer (cap 50).
+- `applyServerEvent("closed")` removes the thread entry entirely.
+- `applyDesktopState` only updates `desktopOverlay`; never touches `snapshot.url`/`title` (server is truth for those).
+
+### `apps/web/src/environmentApi.ts`
+
+```ts
+preview: {
+ open: (input) => rpcClient.preview.open(input as never),
+ navigate: (input) => rpcClient.preview.navigate(input as never),
+ refresh: (input) => rpcClient.preview.refresh(input as never),
+ close: (input) => rpcClient.preview.close(input as never),
+ list: (threadId) => rpcClient.preview.list(threadId),
+ onEvent: (callback) => rpcClient.preview.onEvent(callback),
+},
+```
+
+Plus mirror in the `EnvironmentApi` shape in `packages/contracts/src/server.ts` (or wherever `EnvironmentApi` lives).
+
+---
+
+## 8. Web: components
+
+### `apps/web/src/components/preview/PreviewPanelShell.tsx`
+
+Lifted from `DiffPanelShell.tsx` verbatim, renamed types. **Must** use the same className contract:
+
+```tsx
+
+```
+
+So preview side panel is visually indistinguishable in spacing from the diff panel.
+
+### `apps/web/src/components/preview/PreviewView.tsx`
+
+Renderer of chrome bar + ``. Direct port of ami's `browser-view.tsx` chrome (URL bar with protocol/host/path split, back/fwd/refresh/loading bar) but stripped of: devtools button, screenshot button, runtime errors badge, react-grab. Uses `lucide-react` icons that t3code already depends on (`ArrowLeft`, `ArrowRight`, `RefreshCw`, `X`) instead of `@hugeicons/react`.
+
+Key behaviour:
+
+- On mount: `await desktopBridge.preview.createTab(tabId)`, `await desktopBridge.preview.getPreloadPath()`.
+- Subscribes to `desktopBridge.preview.onStateChange(handleState)` → `previewStateStore.applyDesktopState`.
+- Subscribes to `EnvironmentApi.preview.onEvent(handleEvent)` → `previewStateStore.applyServerEvent`.
+- On `` `dom-ready`: read `webContentsId`, call `desktopBridge.preview.registerWebview`, then call `EnvironmentApi.preview.navigate({ threadId, tabId, url })` so server learns the resolved URL.
+- On unmount when panel hides (not when thread changes — see persistence note below): `setVisibility(tabId, false)`.
+
+### `apps/web/src/components/preview/PreviewWebview.tsx`
+
+```tsx
+"use client";
+
+import { useEffect, useState } from "react";
+import { isDesktop } from "~/env";
+
+interface Props {
+ tabId: string;
+ initialUrl: string | null;
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ webview: Electron.WebviewTag;
+ }
+}
+
+export function PreviewWebview({ tabId, initialUrl }: Props) {
+ const [config, setConfig] = useState<{ partition: string; preload: string } | null>(null);
+
+ useEffect(() => {
+ if (!isDesktop || !window.desktopBridge?.preview) return;
+ void Promise.all([
+ window.desktopBridge.preview.getBrowserPartition(),
+ window.desktopBridge.preview.getPreloadPath(),
+ ]).then(([partition, preload]) => setConfig({ partition, preload }));
+ }, []);
+
+ if (!isDesktop || !window.desktopBridge?.preview || !config) return null;
+
+ const src = initialUrl ?? "about:blank";
+ return (
+
+ );
+}
+```
+
+### `apps/web/src/components/preview/PreviewPanel.tsx`
+
+The right‑panel entrypoint. Reads `previewStateStore` for the active thread; renders `` if a session exists, otherwise `` with a URL field that calls `EnvironmentApi.preview.open(...)` on submit.
+
+### Persistence across thread changes
+
+Following `PersistentThreadTerminalDrawer` pattern (`ChatView.tsx:3517`): keep multiple `` instances mounted (capped, e.g. `MAX_HIDDEN_MOUNTED_PREVIEW_THREADS = 3`) and toggle `visible` via `desktopBridge.preview.setVisibility`. The `` element stays alive in the DOM but the desktop side knows it's hidden so it can later (v2) skip raster updates.
+
+For v1 the simpler version: only mount the active thread's ``. Closing the right panel calls `desktopBridge.preview.setVisibility(tabId, false)` but does **not** close the tab. Switching threads closes the previous thread's tab. This matches the desktop‑only constraint and avoids hidden ``s eating GPU.
+
+---
+
+## 9. Discoverability glue
+
+### A. `preview.toggle` keybinding (`apps/web/src/routes/_chat.tsx`)
+
+Mirror the existing `chat.new` block (`:51–:78`):
+
+```ts
+if (command === "preview.toggle") {
+ event.preventDefault();
+ event.stopPropagation();
+ if (!window.desktopBridge?.preview) {
+ showPreviewUnsupportedToast();
+ return;
+ }
+ if (!routeThreadRef) return;
+ rightPanelStore.toggle(routeThreadRef, "preview");
+ return;
+}
+```
+
+`previewFocus` and `previewOpen` `when` context keys get computed in the global shortcut handler so `mod+r` (refresh) only matches when the preview is the active right panel and focused.
+
+### B. Terminal link → "Open in preview"
+
+In `ThreadTerminalDrawer.tsx`'s `terminal.registerLinkProvider({ provideLinks })` callback (`:454–:514`), update the `activate(event)` handler:
+
+```ts
+activate: (event: MouseEvent) => {
+ if (!isTerminalLinkActivation(event)) return;
+ if (match.kind !== "url") {
+ // existing path link handling
+ return;
+ }
+ if (!isPreviewable(match.text) || !window.desktopBridge?.preview) {
+ void localApi.shell.openExternal(match.text).catch(/* … */);
+ return;
+ }
+ void localApi.contextMenu
+ .show(
+ [
+ { id: "open-in-preview", label: "Open in preview" },
+ { id: "open-in-browser", label: "Open in browser" },
+ ],
+ { x: event.clientX, y: event.clientY },
+ )
+ .then((choice) => {
+ if (choice === "open-in-preview") {
+ void api.preview.open({ threadId, url: match.text });
+ rightPanelStore.open(threadRef, "preview");
+ } else if (choice === "open-in-browser") {
+ void localApi.shell.openExternal(match.text);
+ }
+ });
+},
+```
+
+`isPreviewable`: localhost / 127.0.0.1 / 0.0.0.0 by default. The `previewUrlPatterns` user setting (a `string[]` in `ServerSettings`) extends the allowlist to cover deploy preview hosts (e.g. `*.vercel.app`).
+
+### C. `ProjectScript.previewUrl` / `autoOpenPreview`
+
+Schema additions are listed in §4. UI changes in `ProjectScriptsControl.tsx`'s Add/Edit dialog form (`:378–:458`):
+
+```tsx
+
+
+ setPreviewUrl(e.target.value)}
+ />
+
+ Auto-open this URL in the preview panel when the script starts.
+
+
+
+```
+
+When `onRunScript(script)` fires in `ChatView.tsx`, after starting the terminal command, if `script.autoOpenPreview && script.previewUrl && desktopBridge.preview`:
+
+```ts
+void api.preview.open({ threadId: activeThread.id, url: script.previewUrl });
+rightPanelStore.open(activeThreadRef, "preview");
+```
+
+---
+
+## 10. Migrations
+
+### Persisted state
+
+- `t3code:preview-state:v1` — new key, no migration needed.
+- `t3code:right-panel-state:v1` — new key, no migration needed.
+- Existing `t3code:terminal-state:v1` — untouched.
+
+### Schema additions
+
+`ProjectScript.previewUrl` and `autoOpenPreview` are both `Schema.optional(...)`. Existing serialized scripts decode unchanged. No data migration required.
+
+### Keybindings
+
+Adding `preview.toggle`, `preview.refresh`, `preview.focusUrl` to `STATIC_KEYBINDING_COMMANDS` is additive. Existing user `~/.t3/keybindings.json` files keep working. Default keybindings file picks up the new defaults on next read.
+
+---
+
+## 11. Testing strategy
+
+Following t3code's vitest patterns (`bun run test`, never `bun test`):
+
+| Test file | What it covers |
+|---|---|
+| `packages/contracts/src/preview.test.ts` | Schema encode/decode round-trips for inputs, snapshot, events; URL trimming; error tagged unions |
+| `apps/server/src/preview/Layers/Manager.test.ts` | `open` creates session and emits `opened`; `navigate` updates and emits `navigated`; `close` removes and emits `closed`; subscribers receive monotonic events; `list` returns sorted snapshots |
+| `apps/web/src/previewStateStore.test.ts` | `applyServerEvent` reducer correctness; ring buffer cap; `closed` removes entry; `desktopOverlay` is independent of snapshot fields |
+| `apps/web/src/rightPanelStore.test.ts` | `open` / `close` / `toggle` semantics; per-thread isolation; `?diff=1` sync compatibility |
+| `apps/web/src/components/preview/PreviewView.test.ts` (logic-only via `PreviewView.logic.ts` extraction) | URL bar input → navigation; navigation button enabled-state derivation; visibility toggle on panel hide |
+| Existing `ThreadTerminalDrawer.test.ts` | Add a case: link activation with `kind: "url"` and `previewable: true` shows the context menu (mock `localApi.contextMenu.show`) |
+
+Manual smoke checklist (drop in `apps/desktop/test/smoke/`):
+
+1. Boot desktop dev, open a thread, hit `mod+shift+j` → empty state appears in right panel.
+2. Type `https://example.com` → page loads, title updates in chrome bar.
+3. Navigate to `https://example.org` → back/forward enable correctly.
+4. Hit `mod+r` → page reloads, loading bar animates.
+5. Run a `bun dev` script in terminal printing `http://localhost:5173`, link in xterm → context menu offers "Open in preview".
+6. Restart server (kill `apps/server` child, watch it respawn) → preview tab survives, server replays `opened` event on reconnect, panel state matches reality.
+7. Switch threads → previous thread's preview tab is closed (v1); new thread shows empty state.
+8. Open a second window of the desktop (if supported) or open the same server from a browser tab in another window → `preview.list(threadId)` returns the snapshot, panel is empty in browser (correct: not desktop).
+
+---
+
+## 12. Risks and resolutions
+
+| Risk | Resolution |
+|---|---|
+| Hidden `` GPU cost when keeping multiple threads' previews mounted | v1 only mounts the active thread's preview. v2 can adopt the `PersistentThreadTerminalDrawer` pattern + `setVisibility` |
+| `` keyboard capture eats `mod+shift+j` etc. | `before-input-event` forwarder in `PreviewViewManager` (mirror of ami's pattern, smaller shortcut list) |
+| URL with `X-Frame-Options: DENY` works fine in `` (good — that's why we picked `` over iframe) | n/a |
+| Server restart while desktop is alive: `` is still loaded but server has no record | On WS reconnect, web side sends a `preview.list(threadId)` and if empty, sends a `preview.open(...)` to re‑register the current URL. Add a small `useReconciliation` effect in `PreviewView.tsx` |
+| Renderer process renders something into `` but never registered → orphaned tab in `PreviewViewManager` | `closeTab` is idempotent; on `` unmount, `unregisterWebview` is called; if that didn't fire (crash), the next `createTab` for the same `tabId` reuses the record |
+| `autoOpenPreview` racing with terminal output (URL might not be in stdout yet by the time the script "starts") | Two‑phase: if `script.previewUrl` is set, open eagerly with that URL. If not set but `autoOpenPreview === true`, watch terminal output via existing `terminal-links` extraction and open the first `previewable` URL within a 60s window |
+| Multiple windows of the desktop both rendering the same `` for the same thread | `` is per‑renderer; each window creates its own. The shared `persist:t3code-preview` partition keeps cookies in sync. Server records the last navigation URL but doesn't enforce single‑renderer |
+
+---
+
+## 13. Out of scope (v2+)
+
+Explicitly **not** in this landing:
+
+- Devtools support (would need to port ami's `WebContentsView` + `setDevToolsWebContents` + bounds sync — significant)
+- Page screenshot capture
+- JavaScript injection / `executeJavaScript`
+- React Grab / element picker
+- Console log capture
+- Playwright/CDP agent automation tools (`browser_snapshot`, `browser_execute`)
+- System cookie import (Chrome/Safari decryption)
+- Multi-tab per thread (groups/splits)
+- Agent control overlay ("Agent" pill while controlled)
+- Web build iframe fallback
+
+Any of these can land in follow-up PRs against the same `PreviewViewManager` + `PreviewManager` shape — the v1 schemas are forward‑compatible (e.g. `tabId` is already there for multi‑tab; `recentEvents` ring buffer is already there for console/screenshot events).
+
+---
+
+## 14. UI design system — primitives we reuse
+
+Every visible element below is built on existing components. No new design primitives are introduced. References below are to the `apps/web/src/components/ui/` directory.
+
+| Need | Primitive | File ref | Notes |
+|---|---|---|---|
+| Theme colors / radii | CSS vars `--background`, `--card`, `--muted`, `--muted-foreground`, `--border`, `--input`, `--ring`, `--success`, etc. | `apps/web/src/index.css:86` | Light + `@variant dark` blocks. Always reference vars via tailwind utilities (`bg-card`, `text-muted-foreground`) — never hard‑code hex |
+| `cn` class merger | `cn(...inputs)` from `~/lib/utils` | `apps/web/src/lib/utils.ts:8` | Wraps `cx` + `tailwind-merge` |
+| Buttons (chrome bar back/fwd/refresh) | `Button` `variant="ghost"` `size="icon-xs"` | `apps/web/src/components/ui/button.tsx` | `icon-xs` is `size-7 rounded-md sm:size-6` — exactly the density in the screenshot |
+| Buttons (URL field submit) | `Button` `variant="outline"` `size="sm"` | same | Matches the "ProjectScript primary action" density already in `BranchToolbar` |
+| Button group (back / fwd / refresh as a unit) | `Group` + implicit segmenting (no `GroupSeparator`) | `apps/web/src/components/ui/group.tsx` | Group automatically removes outer borders between adjacent `[data-slot]` children |
+| URL input (chrome bar editable) | `InputGroup` + `InputGroupInput` + `InputGroupAddon align="inline-start"` (globe icon) | `apps/web/src/components/ui/input-group.tsx` | Click‑anywhere‑to‑focus already wired in `InputGroupAddon`'s `onMouseDown` |
+| URL input (chrome bar disabled / read‑only) | Same `InputGroup` with `disabled` on the input | same | Yields the muted look in the screenshot via `has-[input:disabled]:opacity-64` |
+| Tab strip cells | Plain `