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
deleted file mode 100644
index b6675daf8989ea9cff31cbb15b53eb111ba400ba..0000000000000000000000000000000000000000
--- a/app-preview-browser-plan.md
+++ /dev/null
@@ -1,1853 +0,0 @@
-# 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()(
- "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 `