From c0859a3d9c51d201a23878dc63f181cde2f79e27 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Sun, 3 May 2026 01:06:54 -0700 Subject: [PATCH 01/25] planning --- 404.html | 1600 ++++++++++++++++++++++++++++++ app-preview-browser-plan.md | 1829 +++++++++++++++++++++++++++++++++++ 2 files changed, 3429 insertions(+) create mode 100644 404.html create mode 100644 app-preview-browser-plan.md diff --git a/404.html b/404.html new file mode 100644 index 00000000000..ebdbd1fcf78 --- /dev/null +++ b/404.html @@ -0,0 +1,1600 @@ + + + + + + asdasddddd.com + + + + + + + + + +
+
+
+
+
+

+ This site can’t be reached +

+ +

asdasddddd.com’s server IP address could not be found.

+ + + +
+

Try:

+ +
+ + +
ERR_NAME_NOT_RESOLVED
+ + +
+
+ + +
+ +
+
Check your Internet connection
+
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 00000000000..078c1cc93e9 --- /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 ` +
+ +
+ + + } + > + + + Open in system browser + + + + } + > + + + Close preview + +
+ + ); +} + +function PreviewTab({ + tab, + onActivate, + onClose, +}: { + tab: PreviewTabDescriptor; + onActivate: () => void; + onClose: () => void; +}) { + return ( +
+ + +
+ ); +} +``` + +### 15.3 Favicon helper (`apps/web/src/lib/favicon.ts`) + +Borrowed from ami's `custom-tab.tsx:57` — Google's s2 favicon endpoint with `onError` fallback to a `` icon. Lives in its own module so we can swap providers later (e.g. self-hosted favicon proxy). + +```ts +// apps/web/src/lib/favicon.ts +const FAVICON_PROVIDER = "https://www.google.com/s2/favicons"; + +export function faviconUrlForOrigin(rawUrl: string | null, size = 32): string | null { + if (!rawUrl) return null; + try { + const url = new URL(rawUrl); + if (!url.host) return null; + return `${FAVICON_PROVIDER}?domain=${encodeURIComponent(url.host)}&sz=${size}`; + } catch { + return null; + } +} +``` + +```tsx +// apps/web/src/components/preview/TabFavicon.tsx +import { Globe } from "lucide-react"; +import { useEffect, useState } from "react"; +import { faviconUrlForOrigin } from "~/lib/favicon"; + +export function TabFavicon({ url }: { url: string | null }) { + const src = faviconUrlForOrigin(url, 32); + const [errored, setErrored] = useState(false); + useEffect(() => setErrored(false), [src]); + + if (!src || errored) { + return ; + } + return ( + setErrored(true)} + /> + ); +} +``` + +### 15.4 Unreachable / error state (`PreviewUnreachable.tsx`) — port of 404.html + +When `navStatus._tag === "LoadFailed"`, render this instead of the failed ``. The original Chromium `404.html` (`asdasddddd.com` example) uses `--google-gray-*` vars; we map every Google gray to the closest theme variable so it auto-themes. + +Color mapping (Chromium → t3code theme): + +| Chromium | Tailwind/t3code | +|---|---| +| `--background-color: #fff` / `--google-gray-900` (dark) | `bg-background` | +| `--text-color: --google-gray-700` / `--google-gray-500` (dark) | `text-muted-foreground` | +| `--heading-color: --google-gray-900` / `--google-gray-500` (dark) | `text-foreground` | +| `--error-code-color: --google-gray-700` / `--google-gray-500` (dark) | `text-muted-foreground/70` | +| `--quiet-background-color: rgb(247,247,247)` / `--background` (dark) | `bg-muted/40` | +| `--primary-button-fill-color: --google-blue-600` / `--google-blue-300` (dark) | `bg-primary text-primary-foreground` (theme primary is `oklch(0.488 0.217 264)` light / `oklch(0.588 0.217 264)` dark — a very close blue) | +| `--secondary-button-*` | `Button variant="outline"` | +| `--link-color: rgb(88,88,88)` / `--google-blue-300` (dark) | `text-primary underline-offset-4 hover:underline` | +| Body font `system-ui, sans-serif; font-size: 75%` | We keep DM Sans (already in `apps/web/src/index.css:148`) and `text-sm` — the Chromium font tweak just compensates for their `html { font-size: 125% }` — we don't replicate that | + +Skeleton: + +```tsx +// apps/web/src/components/preview/PreviewUnreachable.tsx +"use client"; + +import { useState } from "react"; +import { Button } from "~/components/ui/button"; +import { cn } from "~/lib/utils"; + +interface Props { + url: string; + errorCode: string; // e.g. "ERR_NAME_NOT_RESOLVED", "ERR_CONNECTION_REFUSED" + description: string; + onReload: () => void; +} + +const ICON_GENERIC = ( + // Replace with an inline SVG that matches the Chromium "icon-generic" + // (a stylized broken page). For v1 a Lucide MapPinOff is a fine stand-in — + // we want a visual that reads "destination unreachable". + + + + + +); + +export function PreviewUnreachable({ url, errorCode, description, onReload }: Props) { + const [showDetails, setShowDetails] = useState(false); + const host = safeHost(url) ?? url; + + return ( +
+
+ {/* Icon + headline */} +
+ {ICON_GENERIC} +

+ This site can’t be reached +

+
+ + {/* Summary — uses dangerouslySetInnerHTML only for the bold host treatment */} +

+ {host} + ’s server DNS address{" "} + could not be found. +

+ + {/* Suggestions list (when details open) */} + {showDetails && ( +
+

Try:

+
    +
  • Checking the connection
  • +
  • Checking the proxy and the firewall
  • +
  • Running Network Diagnostics
  • +
+
+ )} + + {/* Error code */} +
+ {errorCode} +
+ + {/* Actions */} +
+ +
+ +
+
+
+ ); +} + +function safeHost(url: string): string | null { + try { return new URL(url).host; } catch { return null; } +} +``` + +The component intentionally: +- Uses theme tokens only — looks correct in light + dark with no extra wiring. +- Drops Chromium's "Diagnose connection" / "Portal sign-in" buttons (irrelevant in our shell). +- Drops the dinosaur game (RIP). +- Maps `errorCode` → human description in `PreviewView.tsx` via a small lookup (`ERR_CONNECTION_REFUSED` → "Connection refused", etc.) before rendering. + +### 15.5 Loading bar + +A 1.5px primary-colored bar that fills as the page loads, anchored to the bottom of the chrome row. Direct port of ami's pattern (`browser-view.tsx:434`): + +```tsx +{loadProgress > 0 && ( +
+)} +``` + +Progress is computed locally with `useLoadingProgress(isLoading)` — same hook signature as ami's, ~30 lines. + +--- + +## 16. Local server discovery (port scanner) + +The "Local" recommendations in §15.1 need a feed. New backend service. + +### `apps/server/src/preview/Services/PortScanner.ts` + +```ts +import { Context, Effect } from "effect"; + +export interface DiscoveredLocalServer { + host: string; // "localhost" + port: number; // 5175 + url: string; // "http://localhost:5175" + processName: string | null; // "node", "vite", "next-server" + pid: number | null; +} + +export interface PreviewPortScannerShape { + /** One-shot snapshot of currently listening localhost ports. */ + readonly scan: () => Effect.Effect>; + /** Subscribe to changes. Listener is called on every diff. */ + readonly subscribe: ( + listener: (servers: ReadonlyArray) => Effect.Effect, + ) => Effect.Effect<() => void>; + /** Hint that at least one client is interested → starts polling. Returns release fn. */ + readonly retain: () => Effect.Effect<() => void>; +} + +export class PreviewPortScanner extends Context.Service< + PreviewPortScanner, + PreviewPortScannerShape +>()("t3/preview/Services/PortScanner") {} +``` + +### `apps/server/src/preview/Layers/PortScanner.ts` + +Two strategies, picked at startup: + +1. **Preferred — `lsof`** on macOS/Linux: `lsof -iTCP -sTCP:LISTEN -P -n -F pcn` returns `pid`, `command`, `name` (host:port). Fast (<50ms typical), gives process name for free. Parsed via the `-F` field-format which is stable across versions. +2. **Fallback — TCP connect probe**: iterate a curated list of common dev ports `[3000, 3001, 3333, 4173, 4200, 4321, 5000, 5173, 5174, 5175, 5500, 8000, 8080, 8081, 8888, 9000]` against `127.0.0.1`, mark any that accepts a connection. Used on Windows and as the safety net if `lsof` is missing. + +Polling cadence: +- When `retain()` count is 0 → not polling. +- When ≥1 → poll every 3s. Diff against last result; only emit `subscribe` callbacks when the set differs. +- `retain()` returns a release fn that decrements the counter; goes idle automatically when the empty state is hidden. + +The renderer side (`useDiscoveredLocalServers(threadId)`) calls `EnvironmentApi.preview.subscribePorts(callback)` and the WS handler calls `retain()` on first subscribe / releases on the last unsubscribe. + +### Augmenting the discovery feed + +The card list in §15.1 is the union of: + +1. **Listening ports** from the scanner (above). +2. **Recently seen URLs** — `apps/web/src/previewStateStore.ts` already has a `recentEvents` ring buffer; we additionally maintain a small per-thread `recentUrlsFromTerminal: string[]` populated from the existing `terminal-links` extraction (already wired in `ThreadTerminalDrawer.tsx:454`). +3. **Configured URLs** — every active `ProjectScript.previewUrl` from the active project. + +Deduped by URL string. Sort: configured > listening > recent. This yields the "smart, contextual, no-thought-required" feel the screenshot implies. + +--- + +## 17. File map (additions to §3) + +Append these to the file list: + +| File | Purpose | +|---|---| +| `apps/server/src/preview/Services/PortScanner.ts` | Service tag + interface | +| `apps/server/src/preview/Layers/PortScanner.ts` | `lsof` strategy + TCP probe fallback + polling | +| `apps/server/src/preview/Layers/PortScanner.test.ts` | Parser tests for `lsof -F pcn` output | +| `apps/web/src/lib/favicon.ts` | `faviconUrlForOrigin(url, size)` helper | +| `apps/web/src/components/preview/PreviewTabStrip.tsx` | Tab strip (screenshot 2) | +| `apps/web/src/components/preview/TabFavicon.tsx` | Favicon `` w/ `` fallback | +| `apps/web/src/components/preview/BrowserMockup.tsx` | Tiny tailwind browser thumbnail icon | +| `apps/web/src/components/preview/PreviewLocalServerCard.tsx` | Card row for a discovered server | +| `apps/web/src/components/preview/PreviewUnreachable.tsx` | 404.html rewritten in tailwind | +| `apps/web/src/components/preview/useDiscoveredLocalServers.ts` | Hook subscribing to `EnvironmentApi.preview.subscribePorts` + merging in `recentUrlsFromTerminal` and `ProjectScript.previewUrl` | +| `apps/web/src/components/preview/useLoadingProgress.ts` | 30-line progress simulator (port of ami's) | +| `apps/web/src/components/preview/errorCodeMessages.ts` | `ERR_*` → human-readable description map | + +Update the contract additions in §4 to add: + +```ts +// packages/contracts/src/preview.ts +export const DiscoveredLocalServer = Schema.Struct({ + host: TrimmedNonEmptyString, + port: Schema.Int.check(Schema.isGreaterThan(0)).check(Schema.isLessThan(65536)), + url: TrimmedNonEmptyString, + processName: Schema.NullOr(TrimmedNonEmptyString), + pid: Schema.NullOr(Schema.Int.check(Schema.isGreaterThan(0))), +}); +export type DiscoveredLocalServer = typeof DiscoveredLocalServer.Type; +``` + +WS routes added (§5): + +``` +preview.subscribePorts(callback) → unsubscribe +preview.scanPortsOnce() → ReadonlyArray +``` + +--- + +## 18. Implementation order (single‑shot landing) — REVISED + +To minimize cross‑file thrash while writing: + +1. `packages/contracts/src/preview.ts` (+ test) — `PreviewSession`, `PreviewEvent`, `DiscoveredLocalServer` schemas; `keybindings.ts` / `project.ts` schema edits → **build contracts first**. +2. `apps/server/src/preview/{Services,Layers}/Manager.ts` (+ test) and `{Services,Layers}/PortScanner.ts` (+ test). +3. `apps/server/src/ws.ts` and `runtimeLayer` provision (`PreviewManager.Default`, `PreviewPortScanner.Default`). +4. `apps/desktop/src/preview-view-manager.ts`, `preview-preload.ts`, `main.ts` IPC handlers, `preload.ts` `desktopBridge.preview` namespace; update `apps/desktop/tsdown.config.ts` to also bundle `preview-preload.ts` → `preview-preload.cjs`. +5. `apps/web/src/lib/favicon.ts`; `apps/web/src/previewStateStore.ts`, `apps/web/src/rightPanelStore.ts` (+ tests). +6. `apps/web/src/environmentApi.ts` — add `preview` slot. +7. Right‑panel arbiter migration in `ChatView.tsx` (replace `planSidebarOpen`, render preview alongside diff/plan). +8. Components, in dependency order: + - `BrowserMockup.tsx`, `TabFavicon.tsx`, `useLoadingProgress.ts`, `errorCodeMessages.ts`, `useDiscoveredLocalServers.ts` + - `PreviewLocalServerCard.tsx`, `PreviewEmptyState.tsx` + - `PreviewUnreachable.tsx` + - `PreviewTabStrip.tsx` + - `PreviewWebview.tsx` (the `` host, no-op on web) + - `PreviewView.tsx` (ties it all together: tab strip + chrome row + body switching between empty / webview / unreachable) + - `PreviewPanelShell.tsx`, `PreviewPanel.tsx` +9. Keybinding wiring: `_chat.tsx` `preview.toggle`/`preview.refresh`/`preview.focusUrl`; `keybindings.ts` shortcut helpers. +10. `ProjectScriptsControl.tsx` form additions (`previewUrl`, `autoOpenPreview`). +11. `ThreadTerminalDrawer.tsx` link‑activation update — context menu "Open in preview" / "Open in browser" for previewable URLs. +12. `KEYBINDINGS.md` doc update — add `preview.*` commands and `previewFocus` / `previewOpen` `when` keys. +13. Run `bun fmt && bun lint && bun typecheck && bun run test` — must all pass per `AGENTS.md`. +14. Manual smoke against the dev desktop (`bun run dev:desktop`). + +Estimated diff size: ~3,500–4,500 lines added (mostly net‑new files), ~200 lines modified across `ChatView.tsx`, `ThreadTerminalDrawer.tsx`, `ProjectScriptsControl.tsx`, `_chat.tsx`, `main.ts`, `preload.ts`, `tsdown.config.ts`, `keybindings.ts`, `ws.ts`, `KEYBINDINGS.md`. From 4714f69adba6ed17c5e50761a687cc243c0a4310 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Sun, 3 May 2026 05:16:10 -0700 Subject: [PATCH 02/25] feat(preview): in-app browser preview panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a desktop-only browser preview that lives in the right panel slot alongside plan/diff. Lets the user point an Electron at any URL — typed into a chrome-style URL bar, clicked from the empty-state list of detected localhost dev servers, or auto-opened by a project script with `previewUrl` set. Single-tab per thread. Server (Effect/Layers): - PreviewManager: per-(thread, tab) session metadata via SynchronizedRef + PubSub; survives WS reconnect via `list`/replay. - PreviewPortScanner: lsof on macOS/Linux, TCP probe fallback on Windows; reference-counted polling so we only scan when subscribed. - WS RPC + streams (`preview.open|navigate|refresh|close|list|reportStatus`, `subscribePreviewEvents`, `subscribeDiscoveredLocalServers`). Desktop: - PreviewViewManager owns Chromium WebContents per tab, mediates navigation/zoom/devtools/clear-storage. registerWebview gates by webContents.getType() === "webview" and host-window match. - IPC channels for create/close/register/navigate/back/forward/refresh/ zoom/hardReload/openDevTools/clearCookies/clearCache/getBrowserPartition. - Forwards app-level shortcuts (mod+shift+J, mod+K, mod+,, mod+W) from the webview back to the main window. - Persisted browser session partition (cookies, cache). Web: - PreviewPanel/PreviewView/PreviewWebview render the surface; chrome row with back/forward/refresh + URL input + Open-in-browser + 3-dot menu (Hard reload, DevTools, Zoom −/+/reset, Clear cookies/cache). - usePreviewSession subscribes to server events; usePreviewBridge mirrors desktop state into the store and forwards Loading→Success/ LoadFailed back to the server. - previewStateStore: per-thread snapshot + desktopOverlay + recently- seen URLs (Zustand). - rightPanelStore arbitrates plan vs. preview vs. diff; ChatView's toggles strip the `?diff=1` URL hint when switching to preview and vice versa so the panels are mutually exclusive. - Top-nav Globe toggle in ChatHeader (desktop builds only) and a `mod+shift+J` keybinding routed via a typed previewActionBus. - PreviewEmptyState lists detected localhost servers (scanner + configured project URLs + recently-seen) with live "listening" pulse. - PreviewUnreachable: theme-aware port of Chromium's "site can't be reached" page. - Resizable inline panel (RightPanelResizeHandle + useResizableWidth); width persists to localStorage on drag-end. - Terminal link "Open in preview" context-menu integration for loopback URLs. Contracts: - preview.ts schemas (PreviewSessionSnapshot, PreviewNavStatus, PreviewEvent, RPC inputs/results, DiscoveredLocalServer). - ProjectScript schema gains optional `previewUrl` + `autoOpenPreview`. - New keybinding commands: preview.toggle/refresh/focusUrl/zoomIn/Out/ resetZoom; new `when:` contexts `previewFocus` / `previewOpen`. Shared: - @t3tools/shared/preview: normalizePreviewUrl, isPreviewableUrl, isLoopbackHost, newPreviewTabId, LSOF_LOCAL_HOST_TOKENS. Tests: - contracts: schema decode tests for all preview events/snapshots/inputs. - shared: URL normalization coverage. - server: PreviewManager (open/navigate/reportStatus/refresh/close, multi-subscriber isolation, idempotency); PortScanner (lsof parsing including IPv6, TCP probe, reference-counted polling). - web: previewStateStore (per-tab event application, dedupe, reconnect recovery); rightPanelStore arbitration. --- app-preview-browser-plan.md | 420 ++++++++-------- apps/desktop/src/ipc/DesktopIpcHandlers.ts | 4 + apps/desktop/src/ipc/channels.ts | 16 + apps/desktop/src/ipc/methods/preview.ts | 61 +++ apps/desktop/src/preload.ts | 35 +- apps/desktop/src/preview-view-manager.ts | 455 ++++++++++++++++++ apps/desktop/src/window/DesktopWindow.ts | 4 + .../server/src/preview/Layers/Manager.test.ts | 261 ++++++++++ apps/server/src/preview/Layers/Manager.ts | 313 ++++++++++++ .../src/preview/Layers/PortScanner.test.ts | 180 +++++++ apps/server/src/preview/Layers/PortScanner.ts | 243 ++++++++++ apps/server/src/preview/Services/Manager.ts | 94 ++++ .../src/preview/Services/PortScanner.ts | 36 ++ apps/server/src/server.test.ts | 25 + apps/server/src/server.ts | 5 + apps/server/src/ws.ts | 62 +++ apps/web/package.json | 1 + apps/web/src/components/ChatView.browser.tsx | 20 + apps/web/src/components/ChatView.tsx | 154 +++++- .../src/components/ProjectScriptsControl.tsx | 39 ++ .../src/components/ThreadTerminalDrawer.tsx | 26 +- apps/web/src/components/chat/ChatHeader.tsx | 35 +- .../src/components/preview/BrowserMockup.tsx | 24 + .../components/preview/PreviewChromeRow.tsx | 197 ++++++++ .../components/preview/PreviewEmptyState.tsx | 59 +++ .../preview/PreviewLocalServerCard.tsx | 58 +++ .../components/preview/PreviewMoreMenu.tsx | 121 +++++ .../src/components/preview/PreviewPanel.tsx | 35 ++ .../components/preview/PreviewPanelShell.tsx | 77 +++ .../components/preview/PreviewUnreachable.tsx | 90 ++++ .../src/components/preview/PreviewView.tsx | 193 ++++++++ .../src/components/preview/PreviewWebview.tsx | 116 +++++ .../preview/RightPanelResizeHandle.tsx | 34 ++ .../src/components/preview/ZoomIndicator.tsx | 54 +++ .../components/preview/errorCodeMessages.ts | 12 + .../preview/openTerminalLinkInPreview.ts | 70 +++ .../components/preview/previewActionBus.ts | 31 ++ .../src/components/preview/previewBridge.ts | 9 + .../components/preview/previewConstants.ts | 21 + .../preview/useDiscoveredLocalServers.test.ts | 116 +++++ .../preview/useDiscoveredLocalServers.ts | 141 ++++++ .../components/preview/useLoadingProgress.ts | 45 ++ .../components/preview/usePreviewBridge.ts | 137 ++++++ .../components/preview/usePreviewSession.ts | 67 +++ apps/web/src/environmentApi.ts | 10 + apps/web/src/hooks/useResizableWidth.ts | 176 +++++++ apps/web/src/keybindings.ts | 28 ++ apps/web/src/lib/favicon.ts | 20 + apps/web/src/lib/previewFocus.ts | 15 + apps/web/src/main.tsx | 4 + apps/web/src/previewStateStore.test.ts | 227 +++++++++ apps/web/src/previewStateStore.ts | 188 ++++++++ apps/web/src/rightPanelStore.test.ts | 70 +++ apps/web/src/rightPanelStore.ts | 114 +++++ apps/web/src/routes/_chat.tsx | 61 +++ docs/user/keybindings.md | 14 + packages/client-runtime/src/wsRpcClient.ts | 31 ++ packages/contracts/src/index.ts | 1 + packages/contracts/src/ipc.ts | 80 +++ packages/contracts/src/keybindings.ts | 6 + packages/contracts/src/orchestration.ts | 11 + packages/contracts/src/preview.test.ts | 162 +++++++ packages/contracts/src/preview.ts | 181 +++++++ packages/contracts/src/rpc.ts | 78 +++ packages/shared/package.json | 4 + packages/shared/src/keybindings.ts | 7 + packages/shared/src/preview.test.ts | 75 +++ packages/shared/src/preview.ts | 92 ++++ 68 files changed, 5622 insertions(+), 229 deletions(-) create mode 100644 apps/desktop/src/ipc/methods/preview.ts create mode 100644 apps/desktop/src/preview-view-manager.ts create mode 100644 apps/server/src/preview/Layers/Manager.test.ts create mode 100644 apps/server/src/preview/Layers/Manager.ts create mode 100644 apps/server/src/preview/Layers/PortScanner.test.ts create mode 100644 apps/server/src/preview/Layers/PortScanner.ts create mode 100644 apps/server/src/preview/Services/Manager.ts create mode 100644 apps/server/src/preview/Services/PortScanner.ts create mode 100644 apps/web/src/components/preview/BrowserMockup.tsx create mode 100644 apps/web/src/components/preview/PreviewChromeRow.tsx create mode 100644 apps/web/src/components/preview/PreviewEmptyState.tsx create mode 100644 apps/web/src/components/preview/PreviewLocalServerCard.tsx create mode 100644 apps/web/src/components/preview/PreviewMoreMenu.tsx create mode 100644 apps/web/src/components/preview/PreviewPanel.tsx create mode 100644 apps/web/src/components/preview/PreviewPanelShell.tsx create mode 100644 apps/web/src/components/preview/PreviewUnreachable.tsx create mode 100644 apps/web/src/components/preview/PreviewView.tsx create mode 100644 apps/web/src/components/preview/PreviewWebview.tsx create mode 100644 apps/web/src/components/preview/RightPanelResizeHandle.tsx create mode 100644 apps/web/src/components/preview/ZoomIndicator.tsx create mode 100644 apps/web/src/components/preview/errorCodeMessages.ts create mode 100644 apps/web/src/components/preview/openTerminalLinkInPreview.ts create mode 100644 apps/web/src/components/preview/previewActionBus.ts create mode 100644 apps/web/src/components/preview/previewBridge.ts create mode 100644 apps/web/src/components/preview/previewConstants.ts create mode 100644 apps/web/src/components/preview/useDiscoveredLocalServers.test.ts create mode 100644 apps/web/src/components/preview/useDiscoveredLocalServers.ts create mode 100644 apps/web/src/components/preview/useLoadingProgress.ts create mode 100644 apps/web/src/components/preview/usePreviewBridge.ts create mode 100644 apps/web/src/components/preview/usePreviewSession.ts create mode 100644 apps/web/src/hooks/useResizableWidth.ts create mode 100644 apps/web/src/lib/favicon.ts create mode 100644 apps/web/src/lib/previewFocus.ts create mode 100644 apps/web/src/previewStateStore.test.ts create mode 100644 apps/web/src/previewStateStore.ts create mode 100644 apps/web/src/rightPanelStore.test.ts create mode 100644 apps/web/src/rightPanelStore.ts create mode 100644 packages/contracts/src/preview.test.ts create mode 100644 packages/contracts/src/preview.ts create mode 100644 packages/shared/src/preview.test.ts create mode 100644 packages/shared/src/preview.ts diff --git a/app-preview-browser-plan.md b/app-preview-browser-plan.md index 078c1cc93e9..b6675daf898 100644 --- a/app-preview-browser-plan.md +++ b/app-preview-browser-plan.md @@ -143,7 +143,7 @@ export function selectActiveRightPanel( - 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. +- Existing `planSidebarDismissedForTurnRef`/`planSidebarOpenOnNextThreadRef` logic stays local — it only governs whether to _call_ `open()`/`close()` on turn change. ### Render arbitration @@ -176,49 +176,49 @@ const useSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY); ### 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 | +| 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 | +| 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 @@ -335,10 +335,7 @@ export class PreviewInvalidUrlError extends Schema.TaggedErrorClass Effect.Effect; + readonly open: (input: PreviewOpenInput) => Effect.Effect; readonly navigate: ( input: PreviewNavigateInput, ) => Effect.Effect; - readonly refresh: ( - input: PreviewRefreshInput, - ) => Effect.Effect; + readonly refresh: (input: PreviewRefreshInput) => Effect.Effect; readonly close: (input: PreviewCloseInput) => Effect.Effect; - readonly list: ( - threadId: string, - ) => 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") {} +export class PreviewManager extends Context.Service()( + "t3/preview/Services/Manager/PreviewManager", +) {} ``` ### `apps/server/src/preview/Layers/Manager.ts` @@ -435,15 +425,12 @@ const normalizeUrl = (input: string) => 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 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 })), - ), + Effect.catchAll((cause) => Effect.fail(new PreviewInvalidUrlError({ rawUrl: input, cause }))), ); ``` @@ -513,14 +500,19 @@ export class PreviewViewManager { this.mainWindow = window; } - getPreloadPath(): string { return this.preloadPath; } - getBrowserPartition(): string { return PREVIEW_PARTITION; } + 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() + const ua = sess + .getUserAgent() .replace(/Electron\/[\d.]+ /, "") .replace(/\s*t3code\/[\d.]+/, ""); sess.setUserAgent(ua); @@ -593,9 +585,15 @@ export class PreviewViewManager { 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(); } + 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); @@ -656,16 +654,17 @@ export class PreviewViewManager { 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 + { 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, + return SHORTCUTS.some( + (s) => + s.key.toLowerCase() === input.key.toLowerCase() && + s.meta === input.meta && + s.shift === input.shift, ); } @@ -704,9 +703,9 @@ export class PreviewViewManager { 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}`); + const parsed = new URL( + trimmed.includes("://") ? trimmed : `${useHttp ? "http" : "https"}://${trimmed}`, + ); return parsed.href; } } @@ -716,9 +715,15 @@ export class PreviewTabNotFoundError extends Error { super(`Preview tab not found: ${tabId}`); } } -export class PreviewWebContentsNotFoundError extends Error { /* … */ } -export class PreviewWebviewNotInitializedError extends Error { /* … */ } -export class PreviewInvalidUrlError extends Error { /* … */ } +export class PreviewWebContentsNotFoundError extends Error { + /* … */ +} +export class PreviewWebviewNotInitializedError extends Error { + /* … */ +} +export class PreviewInvalidUrlError extends Error { + /* … */ +} export const previewViewManager = new PreviewViewManager(); ``` @@ -734,18 +739,20 @@ 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: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)); + previewViewManager.setVisibility(tabId, visible), +); ipcMain.handle("preview:registerWebview", (_e, tabId: string, wcId: number) => - previewViewManager.registerWebview(tabId, wcId)); + previewViewManager.registerWebview(tabId, wcId), +); ipcMain.handle("preview:unregisterWebview", (_e, tabId: string) => - previewViewManager.unregisterWebview(tabId)); + previewViewManager.unregisterWebview(tabId), +); ipcMain.handle("preview:navigate", (_e, tabId: string, url: string) => - previewViewManager.navigate(tabId, url)); + 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)); @@ -776,16 +783,13 @@ contextBridge.exposeInMainWorld("desktopBridge", { 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), + 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"), + getBrowserPartition: (): Promise => ipcRenderer.invoke("preview:getBrowserPartition"), onStateChange: (cb: (tabId: string, state: DesktopPreviewTabState) => void) => { const listener = (_e: unknown, tabId: string, state: DesktopPreviewTabState) => cb(tabId, state); @@ -1096,14 +1100,14 @@ Adding `preview.toggle`, `preview.refresh`, `preview.focusUrl` to `STATIC_KEYBIN 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`) | +| 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/`): @@ -1120,15 +1124,15 @@ Manual smoke checklist (drop in `apps/desktop/test/smoke/`): ## 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 | +| 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 | --- @@ -1155,38 +1159,43 @@ Any of these can land in follow-up PRs against the same `PreviewViewManager` + ` 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 `
+ ); +} diff --git a/apps/web/src/components/preview/PreviewChromeRow.tsx b/apps/web/src/components/preview/PreviewChromeRow.tsx new file mode 100644 index 00000000000..2780d6bf409 --- /dev/null +++ b/apps/web/src/components/preview/PreviewChromeRow.tsx @@ -0,0 +1,197 @@ +import { ArrowLeft, ArrowRight, ExternalLink, Globe, RotateCw } from "lucide-react"; +import { + type FormEvent, + type KeyboardEvent, + type ReactNode, + useEffect, + useRef, + useState, +} from "react"; + +import { Button } from "~/components/ui/button"; +import { InputGroup, InputGroupAddon, InputGroupInput } from "~/components/ui/input-group"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; +import { cn } from "~/lib/utils"; + +interface Props { + url: string; + loading: boolean; + loadProgress: number; + canGoBack: boolean; + canGoForward: boolean; + refreshDisabled: boolean; + inputDisabled?: boolean | undefined; + /** Bumping this value re-focuses and selects the URL input. */ + focusUrlNonce?: number | undefined; + onBack: () => void; + onForward: () => void; + onRefresh: () => void; + onSubmit: (url: string) => void; + /** When provided, renders an "Open in browser" affordance to the right. */ + onOpenInBrowser?: (() => void) | undefined; + /** + * Trailing slot rendered after the URL input. Used by the preview view + * to mount the three-dot menu (hard reload, devtools, zoom, clear data). + */ + trailingActions?: ReactNode; +} + +const NOOP = () => {}; + +export function PreviewChromeRow({ + url, + loading, + loadProgress, + canGoBack, + canGoForward, + refreshDisabled, + inputDisabled, + focusUrlNonce, + onBack, + onForward, + onRefresh, + onSubmit, + onOpenInBrowser, + trailingActions, +}: Props) { + const inputRef = useRef(null); + const [draft, setDraft] = useState(url); + + // Sync the input with external URL changes, but only when the user isn't + // actively typing (preserves in-progress edits during navigation events). + useEffect(() => { + setDraft((previous) => (document.activeElement === inputRef.current ? previous : url)); + }, [url]); + + useEffect(() => { + if (focusUrlNonce == null) return; + const node = inputRef.current; + if (!node) return; + node.focus(); + node.select(); + }, [focusUrlNonce]); + + const submit = (event?: FormEvent | KeyboardEvent) => { + event?.preventDefault(); + const next = draft.trim(); + if (next.length === 0) return; + onSubmit(next); + }; + + return ( +
+
+
+ + + } + > + + + Back + + + + } + > + + + Forward + + + + } + > + + + {loading ? "Loading…" : "Refresh"} + +
+ + + + + + setDraft(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") submit(event); + if (event.key === "Escape") { + event.preventDefault(); + setDraft(url); + inputRef.current?.blur(); + } + }} + placeholder="Search or enter URL" + spellCheck={false} + disabled={inputDisabled} + data-preview-url-input + size="sm" + /> + + + {onOpenInBrowser ? ( + + + } + > + + + Open in system browser + + ) : null} + {trailingActions} +
+ {loadProgress > 0 ? ( +
+ ) : null} +
+ ); +} diff --git a/apps/web/src/components/preview/PreviewEmptyState.tsx b/apps/web/src/components/preview/PreviewEmptyState.tsx new file mode 100644 index 00000000000..653fb93610c --- /dev/null +++ b/apps/web/src/components/preview/PreviewEmptyState.tsx @@ -0,0 +1,59 @@ +import type { EnvironmentId } from "@t3tools/contracts"; +import { Globe } from "lucide-react"; + +import { Empty, EmptyDescription, EmptyMedia, EmptyTitle } from "~/components/ui/empty"; + +import { PreviewLocalServerCard } from "./PreviewLocalServerCard"; +import { useDiscoveredLocalServers } from "./useDiscoveredLocalServers"; + +interface Props { + environmentId: EnvironmentId; + configuredUrls?: ReadonlyArray | undefined; + recentlySeenUrls?: ReadonlyArray | undefined; + onOpenUrl: (url: string) => void; +} + +export function PreviewEmptyState({ + environmentId, + configuredUrls, + recentlySeenUrls, + onOpenUrl, +}: Props) { + const servers = useDiscoveredLocalServers({ + environmentId, + configuredUrls, + recentlySeenUrls, + }); + + if (servers.length === 0) { + return ( + + + + + No preview yet + + Type a URL above, or run a dev script. Listening localhost ports will show up here + automatically. + + + ); + } + + return ( +
+

+ Local +

+
+ {servers.map((server) => ( + onOpenUrl(server.url)} + /> + ))} +
+
+ ); +} diff --git a/apps/web/src/components/preview/PreviewLocalServerCard.tsx b/apps/web/src/components/preview/PreviewLocalServerCard.tsx new file mode 100644 index 00000000000..c2e3018d365 --- /dev/null +++ b/apps/web/src/components/preview/PreviewLocalServerCard.tsx @@ -0,0 +1,58 @@ +import { cn } from "~/lib/utils"; + +import { BrowserMockup } from "./BrowserMockup"; +import type { PreviewableServer } from "./useDiscoveredLocalServers"; + +interface Props { + server: PreviewableServer; + onOpen: () => void; +} + +export function PreviewLocalServerCard({ server, onOpen }: Props) { + const subtitle = describeServer(server); + return ( + + ); +} + +function describeServer(server: PreviewableServer): string { + if (server.processName) return server.processName; + if (server.listening) return "Listening"; + if (server.source === "configured") return "Configured"; + return "Recently seen"; +} + +function PulsingDot() { + return ( + + + + + ); +} + +function DimDot() { + return ( + + ); +} diff --git a/apps/web/src/components/preview/PreviewMoreMenu.tsx b/apps/web/src/components/preview/PreviewMoreMenu.tsx new file mode 100644 index 00000000000..748a4a27019 --- /dev/null +++ b/apps/web/src/components/preview/PreviewMoreMenu.tsx @@ -0,0 +1,121 @@ +"use client"; + +import { Minus, MoreVertical, Plus as PlusIcon, RotateCcw } from "lucide-react"; + +import { Button } from "~/components/ui/button"; +import { Menu, MenuItem, MenuPopup, MenuSeparator, MenuTrigger } from "~/components/ui/menu"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; + +import { previewBridge } from "./previewBridge"; + +interface Props { + /** Active preview tab id. Tab-targeting actions are disabled without it. */ + tabId: string | null; + /** + * True only after the desktop bridge has registered a `webContentsId` for + * the active tab. Tab-targeting actions throw on the desktop side until + * then; we disable those items so the menu doesn't fire silent no-ops. + */ + hasWebContents: boolean; + /** Current zoom factor as a number (1.0 = 100%). */ + zoomFactor: number; +} + +/** + * Three-dot menu in the chrome row. Wires Hard reload, DevTools, zoom + * controls, and storage-clearing actions. Only mounted by `PreviewView` + * when the desktop bridge is present, so we can call it unconditionally. + */ +export function PreviewMoreMenu({ tabId, hasWebContents, zoomFactor }: Props) { + if (!previewBridge) return null; + const bridge = previewBridge; + const tabDisabled = !tabId || !hasWebContents; + const callTab = (op: (tabId: string) => Promise) => () => { + if (!tabId) return; + void op(tabId).catch(() => undefined); + }; + + const zoomLabel = `${Math.round(zoomFactor * 100)}%`; + + return ( + + + + } + /> + } + > + + + More + + + + Hard reload + + + Open DevTools + + + {/* + Zoom row: label + inline control cluster. `closeOnClick=false` + keeps the menu open while the user clicks the +/− buttons. + */} + event.preventDefault()} + className="justify-between" + disabled={tabDisabled} + > + Zoom + + + + {zoomLabel} + + + + + + + void bridge.clearCookies().catch(() => undefined)}> + Clear cookies + + void bridge.clearCache().catch(() => undefined)}> + Clear cache + + + + ); +} diff --git a/apps/web/src/components/preview/PreviewPanel.tsx b/apps/web/src/components/preview/PreviewPanel.tsx new file mode 100644 index 00000000000..edd19a31c47 --- /dev/null +++ b/apps/web/src/components/preview/PreviewPanel.tsx @@ -0,0 +1,35 @@ +"use client"; + +import type { ScopedThreadRef } from "@t3tools/contracts"; + +import { isPreviewSupportedInRuntime } from "~/previewStateStore"; + +import { PreviewPanelShell, type PreviewPanelMode } from "./PreviewPanelShell"; +import { PreviewView } from "./PreviewView"; + +interface Props { + mode: PreviewPanelMode; + threadRef: ScopedThreadRef; + configuredUrls?: ReadonlyArray | undefined; + visible: boolean; +} + +export function PreviewPanel({ mode, threadRef, configuredUrls, visible }: Props) { + if (!isPreviewSupportedInRuntime()) { + return ( + +
+

+ Preview is only available in the T3 Code desktop app. +

+
+
+ ); + } + + return ( + + + + ); +} diff --git a/apps/web/src/components/preview/PreviewPanelShell.tsx b/apps/web/src/components/preview/PreviewPanelShell.tsx new file mode 100644 index 00000000000..71e8dd56107 --- /dev/null +++ b/apps/web/src/components/preview/PreviewPanelShell.tsx @@ -0,0 +1,77 @@ +import { type ReactNode, useEffect, useState } from "react"; + +import { isElectron } from "~/env"; +import { useResizableWidth } from "~/hooks/useResizableWidth"; +import { cn } from "~/lib/utils"; + +import { RightPanelResizeHandle } from "./RightPanelResizeHandle"; + +export type PreviewPanelMode = "inline" | "sheet" | "sidebar"; + +const PREVIEW_PANEL_WIDTH_STORAGE_KEY = "t3code:preview-panel-width"; +const PREVIEW_PANEL_MIN_WIDTH = 360; +/** Hard ceiling so a wide monitor can't yield a panel that swallows the chat. */ +const PREVIEW_PANEL_MAX_WIDTH_PX = 1400; +/** Fraction of the viewport allowed; the panel is min(this · vw, MAX_PX). */ +const PREVIEW_PANEL_MAX_WIDTH_FRACTION = 0.7; +const PREVIEW_PANEL_DEFAULT_WIDTH = 540; + +/** + * Shell for the preview panel. In inline mode the panel is user-resizable + * via a drag handle on the left edge; width persists per browser. In + * sheet/sidebar modes the parent owns the size. + */ +export function PreviewPanelShell(props: { mode: PreviewPanelMode; children: ReactNode }) { + const useDragRegion = isElectron && props.mode !== "sheet"; + const isInline = props.mode === "inline"; + const maxWidth = useViewportClampedMaxWidth(); + const { width, handlers } = useResizableWidth({ + storageKey: PREVIEW_PANEL_WIDTH_STORAGE_KEY, + defaultWidth: PREVIEW_PANEL_DEFAULT_WIDTH, + minWidth: PREVIEW_PANEL_MIN_WIDTH, + maxWidth, + edge: "left", + }); + + return ( +
+ {isInline ? : null} + {useDragRegion ?
: null} + {props.children} +
+ ); +} + +/** + * Track viewport width to derive a sensible upper bound for the panel. + * Resize-aware so dragging the OS window narrower re-clamps the stored + * width on the next render (the hook's clamp picks this up automatically). + */ +function useViewportClampedMaxWidth(): number { + const [vw, setVw] = useState(() => (typeof window === "undefined" ? 1280 : window.innerWidth)); + useEffect(() => { + if (typeof window === "undefined") return; + let frame = 0; + const onResize = () => { + // Coalesce rapid resize events into one rAF tick. + if (frame !== 0) return; + frame = window.requestAnimationFrame(() => { + frame = 0; + setVw(window.innerWidth); + }); + }; + window.addEventListener("resize", onResize); + return () => { + window.removeEventListener("resize", onResize); + if (frame !== 0) window.cancelAnimationFrame(frame); + }; + }, []); + return Math.min(PREVIEW_PANEL_MAX_WIDTH_PX, Math.floor(vw * PREVIEW_PANEL_MAX_WIDTH_FRACTION)); +} diff --git a/apps/web/src/components/preview/PreviewUnreachable.tsx b/apps/web/src/components/preview/PreviewUnreachable.tsx new file mode 100644 index 00000000000..c6ada2cb491 --- /dev/null +++ b/apps/web/src/components/preview/PreviewUnreachable.tsx @@ -0,0 +1,90 @@ +import { useState } from "react"; + +import { Button } from "~/components/ui/button"; + +import { describePreviewError } from "./errorCodeMessages"; + +interface Props { + url: string; + /** Chromium net error code, e.g. -105. */ + code: number; + /** Stringified Chromium error, e.g. "ERR_NAME_NOT_RESOLVED". */ + description: string; + onReload: () => void; +} + +/** Theme-aware tailwind port of Chromium's "This site can't be reached" page. */ +export function PreviewUnreachable({ url, code, description, onReload }: Props) { + const [showDetails, setShowDetails] = useState(false); + const host = safeHost(url) ?? url; + const friendly = describePreviewError(code, description); + const errorLabel = description.length > 0 ? description : `ERR_${Math.abs(code) || "FAILED"}`; + + return ( +
+
+ +

+ This site can’t be reached +

+

+ {host}: {friendly}. +

+ + {showDetails ? ( +
+

Try:

+
    +
  • Checking your connection
  • +
  • Confirming the dev server is running
  • +
  • Checking the proxy and the firewall
  • +
+
+ ) : null} + +
+ {errorLabel} +
+ +
+ +
+ +
+
+
+ ); +} + +function ErrorIcon({ className }: { className?: string }) { + return ( + + + + + + ); +} + +function safeHost(url: string): string | null { + try { + return new URL(url).host; + } catch { + return null; + } +} diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx new file mode 100644 index 00000000000..352ac974061 --- /dev/null +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -0,0 +1,193 @@ +"use client"; + +import { scopedThreadKey } from "@t3tools/client-runtime"; +import { type ScopedThreadRef } from "@t3tools/contracts"; +import { useCallback, useEffect, useState } from "react"; + +import { ensureEnvironmentApi } from "~/environmentApi"; +import { ensureLocalApi } from "~/localApi"; +import { selectThreadPreviewState, usePreviewStateStore } from "~/previewStateStore"; + +import { previewBridge } from "./previewBridge"; +import { subscribePreviewAction } from "./previewActionBus"; +import { PreviewChromeRow } from "./PreviewChromeRow"; +import { PreviewEmptyState } from "./PreviewEmptyState"; +import { PreviewMoreMenu } from "./PreviewMoreMenu"; +import { PreviewUnreachable } from "./PreviewUnreachable"; +import { PreviewWebview } from "./PreviewWebview"; +import { useLoadingProgress } from "./useLoadingProgress"; +import { usePreviewSession } from "./usePreviewSession"; +import { ZoomIndicator } from "./ZoomIndicator"; + +interface Props { + threadRef: ScopedThreadRef; + configuredUrls?: ReadonlyArray | undefined; + visible: boolean; +} + +const localApi = typeof window === "undefined" ? null : ensureLocalApi(); + +/** + * Single-tab preview surface: chrome row on top, one webview below, empty + * state when no session exists for the thread. + */ +export function PreviewView({ threadRef, configuredUrls, visible }: Props) { + const [focusUrlNonce, setFocusUrlNonce] = useState(0); + const previewState = usePreviewStateStore((state) => + selectThreadPreviewState(state.byThreadKey, threadRef), + ); + const rememberUrl = usePreviewStateStore((state) => state.rememberUrl); + + usePreviewSession(threadRef); + + const { snapshot, desktopOverlay } = previewState; + const tabId = snapshot?.tabId ?? null; + const navStatus = snapshot?.navStatus ?? { _tag: "Idle" as const }; + const url = navStatus._tag === "Idle" ? "" : navStatus.url; + const loading = desktopOverlay?.loading ?? navStatus._tag === "Loading"; + const canGoBack = desktopOverlay?.canGoBack ?? snapshot?.canGoBack ?? false; + const canGoForward = desktopOverlay?.canGoForward ?? snapshot?.canGoForward ?? false; + const refreshDisabled = navStatus._tag === "Idle"; + const isUnreachable = navStatus._tag === "LoadFailed"; + const loadProgress = useLoadingProgress(loading); + + const handleSubmitUrl = useCallback( + async (next: string) => { + const api = ensureEnvironmentApi(threadRef.environmentId); + try { + if (tabId && previewBridge) { + // Drive the webview imperatively; `usePreviewBridge` mirrors the + // resolved URL back to the server so other clients stay in sync. + await previewBridge.navigate(tabId, next); + rememberUrl(threadRef, next); + } else { + const resolved = await api.preview.open({ threadId: threadRef.threadId, url: next }); + const resolvedUrl = resolved.navStatus._tag === "Idle" ? next : resolved.navStatus.url; + rememberUrl(threadRef, resolvedUrl); + } + } catch { + // Server-side `failed` event renders the unreachable view. + } + }, + [rememberUrl, tabId, threadRef], + ); + + const handleRefresh = useCallback(() => { + if (previewBridge && tabId) void previewBridge.refresh(tabId); + }, [tabId]); + + const handleZoomIn = useCallback(() => { + if (previewBridge && tabId) void previewBridge.zoomIn(tabId); + }, [tabId]); + + const handleZoomOut = useCallback(() => { + if (previewBridge && tabId) void previewBridge.zoomOut(tabId); + }, [tabId]); + + const handleResetZoom = useCallback(() => { + if (previewBridge && tabId) void previewBridge.resetZoom(tabId); + }, [tabId]); + + const handleBack = useCallback(() => { + if (previewBridge && tabId) void previewBridge.goBack(tabId); + }, [tabId]); + + const handleForward = useCallback(() => { + if (previewBridge && tabId) void previewBridge.goForward(tabId); + }, [tabId]); + + const handleOpenInBrowser = useCallback(() => { + if (!localApi || !url) return; + void localApi.shell.openExternal(url).catch(() => undefined); + }, [url]); + + // Subscribe only while visible; `toggle-panel` is owned by ChatView's + // URL-aware handler regardless of whether the panel is currently mounted. + useEffect(() => { + if (!visible) return; + return subscribePreviewAction((action) => { + switch (action) { + case "refresh": + handleRefresh(); + return; + case "focus-url": + setFocusUrlNonce((value) => value + 1); + return; + case "zoom-in": + handleZoomIn(); + return; + case "zoom-out": + handleZoomOut(); + return; + case "reset-zoom": + handleResetZoom(); + return; + case "toggle-panel": + return; + } + }); + }, [handleRefresh, handleResetZoom, handleZoomIn, handleZoomOut, visible]); + + return ( +
+ void handleSubmitUrl(next)} + onOpenInBrowser={tabId ? handleOpenInBrowser : undefined} + trailingActions={ + previewBridge ? ( + + ) : null + } + /> + +
+ {tabId && snapshot ? ( + + ) : ( + void handleSubmitUrl(next)} + /> + )} + {snapshot && desktopOverlay ? ( + + ) : null} + {isUnreachable && navStatus._tag === "LoadFailed" ? ( +
+ +
+ ) : null} +
+
+ ); +} diff --git a/apps/web/src/components/preview/PreviewWebview.tsx b/apps/web/src/components/preview/PreviewWebview.tsx new file mode 100644 index 00000000000..3f78c588f90 --- /dev/null +++ b/apps/web/src/components/preview/PreviewWebview.tsx @@ -0,0 +1,116 @@ +"use client"; + +import type { ScopedThreadRef } from "@t3tools/contracts"; +import { useEffect, useRef, useState } from "react"; + +import { isElectron } from "~/env"; + +import { previewBridge } from "./previewBridge"; +import { usePreviewBridge } from "./usePreviewBridge"; + +interface Props { + threadRef: ScopedThreadRef; + tabId: string; + /** + * URL to load on first mount. Subsequent prop changes are ignored — once + * the webview is live, navigation flows exclusively through the bridge + * (`previewBridge.navigate`, `goBack`, `goForward`, `refresh`). Otherwise, + * a snapshot URL update from the server would re-set `` and + * race with the `loadURL` we already issued via the bridge. + */ + initialUrl: string | null; + className?: string; +} + +interface ElectronWebview extends HTMLElement { + src: string; + partition: string; + allowpopups: boolean; + reload: () => void; + getWebContentsId: () => number; +} + +declare global { + interface HTMLElementTagNameMap { + webview: ElectronWebview; + } +} + +/** + * Hosts the Electron `` for a single preview tab. Returns null on + * web builds. The two-step handshake (createTab → wait for `dom-ready` → + * registerWebview) is necessary because `getWebContentsId()` only returns a + * valid id once the embedded contents have parsed; calling it synchronously + * after mount throws. + */ +export function PreviewWebview({ threadRef, tabId, initialUrl, className }: Props) { + const [config, setConfig] = useState<{ partition: string } | null>(null); + const webviewRef = useRef(null); + // Capture once at mount; never re-derived from `initialUrl` props later. + const initialSrcRef = useRef(initialUrl ?? "about:blank"); + const bridge = previewBridge; + + // Per-tab desktop lifecycle: createTab on mount, closeTab on unmount, + // mirror state into the store, and reflect navigation back to the server. + usePreviewBridge({ threadRef, tabId }); + + useEffect(() => { + if (!bridge) return; + let cancelled = false; + void bridge + .getBrowserPartition() + .then((partition) => { + if (cancelled) return; + setConfig({ partition }); + }) + .catch(() => undefined); + return () => { + cancelled = true; + }; + }, [bridge]); + + useEffect(() => { + if (!bridge || !config) return; + const webview = webviewRef.current; + if (!webview) return; + + const onDomReady = () => { + try { + const id = webview.getWebContentsId(); + if (Number.isFinite(id)) { + void bridge.registerWebview(tabId, id); + } + } catch { + // The next dom-ready (e.g. cross-document navigation) will retry. + } + }; + webview.addEventListener("dom-ready", onDomReady as EventListener); + // Defensive: dom-ready may have fired between React commit and this + // effect running. Failures fall through to the listener above. + try { + const existing = webview.getWebContentsId(); + if (Number.isFinite(existing)) { + void bridge.registerWebview(tabId, existing); + } + } catch { + // covered by dom-ready listener + } + + return () => { + webview.removeEventListener("dom-ready", onDomReady as EventListener); + }; + }, [bridge, config, tabId]); + + if (!isElectron || !bridge || !config) return null; + + return ( + + ); +} diff --git a/apps/web/src/components/preview/RightPanelResizeHandle.tsx b/apps/web/src/components/preview/RightPanelResizeHandle.tsx new file mode 100644 index 00000000000..5091c8b5915 --- /dev/null +++ b/apps/web/src/components/preview/RightPanelResizeHandle.tsx @@ -0,0 +1,34 @@ +import type { ResizableWidthHandlers } from "~/hooks/useResizableWidth"; +import { cn } from "~/lib/utils"; + +interface Props { + handlers: ResizableWidthHandlers; + className?: string; +} + +/** + * Hit target for resizing a right-anchored panel via its left edge. + * + * - Sits on top of the panel's border with a 4px overlap on each side so the + * user can grab a few pixels off the edge without aiming. + * - Visual indicator is a 1px line that lights up on hover/active to mirror + * VS Code / Cursor. + */ +export function RightPanelResizeHandle({ handlers, className }: Props) { + return ( +
+ +
+ ); +} diff --git a/apps/web/src/components/preview/ZoomIndicator.tsx b/apps/web/src/components/preview/ZoomIndicator.tsx new file mode 100644 index 00000000000..0ac536668ac --- /dev/null +++ b/apps/web/src/components/preview/ZoomIndicator.tsx @@ -0,0 +1,54 @@ +import { useEffect, useRef, useState } from "react"; + +import { cn } from "~/lib/utils"; + +const HIDE_AFTER_MS = 1500; +const ZOOM_EPSILON = 0.001; + +interface Props { + /** Current zoom factor (1.0 = 100%); changes drive the transient indicator. */ + zoomFactor: number; +} + +/** + * Floating "X%" pill that surfaces in the top-right of the webview area + * whenever the zoom factor changes, then fades out after a short pause. + * + * Suppressed for the first render's value so we don't flash 100% on mount. + */ +export function ZoomIndicator({ zoomFactor }: Props) { + const [visible, setVisible] = useState(false); + const lastFactorRef = useRef(zoomFactor); + const timerRef = useRef(null); + + useEffect(() => { + if (Math.abs(lastFactorRef.current - zoomFactor) < ZOOM_EPSILON) return; + lastFactorRef.current = zoomFactor; + setVisible(true); + if (timerRef.current !== null) window.clearTimeout(timerRef.current); + timerRef.current = window.setTimeout(() => { + setVisible(false); + timerRef.current = null; + }, HIDE_AFTER_MS); + return () => { + if (timerRef.current !== null) { + window.clearTimeout(timerRef.current); + timerRef.current = null; + } + }; + }, [zoomFactor]); + + const percent = `${Math.round(zoomFactor * 100)}%`; + + return ( +
+ {percent} +
+ ); +} diff --git a/apps/web/src/components/preview/errorCodeMessages.ts b/apps/web/src/components/preview/errorCodeMessages.ts new file mode 100644 index 00000000000..78ee928800b --- /dev/null +++ b/apps/web/src/components/preview/errorCodeMessages.ts @@ -0,0 +1,12 @@ +import { PREVIEW_ERROR_CODE_MESSAGES } from "./previewConstants"; + +/** + * Resolve a friendly description for a Chromium / network error. Falls back + * to the description string passed in when the code isn't in our table. + */ +export function describePreviewError(code: number, description: string): string { + const friendly = PREVIEW_ERROR_CODE_MESSAGES[description]; + if (friendly) return friendly; + if (description.length > 0) return description; + return `Network error (${code})`; +} diff --git a/apps/web/src/components/preview/openTerminalLinkInPreview.ts b/apps/web/src/components/preview/openTerminalLinkInPreview.ts new file mode 100644 index 00000000000..0cafb439483 --- /dev/null +++ b/apps/web/src/components/preview/openTerminalLinkInPreview.ts @@ -0,0 +1,70 @@ +import type { EnvironmentApi, LocalApi, ScopedThreadRef } from "@t3tools/contracts"; +import { isPreviewableUrl } from "@t3tools/shared/preview"; + +import { isPreviewSupportedInRuntime } from "~/previewStateStore"; +import { useRightPanelStore } from "~/rightPanelStore"; + +interface OpenTerminalLinkInPreviewInput { + readonly url: string; + readonly position: { x: number; y: number }; + readonly threadRef: ScopedThreadRef; + readonly api: EnvironmentApi; + readonly localApi: LocalApi; + /** Called whenever the URL ultimately needs to open in the system browser. */ + readonly fallbackToBrowser: () => void; +} + +/** + * Handles a terminal-link click that resolves to a URL. + * + * - For non-loopback / unsupported runtimes, defers to the system browser. + * - For previewable URLs in the desktop build, presents a context menu to + * choose between the in-app preview and the system browser. + * + * Failures fall back to the system browser so a stuck context-menu doesn't + * leave the user without a way to open the link. + */ +export async function openTerminalLinkInPreview( + input: OpenTerminalLinkInPreviewInput, +): Promise { + const supportsPreview = + isPreviewableUrl(input.url) && + isPreviewSupportedInRuntime() && + input.threadRef.threadId.length > 0; + + if (!supportsPreview) { + input.fallbackToBrowser(); + return; + } + + let choice: "open-in-preview" | "open-in-browser" | null; + try { + choice = await input.localApi.contextMenu.show( + [ + { id: "open-in-preview", label: "Open in preview" }, + { id: "open-in-browser", label: "Open in browser" }, + ], + input.position, + ); + } catch { + input.fallbackToBrowser(); + return; + } + + if (choice === "open-in-preview") { + try { + await input.api.preview.open({ + threadId: input.threadRef.threadId, + url: input.url, + }); + useRightPanelStore.getState().open(input.threadRef, "preview"); + } catch { + input.fallbackToBrowser(); + } + return; + } + + if (choice === "open-in-browser") { + input.fallbackToBrowser(); + } +} diff --git a/apps/web/src/components/preview/previewActionBus.ts b/apps/web/src/components/preview/previewActionBus.ts new file mode 100644 index 00000000000..23efdf487c6 --- /dev/null +++ b/apps/web/src/components/preview/previewActionBus.ts @@ -0,0 +1,31 @@ +"use client"; + +/** + * Typed window-event bus for preview-panel actions. Lets the global + * keybinding handler in `routes/_chat.tsx` reach `ChatView`'s URL-aware + * arbitration without prop drilling or shared refs. + */ +export type PreviewAction = + | "toggle-panel" + | "refresh" + | "focus-url" + | "zoom-in" + | "zoom-out" + | "reset-zoom"; + +const EVENT_NAME = "t3code:preview-action"; + +export function dispatchPreviewAction(action: PreviewAction): void { + if (typeof window === "undefined") return; + window.dispatchEvent(new CustomEvent(EVENT_NAME, { detail: action })); +} + +export function subscribePreviewAction(listener: (action: PreviewAction) => void): () => void { + if (typeof window === "undefined") return () => {}; + const handler = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (typeof detail === "string") listener(detail); + }; + window.addEventListener(EVENT_NAME, handler); + return () => window.removeEventListener(EVENT_NAME, handler); +} diff --git a/apps/web/src/components/preview/previewBridge.ts b/apps/web/src/components/preview/previewBridge.ts new file mode 100644 index 00000000000..90e9bf7241b --- /dev/null +++ b/apps/web/src/components/preview/previewBridge.ts @@ -0,0 +1,9 @@ +/** + * Module-level handle to the desktop preview bridge. + * + * Resolved once at import time so React hooks don't pay for repeated + * `window.desktopBridge?.preview` lookups on every render. `null` on the web + * build where there's no Electron host. + */ +export const previewBridge = + typeof window === "undefined" ? null : (window.desktopBridge?.preview ?? null); diff --git a/apps/web/src/components/preview/previewConstants.ts b/apps/web/src/components/preview/previewConstants.ts new file mode 100644 index 00000000000..d174577a584 --- /dev/null +++ b/apps/web/src/components/preview/previewConstants.ts @@ -0,0 +1,21 @@ +/** Cap for the per-thread "recently seen" URL list shown in the empty state. */ +export const PREVIEW_RECENT_URL_LIMIT = 10; + +/** + * Common Chromium error codes mapped to a short human label. Used by the + * unreachable view to drop the raw `ERR_*` code in favour of friendlier copy. + */ +export const PREVIEW_ERROR_CODE_MESSAGES: Readonly> = Object.freeze({ + ERR_NAME_NOT_RESOLVED: "DNS address could not be found", + ERR_NAME_RESOLUTION_FAILED: "DNS address could not be found", + ERR_CONNECTION_REFUSED: "Connection refused", + ERR_CONNECTION_RESET: "Connection was reset", + ERR_CONNECTION_CLOSED: "Connection was closed", + ERR_CONNECTION_TIMED_OUT: "Connection timed out", + ERR_INTERNET_DISCONNECTED: "No internet connection", + ERR_TIMED_OUT: "Connection timed out", + ERR_CERT_AUTHORITY_INVALID: "Certificate authority is not trusted", + ERR_CERT_COMMON_NAME_INVALID: "Certificate hostname mismatch", + ERR_CERT_DATE_INVALID: "Certificate is expired or not yet valid", + ERR_TOO_MANY_REDIRECTS: "Too many redirects", +}); diff --git a/apps/web/src/components/preview/useDiscoveredLocalServers.test.ts b/apps/web/src/components/preview/useDiscoveredLocalServers.test.ts new file mode 100644 index 00000000000..43bd879d032 --- /dev/null +++ b/apps/web/src/components/preview/useDiscoveredLocalServers.test.ts @@ -0,0 +1,116 @@ +import type { DiscoveredLocalServer } from "@t3tools/contracts"; +import { describe, expect, it } from "vitest"; + +import { mergeServers, type PreviewableServer } from "./useDiscoveredLocalServers"; + +const scannerServer = (overrides: Partial): DiscoveredLocalServer => ({ + host: "localhost", + port: 5173, + url: "http://localhost:5173", + processName: "vite", + pid: 1234, + ...overrides, +}); + +describe("mergeServers", () => { + it("returns scanner-only entries unchanged", () => { + const result = mergeServers({ + scanner: [scannerServer({})], + configuredUrls: [], + recentlySeenUrls: [], + }); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + host: "localhost", + port: 5173, + source: "scanner", + listening: true, + processName: "vite", + }); + }); + + it("enriches a configured entry with live process metadata when scanner sees it", () => { + const result = mergeServers({ + scanner: [scannerServer({ port: 5173, processName: "node", pid: 9999 })], + configuredUrls: ["http://localhost:5173"], + recentlySeenUrls: [], + }); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + port: 5173, + source: "configured", + listening: true, + processName: "node", + pid: 9999, + }); + }); + + it("keeps configured entries that the scanner doesn't see, with listening=false", () => { + const result = mergeServers({ + scanner: [], + configuredUrls: ["http://localhost:5173"], + recentlySeenUrls: [], + }); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + source: "configured", + listening: false, + }); + }); + + it("dedupes recently-seen URLs against scanner+configured entries", () => { + const result = mergeServers({ + scanner: [scannerServer({ port: 5173 })], + configuredUrls: [], + recentlySeenUrls: ["http://localhost:5173/", "http://localhost:8080/"], + }); + expect(result.map((s) => s.port)).toEqual([5173, 8080]); + expect(result.find((s) => s.port === 5173)?.source).toBe("scanner"); + expect(result.find((s) => s.port === 8080)?.source).toBe("recent"); + }); + + it("ignores non-loopback URLs in configured/recent inputs", () => { + const result = mergeServers({ + scanner: [], + configuredUrls: ["https://example.com", "ws://localhost:5173"], + recentlySeenUrls: ["https://api.example.com"], + }); + expect(result).toHaveLength(0); + }); + + it("sorts: configured before scanner before recent, then by port", () => { + const result = mergeServers({ + scanner: [scannerServer({ port: 8080 }), scannerServer({ port: 3000 })], + configuredUrls: ["http://localhost:5173"], + recentlySeenUrls: ["http://localhost:9000/", "http://localhost:4321/"], + }); + expect(result.map((s) => `${s.source}:${s.port}`)).toEqual([ + "configured:5173", + "scanner:3000", + "scanner:8080", + "recent:4321", + "recent:9000", + ]); + }); + + it("dedupes by lowercased host", () => { + const result = mergeServers({ + scanner: [scannerServer({ host: "Localhost", port: 5173 })], + configuredUrls: ["http://localhost:5173"], + recentlySeenUrls: [], + }); + expect(result).toHaveLength(1); + }); +}); + +describe("PreviewableServer interface", () => { + it("preserves listening flag through enrichment", () => { + const result = mergeServers({ + scanner: [scannerServer({})], + configuredUrls: ["http://localhost:5173"], + recentlySeenUrls: [], + }); + const merged: PreviewableServer | undefined = result[0]; + expect(merged?.listening).toBe(true); + }); +}); diff --git a/apps/web/src/components/preview/useDiscoveredLocalServers.ts b/apps/web/src/components/preview/useDiscoveredLocalServers.ts new file mode 100644 index 00000000000..9eab8592d9e --- /dev/null +++ b/apps/web/src/components/preview/useDiscoveredLocalServers.ts @@ -0,0 +1,141 @@ +import type { DiscoveredLocalServer } from "@t3tools/contracts"; +import { isLoopbackHost } from "@t3tools/shared/preview"; +import { useEffect, useMemo, useState } from "react"; + +import { ensureEnvironmentApi } from "~/environmentApi"; +import type { EnvironmentId } from "@t3tools/contracts"; + +export interface PreviewableServer extends DiscoveredLocalServer { + source: "scanner" | "configured" | "recent"; + /** + * True when the port scanner currently sees this server listening. A + * `configured` entry can also be `listening` when the scan enriched it. + */ + listening: boolean; +} + +interface UseDiscoveredLocalServersInput { + environmentId: EnvironmentId; + configuredUrls?: ReadonlyArray | undefined; + recentlySeenUrls?: ReadonlyArray | undefined; +} + +/** + * Subscribe to live localhost port scans, merge in configured / + * recently-seen URLs, and return a stable sorted list. Retains the scanner + * while mounted. + */ +export function useDiscoveredLocalServers( + input: UseDiscoveredLocalServersInput, +): ReadonlyArray { + const [scannerSnapshot, setScannerSnapshot] = useState>([]); + + useEffect(() => { + const api = ensureEnvironmentApi(input.environmentId); + setScannerSnapshot([]); + const unsubscribe = api.preview.subscribePorts((next) => { + setScannerSnapshot(next.servers); + }); + return unsubscribe; + }, [input.environmentId]); + + return useMemo( + () => + mergeServers({ + scanner: scannerSnapshot, + configuredUrls: input.configuredUrls ?? [], + recentlySeenUrls: input.recentlySeenUrls ?? [], + }), + [scannerSnapshot, input.configuredUrls, input.recentlySeenUrls], + ); +} + +export function mergeServers(input: { + scanner: ReadonlyArray; + configuredUrls: ReadonlyArray; + recentlySeenUrls: ReadonlyArray; +}): ReadonlyArray { + const seen = new Map(); + + for (const url of input.configuredUrls) { + const parsed = parseLocalUrl(url); + if (!parsed) continue; + const key = canonicalKey(parsed.host, parsed.port); + if (seen.has(key)) continue; + seen.set(key, { + host: parsed.host, + port: parsed.port, + url: parsed.url, + processName: null, + pid: null, + source: "configured", + listening: false, + }); + } + + for (const server of input.scanner) { + const key = canonicalKey(server.host, server.port); + const existing = seen.get(key); + if (existing) { + // Enrich a configured entry with live process metadata; flip + // `listening` so it pulses green like a scanner-discovered entry. + seen.set(key, { + ...existing, + processName: server.processName ?? existing.processName, + pid: server.pid ?? existing.pid, + listening: true, + }); + continue; + } + seen.set(key, { ...server, source: "scanner", listening: true }); + } + + for (const url of input.recentlySeenUrls) { + const parsed = parseLocalUrl(url); + if (!parsed) continue; + const key = canonicalKey(parsed.host, parsed.port); + if (seen.has(key)) continue; + seen.set(key, { + host: parsed.host, + port: parsed.port, + url: parsed.url, + processName: null, + pid: null, + source: "recent", + listening: false, + }); + } + + return Array.from(seen.values()).toSorted((a, b) => { + const sourceOrder: Record = { + configured: 0, + scanner: 1, + recent: 2, + }; + if (sourceOrder[a.source] !== sourceOrder[b.source]) { + return sourceOrder[a.source] - sourceOrder[b.source]; + } + return a.port - b.port; + }); +} + +function canonicalKey(host: string, port: number): string { + return `${host.toLowerCase()}:${port}`; +} + +function parseLocalUrl(raw: string): { host: string; port: number; url: string } | null { + try { + const parsed = new URL(raw); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null; + if (!isLoopbackHost(parsed.hostname)) return null; + const port = parsed.port + ? Number.parseInt(parsed.port, 10) + : parsed.protocol === "http:" + ? 80 + : 443; + if (!Number.isFinite(port) || port <= 0) return null; + return { host: parsed.hostname, port, url: parsed.href }; + } catch { + return null; + } +} diff --git a/apps/web/src/components/preview/useLoadingProgress.ts b/apps/web/src/components/preview/useLoadingProgress.ts new file mode 100644 index 00000000000..6c47cea6017 --- /dev/null +++ b/apps/web/src/components/preview/useLoadingProgress.ts @@ -0,0 +1,45 @@ +import { useEffect, useRef, useState } from "react"; + +const TICK_INTERVAL_MS = 120; +const FADE_OUT_DELAY_MS = 220; +const SEED_PERCENT = 4; +const ASYMPTOTE_PERCENT = 90; +const APPROACH_FACTOR = 0.08; +const MIN_INCREMENT = 0.5; + +/** + * Indeterminate progress simulator for the preview chrome's loading bar. + * Animates 0 → 90% asymptotically while `loading` is true, snaps to 100% + * on release, then resets after a short pause. + * + * Uses a ref to thread the latest progress through interval ticks without + * needing `loading` to retrigger the effect, which sidesteps the stale- + * closure pitfalls of reading `progress` directly. + */ +export function useLoadingProgress(loading: boolean): number { + const [progress, setProgress] = useState(0); + const progressRef = useRef(0); + progressRef.current = progress; + + useEffect(() => { + if (!loading) { + if (progressRef.current === 0) return; + setProgress(100); + const timer = window.setTimeout(() => setProgress(0), FADE_OUT_DELAY_MS); + return () => window.clearTimeout(timer); + } + + setProgress((value) => (value > 0 && value < 95 ? value : SEED_PERCENT)); + const interval = window.setInterval(() => { + const current = progressRef.current; + if (current >= ASYMPTOTE_PERCENT) return; + const remaining = ASYMPTOTE_PERCENT - current; + const increment = Math.max(MIN_INCREMENT, remaining * APPROACH_FACTOR); + setProgress(Math.min(ASYMPTOTE_PERCENT, current + increment)); + }, TICK_INTERVAL_MS); + + return () => window.clearInterval(interval); + }, [loading]); + + return progress; +} diff --git a/apps/web/src/components/preview/usePreviewBridge.ts b/apps/web/src/components/preview/usePreviewBridge.ts new file mode 100644 index 00000000000..6a80fb82001 --- /dev/null +++ b/apps/web/src/components/preview/usePreviewBridge.ts @@ -0,0 +1,137 @@ +"use client"; + +import type { + DesktopPreviewTabState, + PreviewReportStatusInput, + ScopedThreadRef, + ThreadId, +} from "@t3tools/contracts"; +import { useEffect, useRef } from "react"; + +import { ensureEnvironmentApi } from "~/environmentApi"; +import { type DesktopPreviewOverlay, usePreviewStateStore } from "~/previewStateStore"; + +import { previewBridge } from "./previewBridge"; + +/** + * Owns the desktop tab lifecycle, mirrors low-latency button state into the + * store, and reflects bridge navigation events back to the server. + * + * Tab create/close is anchored to the panel mount, NOT to the snapshot, so + * snapshot transitions (`opened` → `closed` → `opened`) cannot tear down and + * recreate the tab in the wrong order relative to in-flight desktop IPCs. + */ +export function usePreviewBridge(input: { threadRef: ScopedThreadRef; tabId: string }): void { + const { threadRef, tabId } = input; + const applyDesktopState = usePreviewStateStore((state) => state.applyDesktopState); + const bridge = previewBridge; + + useEffect(() => { + if (!bridge) return; + void bridge.createTab(tabId); + return () => { + void bridge.closeTab(tabId); + }; + }, [bridge, tabId]); + + // One bridge subscription does both jobs (mirror state + forward to + // server) so the desktop bridge keeps a single listener entry per tab. + const lastReportedUrl = useRef(null); + const lastReportedKind = useRef(null); + useEffect(() => { + if (!bridge || typeof window === "undefined") return; + const api = ensureEnvironmentApi(threadRef.environmentId); + lastReportedUrl.current = null; + lastReportedKind.current = null; + const unsubscribe = bridge.onStateChange((changedTabId, state) => { + if (changedTabId !== tabId) return; + applyDesktopState(threadRef, projectDesktopState(state)); + const reported = buildReportInput({ + threadId: threadRef.threadId, + tabId, + state, + lastReportedUrl: lastReportedUrl.current, + lastReportedKind: lastReportedKind.current, + }); + if (!reported) return; + lastReportedUrl.current = reported.lastReportedUrl; + lastReportedKind.current = reported.lastReportedKind; + void api.preview.reportStatus(reported.input).catch(() => undefined); + }); + return unsubscribe; + }, [applyDesktopState, bridge, tabId, threadRef]); +} + +function projectDesktopState(state: DesktopPreviewTabState): DesktopPreviewOverlay { + return { + canGoBack: state.canGoBack, + canGoForward: state.canGoForward, + loading: state.navStatus.kind === "Loading", + zoomFactor: state.zoomFactor, + }; +} + +/** + * Decide whether a state change warrants an RPC to the server, and shape + * the report payload. + * + * - Idle never reports — the tab is post-close or pre-load and the server + * already knows the canonical state from `open` / `closed`. + * - We dedupe on (kind, url): consecutive Loading→Loading→Loading for the + * same URL collapses to a single RPC, ditto Success. + * - LoadFailed always reports (the server uses it to emit `failed`). + */ +function buildReportInput(args: { + readonly threadId: ThreadId; + readonly tabId: string; + readonly state: DesktopPreviewTabState; + readonly lastReportedUrl: string | null; + readonly lastReportedKind: DesktopPreviewTabState["navStatus"]["kind"] | null; +}): { + readonly input: PreviewReportStatusInput; + readonly lastReportedUrl: string; + readonly lastReportedKind: DesktopPreviewTabState["navStatus"]["kind"]; +} | null { + const { threadId, tabId, state, lastReportedUrl, lastReportedKind } = args; + const status = state.navStatus; + if (status.kind === "Idle") return null; + + // Skip if we've already reported the same kind+url. LoadFailed always + // reports (rapid duplicate failures are unusual and worth surfacing). + const sameAsLast = + status.kind !== "LoadFailed" && + status.kind === lastReportedKind && + status.url === lastReportedUrl; + if (sameAsLast) return null; + + const base = { + threadId, + tabId, + canGoBack: state.canGoBack, + canGoForward: state.canGoForward, + }; + if (status.kind === "LoadFailed") { + return { + input: { + ...base, + navStatus: { + _tag: "LoadFailed", + url: status.url, + title: status.title, + code: status.code, + description: status.description, + }, + }, + lastReportedUrl: status.url, + lastReportedKind: "LoadFailed", + }; + } + return { + input: { + ...base, + navStatus: { _tag: status.kind, url: status.url, title: status.title }, + }, + lastReportedUrl: status.url, + lastReportedKind: status.kind, + }; +} diff --git a/apps/web/src/components/preview/usePreviewSession.ts b/apps/web/src/components/preview/usePreviewSession.ts new file mode 100644 index 00000000000..152765213bf --- /dev/null +++ b/apps/web/src/components/preview/usePreviewSession.ts @@ -0,0 +1,67 @@ +"use client"; + +import { scopedThreadKey } from "@t3tools/client-runtime"; +import type { ScopedThreadRef } from "@t3tools/contracts"; +import { useEffect } from "react"; + +import { ensureEnvironmentApi } from "~/environmentApi"; +import { usePreviewStateStore } from "~/previewStateStore"; + +/** + * Subscribes to the server's per-thread preview events and replays the + * latest snapshot on mount. + * + * Reconnect-recovery: when the local renderer remembers a snapshot but the + * server has none (server restarted while we were alive), re-issue + * `preview.open` so subsequent events land on a real session. + */ +export function usePreviewSession(threadRef: ScopedThreadRef): void { + const applyServerSnapshot = usePreviewStateStore((state) => state.applyServerSnapshot); + const applyServerEvent = usePreviewStateStore((state) => state.applyServerEvent); + + useEffect(() => { + if (typeof window === "undefined") return; + const api = ensureEnvironmentApi(threadRef.environmentId); + const threadIdValue = threadRef.threadId; + let cancelled = false; + + void api.preview + .list({ threadId: threadIdValue }) + .then((result) => { + if (cancelled) return; + // Pick the most recent session. Server returns sessions sorted by + // `updatedAt` ascending, so the last one is freshest. + const serverSnapshot = result.sessions.at(-1) ?? null; + if (serverSnapshot) { + applyServerSnapshot(threadRef, serverSnapshot); + return; + } + // Server has no sessions — try to recover what the renderer + // remembers from before the disconnect. + const localSnapshot = + usePreviewStateStore.getState().byThreadKey[scopedThreadKey(threadRef)]?.snapshot; + const recoverableUrl = + localSnapshot && localSnapshot.navStatus._tag !== "Idle" + ? localSnapshot.navStatus.url + : null; + if (recoverableUrl) { + void api.preview + .open({ threadId: threadIdValue, url: recoverableUrl }) + .catch(() => undefined); + } else { + applyServerSnapshot(threadRef, null); + } + }) + .catch(() => undefined); + + const unsubscribe = api.preview.onEvent((event) => { + if (event.threadId !== threadIdValue) return; + applyServerEvent(threadRef, event); + }); + + return () => { + cancelled = true; + unsubscribe(); + }; + }, [applyServerEvent, applyServerSnapshot, threadRef]); +} diff --git a/apps/web/src/environmentApi.ts b/apps/web/src/environmentApi.ts index bdb2e793069..0268d98dcd3 100644 --- a/apps/web/src/environmentApi.ts +++ b/apps/web/src/environmentApi.ts @@ -58,6 +58,16 @@ export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi { subscribeThread: (input, callback, options) => rpcClient.orchestration.subscribeThread(input, callback, options), }, + 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: (input) => rpcClient.preview.list(input as never), + reportStatus: (input) => rpcClient.preview.reportStatus(input as never), + onEvent: (callback) => rpcClient.preview.onEvent(callback), + subscribePorts: (callback, options) => rpcClient.preview.subscribePorts(callback, options), + }, }; } diff --git a/apps/web/src/hooks/useResizableWidth.ts b/apps/web/src/hooks/useResizableWidth.ts new file mode 100644 index 00000000000..3552c82d9dc --- /dev/null +++ b/apps/web/src/hooks/useResizableWidth.ts @@ -0,0 +1,176 @@ +import * as Schema from "effect/Schema"; +import { + type PointerEvent as ReactPointerEvent, + useCallback, + useEffect, + useRef, + useState, +} from "react"; + +import { getLocalStorageItem, setLocalStorageItem } from "./useLocalStorage"; + +const WidthSchema = Schema.Finite; + +export interface UseResizableWidthOptions { + /** localStorage key the persisted width is stored under. */ + readonly storageKey: string; + readonly defaultWidth: number; + readonly minWidth: number; + readonly maxWidth: number; + /** + * Which edge of the host element carries the drag handle: + * - "left" → panel grows leftward (right-anchored panels) + * - "right" → panel grows rightward (left-anchored panels) + */ + readonly edge: "left" | "right"; +} + +export interface ResizableWidthHandlers { + readonly onPointerDown: (event: ReactPointerEvent) => void; + readonly onPointerMove: (event: ReactPointerEvent) => void; + readonly onPointerUp: (event: ReactPointerEvent) => void; + readonly onPointerCancel: (event: ReactPointerEvent) => void; +} + +/** + * Width state for a side-anchored panel resized via a drag handle on the + * specified edge. Width is read from localStorage on mount and persisted on + * drag-end (not on every rAF tick — would otherwise be ~60 writes/sec). + * + * The hook updates an internal `width` state during drag (so the panel + * follows the cursor live) and only commits to localStorage when the user + * lifts the pointer. + */ +export function useResizableWidth(options: UseResizableWidthOptions): { + readonly width: number; + readonly handlers: ResizableWidthHandlers; +} { + const { storageKey, defaultWidth, minWidth, maxWidth, edge } = options; + + const clamp = useCallback( + (value: number): number => { + if (!Number.isFinite(value)) return defaultWidth; + return Math.max(minWidth, Math.min(maxWidth, value)); + }, + [defaultWidth, maxWidth, minWidth], + ); + + // No cross-tab subscription: panel width is per-window state. + const [width, setWidth] = useState(() => { + if (typeof window === "undefined") return defaultWidth; + try { + const stored = getLocalStorageItem(storageKey, WidthSchema); + return clamp(stored ?? defaultWidth); + } catch { + return defaultWidth; + } + }); + + // Re-clamp if min/max change at runtime (e.g. window resize narrows max). + useEffect(() => { + setWidth((current) => clamp(current)); + }, [clamp]); + + const dragStateRef = useRef<{ + pointerId: number; + startX: number; + startWidth: number; + pending: number; + rafId: number | null; + target: HTMLElement; + } | null>(null); + + const releasePointer = useCallback((pointerId: number) => { + const state = dragStateRef.current; + if (!state) return; + if (state.rafId !== null) { + cancelAnimationFrame(state.rafId); + } + try { + if (state.target.hasPointerCapture(pointerId)) { + state.target.releasePointerCapture(pointerId); + } + } catch { + // pointer may already be released; harmless. + } + document.body.style.removeProperty("cursor"); + document.body.style.removeProperty("user-select"); + dragStateRef.current = null; + }, []); + + const onPointerDown = useCallback( + (event: ReactPointerEvent) => { + if (event.button !== 0) return; + event.preventDefault(); + event.stopPropagation(); + const target = event.currentTarget; + try { + target.setPointerCapture(event.pointerId); + } catch { + return; + } + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; + dragStateRef.current = { + pointerId: event.pointerId, + startX: event.clientX, + startWidth: width, + pending: width, + rafId: null, + target, + }; + }, + [width], + ); + + const onPointerMove = useCallback( + (event: ReactPointerEvent) => { + const state = dragStateRef.current; + if (!state || state.pointerId !== event.pointerId) return; + event.preventDefault(); + const delta = edge === "left" ? state.startX - event.clientX : event.clientX - state.startX; + state.pending = clamp(state.startWidth + delta); + if (state.rafId !== null) return; + state.rafId = requestAnimationFrame(() => { + const active = dragStateRef.current; + if (!active) return; + active.rafId = null; + setWidth(active.pending); + }); + }, + [clamp, edge], + ); + + const onPointerUp = useCallback( + (event: ReactPointerEvent) => { + const state = dragStateRef.current; + if (!state || state.pointerId !== event.pointerId) return; + const finalWidth = clamp(state.pending); + releasePointer(event.pointerId); + // Commit once at drag-end to avoid 60Hz localStorage writes. + try { + setLocalStorageItem(storageKey, finalWidth, WidthSchema); + } catch { + // localStorage may be full / disabled; the in-memory state still wins. + } + setWidth(finalWidth); + }, + [clamp, releasePointer, storageKey], + ); + + const onPointerCancel = useCallback( + (event: ReactPointerEvent) => { + const state = dragStateRef.current; + if (!state || state.pointerId !== event.pointerId) return; + // Don't persist a cancelled drag; revert to the start width. + releasePointer(event.pointerId); + setWidth(state.startWidth); + }, + [releasePointer], + ); + + return { + width, + handlers: { onPointerDown, onPointerMove, onPointerUp, onPointerCancel }, + }; +} diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index dbf2450f794..f9603bdce6c 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -30,6 +30,8 @@ export interface ShortcutModifierStateLike { export interface ShortcutMatchContext { terminalFocus: boolean; terminalOpen: boolean; + previewFocus: boolean; + previewOpen: boolean; [key: string]: boolean; } @@ -112,6 +114,8 @@ function resolveContext(options: ShortcutMatchOptions | undefined): ShortcutMatc return { terminalFocus: false, terminalOpen: false, + previewFocus: false, + previewOpen: false, ...options?.context, }; } @@ -379,6 +383,30 @@ export function isDiffToggleShortcut( return matchesCommandShortcut(event, keybindings, "diff.toggle", options); } +export function isPreviewToggleShortcut( + event: ShortcutEventLike, + keybindings: ResolvedKeybindingsConfig, + options?: ShortcutMatchOptions, +): boolean { + return matchesCommandShortcut(event, keybindings, "preview.toggle", options); +} + +export function isPreviewRefreshShortcut( + event: ShortcutEventLike, + keybindings: ResolvedKeybindingsConfig, + options?: ShortcutMatchOptions, +): boolean { + return matchesCommandShortcut(event, keybindings, "preview.refresh", options); +} + +export function isPreviewFocusUrlShortcut( + event: ShortcutEventLike, + keybindings: ResolvedKeybindingsConfig, + options?: ShortcutMatchOptions, +): boolean { + return matchesCommandShortcut(event, keybindings, "preview.focusUrl", options); +} + export function isChatNewShortcut( event: ShortcutEventLike, keybindings: ResolvedKeybindingsConfig, diff --git a/apps/web/src/lib/favicon.ts b/apps/web/src/lib/favicon.ts new file mode 100644 index 00000000000..e5e94b2666f --- /dev/null +++ b/apps/web/src/lib/favicon.ts @@ -0,0 +1,20 @@ +/** + * Favicon helpers for the preview tab strip. + * + * Uses Google's s2 favicon endpoint (same approach as ami's tab strip). + * Callers should always render a `` fallback when the returned URL + * fails to load via an `onError` handler. + */ +const FAVICON_PROVIDER = "https://www.google.com/s2/favicons"; + +export function faviconUrlForOrigin(rawUrl: string | null | undefined, size = 32): string | null { + if (!rawUrl) return null; + try { + const url = new URL(rawUrl); + if (!url.host) return null; + if (url.protocol !== "http:" && url.protocol !== "https:") return null; + return `${FAVICON_PROVIDER}?domain=${encodeURIComponent(url.host)}&sz=${size}`; + } catch { + return null; + } +} diff --git a/apps/web/src/lib/previewFocus.ts b/apps/web/src/lib/previewFocus.ts new file mode 100644 index 00000000000..af7e76eaf03 --- /dev/null +++ b/apps/web/src/lib/previewFocus.ts @@ -0,0 +1,15 @@ +/** + * Returns true when the user's keyboard focus is somewhere inside the + * preview panel (URL bar, chrome buttons, or — once detected via Electron + * `` focus events — the embedded page). + * + * Used by the global keybinding handler to gate `preview.refresh` and + * `preview.focusUrl` to only fire while the preview owns focus. + */ +export function isPreviewFocused(): boolean { + const activeElement = document.activeElement; + if (!(activeElement instanceof HTMLElement)) return false; + if (!activeElement.isConnected) return false; + if (activeElement.tagName.toLowerCase() === "webview") return true; + return activeElement.closest("[data-preview-panel-mode]") !== null; +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index e92cad9aa3d..b4aff4f2cac 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -18,6 +18,10 @@ import { getRouter } from "./router"; import { APP_DISPLAY_NAME } from "./branding"; import { syncDocumentWindowControlsOverlayClass } from "./lib/windowControlsOverlay"; +if (import.meta.env.DEV) { + void import("react-grab"); +} + // Electron loads the app from a file-backed shell, so hash history avoids path resolution issues. const history = isElectron ? createHashHistory() : createBrowserHistory(); diff --git a/apps/web/src/previewStateStore.test.ts b/apps/web/src/previewStateStore.test.ts new file mode 100644 index 00000000000..39dc7adee16 --- /dev/null +++ b/apps/web/src/previewStateStore.test.ts @@ -0,0 +1,227 @@ +import { scopeThreadRef } from "@t3tools/client-runtime"; +import { type EnvironmentId, type PreviewSessionSnapshot, ThreadId } from "@t3tools/contracts"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { __testing, selectThreadPreviewState, usePreviewStateStore } from "./previewStateStore"; + +const environmentId = "env-1" as EnvironmentId; +const ref = scopeThreadRef(environmentId, ThreadId.make("thread-1")); + +const makeSnapshot = (overrides: Partial = {}): PreviewSessionSnapshot => ({ + threadId: "thread-1", + tabId: "tab_a", + navStatus: { _tag: "Loading", url: "http://localhost:5173/", title: "" }, + canGoBack: false, + canGoForward: false, + updatedAt: "2026-01-01T00:00:00.000Z", + ...overrides, +}); + +beforeEach(() => { + usePreviewStateStore.setState({ byThreadKey: {} }); +}); + +describe("previewStateStore (single-tab)", () => { + it("opened event seeds the snapshot and remembers the URL", () => { + const snapshot = makeSnapshot(); + usePreviewStateStore.getState().applyServerEvent(ref, { + type: "opened", + threadId: "thread-1", + tabId: snapshot.tabId, + createdAt: snapshot.updatedAt, + snapshot, + }); + const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); + expect(state.snapshot?.tabId).toBe(snapshot.tabId); + expect(state.recentlySeenUrls).toContain("http://localhost:5173/"); + }); + + it("a second `opened` for a different tab replaces the rendered snapshot", () => { + const a = makeSnapshot({ tabId: "tab_a" }); + const b = makeSnapshot({ tabId: "tab_b" }); + const store = usePreviewStateStore.getState(); + store.applyServerEvent(ref, { + type: "opened", + threadId: "thread-1", + tabId: a.tabId, + createdAt: a.updatedAt, + snapshot: a, + }); + store.applyServerEvent(ref, { + type: "opened", + threadId: "thread-1", + tabId: b.tabId, + createdAt: b.updatedAt, + snapshot: b, + }); + const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); + expect(state.snapshot?.tabId).toBe(b.tabId); + }); + + it("navigated event updates the snapshot URL", () => { + const snapshot = makeSnapshot(); + const store = usePreviewStateStore.getState(); + store.applyServerEvent(ref, { + type: "opened", + threadId: "thread-1", + tabId: snapshot.tabId, + createdAt: snapshot.updatedAt, + snapshot, + }); + store.applyServerEvent(ref, { + type: "navigated", + threadId: "thread-1", + tabId: snapshot.tabId, + createdAt: "2026-01-01T00:00:01.000Z", + snapshot: { + ...snapshot, + navStatus: { _tag: "Success", url: "http://localhost:5173/about", title: "About" }, + }, + }); + const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); + expect(state.snapshot?.navStatus._tag).toBe("Success"); + if (state.snapshot?.navStatus._tag === "Success") { + expect(state.snapshot.navStatus.url).toBe("http://localhost:5173/about"); + } + }); + + it("failed event flips the snapshot to LoadFailed when tabId matches", () => { + const snapshot = makeSnapshot(); + const store = usePreviewStateStore.getState(); + store.applyServerEvent(ref, { + type: "opened", + threadId: "thread-1", + tabId: snapshot.tabId, + createdAt: snapshot.updatedAt, + snapshot, + }); + store.applyServerEvent(ref, { + type: "failed", + threadId: "thread-1", + tabId: snapshot.tabId, + createdAt: "2026-01-01T00:00:01.000Z", + url: "http://localhost:5173/", + title: "", + code: -105, + description: "ERR_NAME_NOT_RESOLVED", + }); + const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); + expect(state.snapshot?.navStatus._tag).toBe("LoadFailed"); + }); + + it("failed event for a non-active tab is ignored", () => { + const snapshot = makeSnapshot({ tabId: "tab_a" }); + const store = usePreviewStateStore.getState(); + store.applyServerEvent(ref, { + type: "opened", + threadId: "thread-1", + tabId: snapshot.tabId, + createdAt: snapshot.updatedAt, + snapshot, + }); + store.applyServerEvent(ref, { + type: "failed", + threadId: "thread-1", + tabId: "tab_b", + createdAt: "2026-01-01T00:00:01.000Z", + url: "http://localhost:9999/", + title: "", + code: -105, + description: "ERR_NAME_NOT_RESOLVED", + }); + const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); + expect(state.snapshot?.navStatus._tag).toBe("Loading"); + }); + + it("closed event clears snapshot but retains recently-seen URLs", () => { + const snapshot = makeSnapshot(); + const store = usePreviewStateStore.getState(); + store.applyServerEvent(ref, { + type: "opened", + threadId: "thread-1", + tabId: snapshot.tabId, + createdAt: snapshot.updatedAt, + snapshot, + }); + store.applyServerEvent(ref, { + type: "closed", + threadId: "thread-1", + tabId: snapshot.tabId, + createdAt: "2026-01-01T00:00:01.000Z", + }); + const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); + expect(state.snapshot).toBeNull(); + expect(state.recentlySeenUrls).toContain("http://localhost:5173/"); + }); + + it("closed event for a different tab is a no-op", () => { + const snapshot = makeSnapshot({ tabId: "tab_a" }); + const store = usePreviewStateStore.getState(); + store.applyServerEvent(ref, { + type: "opened", + threadId: "thread-1", + tabId: snapshot.tabId, + createdAt: snapshot.updatedAt, + snapshot, + }); + store.applyServerEvent(ref, { + type: "closed", + threadId: "thread-1", + tabId: "tab_b", + createdAt: "2026-01-01T00:00:01.000Z", + }); + const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); + expect(state.snapshot?.tabId).toBe(snapshot.tabId); + }); + + it("desktopOverlay updates independently of snapshot", () => { + const snapshot = makeSnapshot(); + const store = usePreviewStateStore.getState(); + store.applyServerEvent(ref, { + type: "opened", + threadId: "thread-1", + tabId: snapshot.tabId, + createdAt: snapshot.updatedAt, + snapshot, + }); + store.applyDesktopState(ref, { + canGoBack: true, + canGoForward: false, + loading: false, + zoomFactor: 1, + }); + const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); + expect(state.desktopOverlay?.canGoBack).toBe(true); + expect(state.snapshot?.canGoBack).toBe(false); + }); + + it("applyServerSnapshot null clears snapshot for a thread that had one", () => { + const snapshot = makeSnapshot(); + const store = usePreviewStateStore.getState(); + store.applyServerSnapshot(ref, snapshot); + store.applyServerSnapshot(ref, null); + const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); + expect(state.snapshot).toBeNull(); + }); + + it("rememberUrl dedupes and caps at limit", () => { + const store = usePreviewStateStore.getState(); + for (let i = 0; i < __testing.RECENT_URL_LIMIT + 5; i += 1) { + store.rememberUrl(ref, `http://localhost:${5000 + i}/`); + } + const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); + expect(state.recentlySeenUrls.length).toBeLessThanOrEqual(__testing.RECENT_URL_LIMIT); + expect(state.recentlySeenUrls[0]).toBe( + `http://localhost:${5000 + __testing.RECENT_URL_LIMIT + 4}/`, + ); + }); + + it("removeThread strips the entry", () => { + const snapshot = makeSnapshot(); + const store = usePreviewStateStore.getState(); + store.applyServerSnapshot(ref, snapshot); + store.removeThread(ref); + const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); + expect(state).toEqual(__testing.EMPTY_THREAD_PREVIEW_STATE); + }); +}); diff --git a/apps/web/src/previewStateStore.ts b/apps/web/src/previewStateStore.ts new file mode 100644 index 00000000000..90719efd4c3 --- /dev/null +++ b/apps/web/src/previewStateStore.ts @@ -0,0 +1,188 @@ +/** + * Per-thread preview UI state. + * + * Single-tab model: one snapshot per thread, mirrored two ways: + * - `snapshot` is the server-authoritative URL/title/load-status, replayed + * on WS reconnect so the panel survives backend restarts. + * - `desktopOverlay` is low-latency state from the local + * (canGoBack/canGoForward/visible/zoom/loading), used by the chrome row's + * button enablement. + * + * The schema-level `tabId` exists because the server still keys sessions by + * `(threadId, tabId)`; the client just always tracks one and ignores the rest. + */ +import { scopedThreadKey } from "@t3tools/client-runtime"; +import { + type PreviewEvent, + type PreviewSessionSnapshot, + type ScopedThreadRef, +} from "@t3tools/contracts"; +import { create } from "zustand"; + +import { PREVIEW_RECENT_URL_LIMIT } from "./components/preview/previewConstants"; + +export interface DesktopPreviewOverlay { + canGoBack: boolean; + canGoForward: boolean; + loading: boolean; + zoomFactor: number; +} + +export interface ThreadPreviewState { + snapshot: PreviewSessionSnapshot | null; + /** Bridge state takes precedence over `snapshot` for nav button enablement. */ + desktopOverlay: DesktopPreviewOverlay | null; + /** Recently-visited URLs surfaced in the empty state. */ + recentlySeenUrls: string[]; +} + +const EMPTY_THREAD_PREVIEW_STATE: ThreadPreviewState = Object.freeze({ + snapshot: null, + desktopOverlay: null, + recentlySeenUrls: [] as string[], +}); + +interface PreviewStateStoreState { + byThreadKey: Record; + applyServerEvent: (ref: ScopedThreadRef, event: PreviewEvent) => void; + applyServerSnapshot: (ref: ScopedThreadRef, snapshot: PreviewSessionSnapshot | null) => void; + applyDesktopState: (ref: ScopedThreadRef, overlay: DesktopPreviewOverlay | null) => void; + rememberUrl: (ref: ScopedThreadRef, url: string) => void; + removeThread: (ref: ScopedThreadRef) => void; +} + +const ensureState = ( + byThreadKey: Record, + threadKey: string, +): ThreadPreviewState => byThreadKey[threadKey] ?? EMPTY_THREAD_PREVIEW_STATE; + +const updateThread = ( + state: PreviewStateStoreState, + threadKey: string, + updater: (current: ThreadPreviewState) => ThreadPreviewState, +): PreviewStateStoreState["byThreadKey"] => { + const current = ensureState(state.byThreadKey, threadKey); + const next = updater(current); + if (next === current) return state.byThreadKey; + return { ...state.byThreadKey, [threadKey]: next }; +}; + +const removeThreadKey = ( + byThreadKey: Record, + threadKey: string, +): Record => { + if (!(threadKey in byThreadKey)) return byThreadKey; + const { [threadKey]: _removed, ...rest } = byThreadKey; + return rest; +}; + +const dedupeRecentUrls = (existing: string[], url: string): string[] => { + const next = [url, ...existing.filter((entry) => entry !== url)]; + return next.slice(0, PREVIEW_RECENT_URL_LIMIT); +}; + +export const usePreviewStateStore = create()((set) => ({ + byThreadKey: {}, + applyServerEvent: (ref, event) => + set((state) => { + const threadKey = scopedThreadKey(ref); + let nextByThread = state.byThreadKey; + switch (event.type) { + case "opened": + case "navigated": + nextByThread = updateThread(state, threadKey, (current) => { + const snapshot = event.snapshot; + const recentlySeenUrls = + snapshot.navStatus._tag === "Idle" + ? current.recentlySeenUrls + : dedupeRecentUrls(current.recentlySeenUrls, snapshot.navStatus.url); + return { ...current, snapshot, recentlySeenUrls }; + }); + break; + case "failed": + nextByThread = updateThread(state, threadKey, (current) => { + if (!current.snapshot || current.snapshot.tabId !== event.tabId) return current; + return { + ...current, + snapshot: { + ...current.snapshot, + navStatus: { + _tag: "LoadFailed", + url: event.url, + title: event.title, + code: event.code, + description: event.description, + }, + updatedAt: event.createdAt, + }, + }; + }); + break; + case "closed": + nextByThread = updateThread(state, threadKey, (current) => { + // Only clear if the closed tab is the one we were tracking; the + // server may have multiple tabs per thread but we only render one. + if (current.snapshot && current.snapshot.tabId !== event.tabId) return current; + return { ...current, snapshot: null, desktopOverlay: null }; + }); + break; + } + return { byThreadKey: nextByThread }; + }), + applyServerSnapshot: (ref, snapshot) => + set((state) => { + const threadKey = scopedThreadKey(ref); + const nextByThread = updateThread(state, threadKey, (current) => { + if (!snapshot && current.snapshot === null) return current; + const recentlySeenUrls = + snapshot && snapshot.navStatus._tag !== "Idle" + ? dedupeRecentUrls(current.recentlySeenUrls, snapshot.navStatus.url) + : current.recentlySeenUrls; + return { ...current, snapshot, recentlySeenUrls }; + }); + return { byThreadKey: nextByThread }; + }), + applyDesktopState: (ref, overlay) => + set((state) => { + const threadKey = scopedThreadKey(ref); + const nextByThread = updateThread(state, threadKey, (current) => ({ + ...current, + desktopOverlay: overlay, + })); + return { byThreadKey: nextByThread }; + }), + rememberUrl: (ref, url) => + set((state) => { + if (url.trim().length === 0) return state; + const threadKey = scopedThreadKey(ref); + const nextByThread = updateThread(state, threadKey, (current) => ({ + ...current, + recentlySeenUrls: dedupeRecentUrls(current.recentlySeenUrls, url), + })); + return { byThreadKey: nextByThread }; + }), + removeThread: (ref) => + set((state) => { + const threadKey = scopedThreadKey(ref); + if (!(threadKey in state.byThreadKey)) return state; + return { byThreadKey: removeThreadKey(state.byThreadKey, threadKey) }; + }), +})); + +export function selectThreadPreviewState( + byThreadKey: Record, + ref: ScopedThreadRef | null | undefined, +): ThreadPreviewState { + if (!ref) return EMPTY_THREAD_PREVIEW_STATE; + return ensureState(byThreadKey, scopedThreadKey(ref)); +} + +export function isPreviewSupportedInRuntime(): boolean { + if (typeof window === "undefined") return false; + return Boolean(window.desktopBridge?.preview); +} + +export const __testing = { + EMPTY_THREAD_PREVIEW_STATE, + RECENT_URL_LIMIT: PREVIEW_RECENT_URL_LIMIT, +}; diff --git a/apps/web/src/rightPanelStore.test.ts b/apps/web/src/rightPanelStore.test.ts new file mode 100644 index 00000000000..9074ff49d02 --- /dev/null +++ b/apps/web/src/rightPanelStore.test.ts @@ -0,0 +1,70 @@ +import { scopeThreadRef } from "@t3tools/client-runtime"; +import { type EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { + selectActiveRightPanel, + selectActiveRightPanelKindWithUrl, + useRightPanelStore, +} from "./rightPanelStore"; + +const refA = scopeThreadRef("env-1" as EnvironmentId, ThreadId.make("thread-A")); +const refB = scopeThreadRef("env-1" as EnvironmentId, ThreadId.make("thread-B")); + +beforeEach(() => { + useRightPanelStore.setState({ byThreadKey: {} }); +}); + +describe("rightPanelStore", () => { + it("open sets the active panel for a thread", () => { + useRightPanelStore.getState().open(refA, "preview"); + expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("preview"); + expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refB)).toBeNull(); + }); + + it("opening a different kind replaces the previous one", () => { + useRightPanelStore.getState().open(refA, "plan"); + useRightPanelStore.getState().open(refA, "preview"); + expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("preview"); + }); + + it("close clears the active panel", () => { + useRightPanelStore.getState().open(refA, "plan"); + useRightPanelStore.getState().close(refA); + expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBeNull(); + }); + + it("toggle opens then closes the same kind", () => { + useRightPanelStore.getState().toggle(refA, "preview"); + expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("preview"); + useRightPanelStore.getState().toggle(refA, "preview"); + expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBeNull(); + }); + + it("toggle to a different kind switches active", () => { + useRightPanelStore.getState().toggle(refA, "preview"); + useRightPanelStore.getState().toggle(refA, "plan"); + expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("plan"); + }); + + it("?diff=1 always wins over persisted state", () => { + useRightPanelStore.getState().open(refA, "preview"); + expect( + selectActiveRightPanelKindWithUrl(useRightPanelStore.getState().byThreadKey, refA, true), + ).toBe("diff"); + expect( + selectActiveRightPanelKindWithUrl(useRightPanelStore.getState().byThreadKey, refA, false), + ).toBe("preview"); + }); + + it("removeThread clears persisted state", () => { + useRightPanelStore.getState().open(refA, "plan"); + useRightPanelStore.getState().removeThread(refA); + expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBeNull(); + }); + + it("close on never-opened thread is a no-op", () => { + useRightPanelStore.getState().close(refA); + expect(useRightPanelStore.getState().byThreadKey).toEqual({}); + }); +}); diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts new file mode 100644 index 00000000000..b8bba44fdcc --- /dev/null +++ b/apps/web/src/rightPanelStore.ts @@ -0,0 +1,114 @@ +/** + * Per-thread arbiter for the right-side panel. + * + * Three tenants share the same slot: the plan sidebar, the diff panel, and + * the preview panel. Only one is open at a time per thread; the choice is + * remembered across thread switches. + * + * The diff panel still uses `?diff=1` as URL truth for deep-linking — when + * that param is present the diff tenant wins regardless of what's persisted + * here. See `selectActiveRightPanelKindWithUrl` for the resolution rule. + */ +import { scopedThreadKey } from "@t3tools/client-runtime"; +import type { ScopedThreadRef } from "@t3tools/contracts"; +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; + +import { resolveStorage } from "./lib/storage"; + +export const RIGHT_PANEL_KINDS = ["plan", "diff", "preview"] as const; +export type RightPanelKind = (typeof RIGHT_PANEL_KINDS)[number]; + +const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v1"; + +interface ThreadRightPanelState { + active: RightPanelKind | null; +} + +interface RightPanelStoreState { + byThreadKey: Record; + open: (ref: ScopedThreadRef, kind: RightPanelKind) => void; + close: (ref: ScopedThreadRef) => void; + toggle: (ref: ScopedThreadRef, kind: RightPanelKind) => void; + removeThread: (ref: ScopedThreadRef) => void; +} + +const updateThread = ( + byThreadKey: Record, + threadKey: string, + next: ThreadRightPanelState, +): Record => { + const current = byThreadKey[threadKey]; + if (current && current.active === next.active) return byThreadKey; + if (next.active === null) { + if (!current) return byThreadKey; + const { [threadKey]: _removed, ...rest } = byThreadKey; + return rest; + } + return { ...byThreadKey, [threadKey]: next }; +}; + +export const useRightPanelStore = create()( + persist( + (set) => ({ + byThreadKey: {}, + open: (ref, kind) => + set((state) => ({ + byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), { active: kind }), + })), + close: (ref) => + set((state) => ({ + byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), { active: null }), + })), + toggle: (ref, kind) => + set((state) => { + const threadKey = scopedThreadKey(ref); + const current = state.byThreadKey[threadKey]?.active ?? null; + const next: RightPanelKind | null = current === kind ? null : kind; + return { + byThreadKey: updateThread(state.byThreadKey, threadKey, { active: next }), + }; + }), + removeThread: (ref) => + set((state) => { + const threadKey = scopedThreadKey(ref); + if (!(threadKey in state.byThreadKey)) return state; + const { [threadKey]: _removed, ...rest } = state.byThreadKey; + return { byThreadKey: rest }; + }), + }), + { + name: RIGHT_PANEL_STORAGE_KEY, + version: 1, + storage: createJSONStorage(() => + resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined), + ), + partialize: (state) => ({ byThreadKey: state.byThreadKey }), + }, + ), +); + +export function selectActiveRightPanel( + byThreadKey: Record, + ref: ScopedThreadRef | null | undefined, +): RightPanelKind | null { + if (!ref) return null; + return byThreadKey[scopedThreadKey(ref)]?.active ?? null; +} + +/** + * Resolves the active right panel taking the `?diff=1` URL truth into + * account. When `diff=1` the diff panel always wins. + * + * Prefer using `useSyncDiffSearchToRightPanel` (in ChatView) to mirror the + * URL into the store and consume `selectActiveRightPanel` directly — this + * helper exists for callers that don't want to install the sync effect. + */ +export function selectActiveRightPanelKindWithUrl( + byThreadKey: Record, + ref: ScopedThreadRef | null | undefined, + diffSearchActive: boolean, +): RightPanelKind | null { + if (diffSearchActive) return "diff"; + return selectActiveRightPanel(byThreadKey, ref); +} diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index 3ccb2e7f734..f28c80f0128 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -2,16 +2,21 @@ import { Outlet, createFileRoute, redirect } from "@tanstack/react-router"; import { useEffect } from "react"; import { useCommandPaletteStore } from "../commandPaletteStore"; +import { dispatchPreviewAction } from "../components/preview/previewActionBus"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { startNewLocalThreadFromContext, startNewThreadFromContext, } from "../lib/chatThreadActions"; +import { isPreviewFocused } from "../lib/previewFocus"; import { isTerminalFocused } from "../lib/terminalFocus"; import { resolveShortcutCommand } from "../keybindings"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; +import { isPreviewSupportedInRuntime } from "../previewStateStore"; +import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore"; import { useThreadSelectionStore } from "../threadSelectionStore"; import { resolveSidebarNewThreadEnvMode } from "~/components/Sidebar.logic"; +import { stackedThreadToast, toastManager } from "~/components/ui/toast"; import { useSettings } from "~/hooks/useSettings"; import { useServerKeybindings } from "~/rpc/serverState"; @@ -26,6 +31,14 @@ function ChatRouteGlobalShortcuts() { ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen : false, ); + // The `previewOpen` shortcut-context flag here uses the store-only value; + // the URL-aware arbitration lives inside ChatView's `onTogglePreview`, + // which we invoke via the action bus to avoid duplicating the rule. + const previewOpen = useRightPanelStore((state) => + routeThreadRef + ? selectActiveRightPanel(state.byThreadKey, routeThreadRef) === "preview" + : false, + ); const appSettings = useSettings(); useEffect(() => { @@ -35,6 +48,8 @@ function ChatRouteGlobalShortcuts() { context: { terminalFocus: isTerminalFocused(), terminalOpen, + previewFocus: isPreviewFocused(), + previewOpen, }, }); @@ -75,6 +90,50 @@ function ChatRouteGlobalShortcuts() { }), handleNewThread, }); + return; + } + + if (command === "preview.toggle") { + event.preventDefault(); + event.stopPropagation(); + if (!routeThreadRef) return; + if (!isPreviewSupportedInRuntime()) { + toastManager.add( + stackedThreadToast({ + type: "info", + title: "Preview is desktop-only", + description: "Open T3 Code in the desktop app to use the in-app preview.", + }), + ); + return; + } + dispatchPreviewAction("toggle-panel"); + return; + } + + // The remaining preview commands only fire when the panel is the + // currently-focused tenant. The `when: previewFocus` rule already + // gates this, but defend against the keybinding being misconfigured. + if ( + command === "preview.refresh" || + command === "preview.focusUrl" || + command === "preview.zoomIn" || + command === "preview.zoomOut" || + command === "preview.resetZoom" + ) { + event.preventDefault(); + event.stopPropagation(); + const action = + command === "preview.refresh" + ? "refresh" + : command === "preview.focusUrl" + ? "focus-url" + : command === "preview.zoomIn" + ? "zoom-in" + : command === "preview.zoomOut" + ? "zoom-out" + : "reset-zoom"; + dispatchPreviewAction(action); } }; @@ -89,6 +148,8 @@ function ChatRouteGlobalShortcuts() { handleNewThread, keybindings, defaultProjectRef, + previewOpen, + routeThreadRef, selectedThreadKeysSize, terminalOpen, appSettings.defaultThreadEnvMode, diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index 67652cc957d..254aa92c6a0 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -23,6 +23,12 @@ See the full schema for more details: [`packages/contracts/src/keybindings.ts`]( { "key": "mod+d", "command": "terminal.split", "when": "terminalFocus" }, { "key": "mod+n", "command": "terminal.new", "when": "terminalFocus" }, { "key": "mod+w", "command": "terminal.close", "when": "terminalFocus" }, + { "key": "mod+shift+j", "command": "preview.toggle" }, + { "key": "mod+r", "command": "preview.refresh", "when": "previewFocus" }, + { "key": "mod+l", "command": "preview.focusUrl", "when": "previewFocus" }, + { "key": "mod+=", "command": "preview.zoomIn", "when": "previewFocus" }, + { "key": "mod+-", "command": "preview.zoomOut", "when": "previewFocus" }, + { "key": "mod+0", "command": "preview.resetZoom", "when": "previewFocus" }, { "key": "mod+k", "command": "commandPalette.toggle", "when": "!terminalFocus" }, { "key": "mod+n", "command": "chat.new", "when": "!terminalFocus" }, { "key": "mod+shift+o", "command": "chat.new", "when": "!terminalFocus" }, @@ -51,6 +57,12 @@ Invalid rules are ignored. Invalid config files are ignored. Warnings are logged - `terminal.split`: split terminal (in focused terminal context by default) - `terminal.new`: create new terminal (in focused terminal context by default) - `terminal.close`: close/kill the focused terminal (in focused terminal context by default) +- `preview.toggle`: open/close the in-app browser preview panel (desktop app only) +- `preview.refresh`: reload the active preview tab (in focused preview context by default) +- `preview.focusUrl`: focus the URL input of the preview panel (in focused preview context by default) +- `preview.zoomIn`: zoom the preview viewport in one step (in focused preview context by default) +- `preview.zoomOut`: zoom the preview viewport out one step (in focused preview context by default) +- `preview.resetZoom`: reset the preview zoom to 100% (in focused preview context by default) - `commandPalette.toggle`: open or close the global command palette - `chat.new`: create a new chat thread preserving the active thread's branch/worktree state - `chat.newLocal`: create a new chat thread for the active project in a new environment (local/worktree determined by app settings (default `local`)) @@ -80,6 +92,8 @@ Currently available context keys: - `terminalFocus` - `terminalOpen` +- `previewFocus` +- `previewOpen` Supported operators: diff --git a/packages/client-runtime/src/wsRpcClient.ts b/packages/client-runtime/src/wsRpcClient.ts index c1c683616b2..733c65ee13e 100644 --- a/packages/client-runtime/src/wsRpcClient.ts +++ b/packages/client-runtime/src/wsRpcClient.ts @@ -79,6 +79,16 @@ export interface WsRpcClient { readonly onEvent: RpcStreamMethod; readonly onMetadata: RpcStreamMethod; }; + readonly preview: { + readonly open: RpcUnaryMethod; + readonly navigate: RpcUnaryMethod; + readonly refresh: RpcUnaryMethod; + readonly close: RpcUnaryMethod; + readonly list: RpcUnaryMethod; + readonly reportStatus: RpcUnaryMethod; + readonly onEvent: RpcStreamMethod; + readonly subscribePorts: RpcStreamMethod; + }; readonly projects: { readonly searchEntries: RpcUnaryMethod; readonly writeFile: RpcUnaryMethod; @@ -212,6 +222,27 @@ export function createWsRpcClient( subscriptionOptions(options, WS_METHODS.subscribeTerminalMetadata), ), }, + preview: { + open: (input) => transport.request((client) => client[WS_METHODS.previewOpen](input)), + navigate: (input) => transport.request((client) => client[WS_METHODS.previewNavigate](input)), + refresh: (input) => transport.request((client) => client[WS_METHODS.previewRefresh](input)), + close: (input) => transport.request((client) => client[WS_METHODS.previewClose](input)), + list: (input) => transport.request((client) => client[WS_METHODS.previewList](input)), + reportStatus: (input) => + transport.request((client) => client[WS_METHODS.previewReportStatus](input)), + onEvent: (listener, options) => + transport.subscribe( + (client) => client[WS_METHODS.subscribePreviewEvents]({}), + listener, + options, + ), + subscribePorts: (listener, options) => + transport.subscribe( + (client) => client[WS_METHODS.subscribeDiscoveredLocalServers]({}), + listener, + options, + ), + }, projects: { searchEntries: (input) => transport.request((client) => client[WS_METHODS.projectsSearchEntries](input)), diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 163d5e236cd..1cb46f6c79d 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -22,4 +22,5 @@ export * from "./editor.ts"; export * from "./project.ts"; export * from "./filesystem.ts"; export * from "./review.ts"; +export * from "./preview.ts"; export * from "./rpc.ts"; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 1d8656ddf4f..d003e868de4 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -54,6 +54,18 @@ import type { } from "./terminal.ts"; import type { ServerRemoveKeybindingInput, ServerUpsertKeybindingInput } from "./server.ts"; import * as Schema from "effect/Schema"; +import type { + DiscoveredLocalServerList, + PreviewCloseInput, + PreviewEvent, + PreviewListInput, + PreviewListResult, + PreviewNavigateInput, + PreviewOpenInput, + PreviewRefreshInput, + PreviewReportStatusInput, + PreviewSessionSnapshot, +} from "./preview.ts"; import type { ClientOrchestrationCommand, OrchestrationGetFullThreadDiffInput, @@ -402,6 +414,33 @@ export const DesktopCloudAuthFetchResultSchema = Schema.Struct({ }); export type DesktopCloudAuthFetchResult = typeof DesktopCloudAuthFetchResultSchema.Type; +/** + * Renderer-facing snapshot of a desktop preview tab. Mirrors the main-process + * PreviewTabState shape but uses serialisable primitives only. + */ +export type DesktopPreviewNavStatus = + | { 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 DesktopPreviewTabState { + tabId: string; + webContentsId: number | null; + navStatus: DesktopPreviewNavStatus; + canGoBack: boolean; + canGoForward: boolean; + /** Current zoom factor (1.0 = 100%). */ + zoomFactor: number; + updatedAt: string; +} + export interface DesktopBridge { getAppBranding: () => DesktopAppBranding | null; getLocalEnvironmentBootstrap: () => DesktopEnvironmentBootstrap | null; @@ -460,6 +499,34 @@ export interface DesktopBridge { downloadUpdate: () => Promise; installUpdate: () => Promise; onUpdateState: (listener: (state: DesktopUpdateState) => void) => () => void; + /** + * Desktop-only preview surface. Present iff the renderer is hosted by the + * Electron desktop build; web builds have `preview === undefined`. + */ + preview?: DesktopPreviewBridge; +} + +export interface DesktopPreviewBridge { + createTab: (tabId: string) => Promise; + closeTab: (tabId: string) => Promise; + registerWebview: (tabId: string, webContentsId: number) => Promise; + navigate: (tabId: string, url: string) => Promise; + goBack: (tabId: string) => Promise; + goForward: (tabId: string) => Promise; + refresh: (tabId: string) => Promise; + zoomIn: (tabId: string) => Promise; + zoomOut: (tabId: string) => Promise; + resetZoom: (tabId: string) => Promise; + /** Reload bypassing the HTTP cache. */ + hardReload: (tabId: string) => Promise; + /** Open the guest webview's DevTools (detached). */ + openDevTools: (tabId: string) => Promise; + /** Drop cookies + storage data for the preview partition (all tabs). */ + clearCookies: () => Promise; + /** Drop the HTTP cache for the preview partition (all tabs). */ + clearCache: () => Promise; + getBrowserPartition: () => Promise; + onStateChange: (listener: (tabId: string, state: DesktopPreviewTabState) => void) => () => void; } /** @@ -619,4 +686,17 @@ export interface EnvironmentApi { }, ) => () => void; }; + preview: { + open: (input: typeof PreviewOpenInput.Encoded) => Promise; + navigate: (input: typeof PreviewNavigateInput.Encoded) => Promise; + refresh: (input: typeof PreviewRefreshInput.Encoded) => Promise; + close: (input: typeof PreviewCloseInput.Encoded) => Promise; + list: (input: typeof PreviewListInput.Encoded) => Promise; + reportStatus: (input: typeof PreviewReportStatusInput.Encoded) => Promise; + onEvent: (callback: (event: PreviewEvent) => void) => () => void; + subscribePorts: ( + callback: (servers: DiscoveredLocalServerList) => void, + options?: { onResubscribe?: () => void }, + ) => () => void; + }; } diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 303d5c71c9c..4399d89d368 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -53,6 +53,12 @@ const STATIC_KEYBINDING_COMMANDS = [ "terminal.new", "terminal.close", "diff.toggle", + "preview.toggle", + "preview.refresh", + "preview.focusUrl", + "preview.zoomIn", + "preview.zoomOut", + "preview.resetZoom", "commandPalette.toggle", "chat.new", "chat.newLocal", diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 218d0de7437..46d51da371f 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -194,6 +194,17 @@ export const ProjectScript = Schema.Struct({ command: TrimmedNonEmptyString, icon: ProjectScriptIcon, runOnWorktreeCreate: Schema.Boolean, + /** + * URL to open in the in-app browser preview when this script runs (or + * when the user explicitly requests a preview). Optional; only honored on + * the desktop build. + */ + previewUrl: Schema.optional(TrimmedNonEmptyString), + /** + * When true, automatically open the preview panel pointed at `previewUrl` + * the moment this script starts. Ignored without `previewUrl` or on web. + */ + autoOpenPreview: Schema.optional(Schema.Boolean), }); export type ProjectScript = typeof ProjectScript.Type; diff --git a/packages/contracts/src/preview.test.ts b/packages/contracts/src/preview.test.ts new file mode 100644 index 00000000000..d1f108ab4e9 --- /dev/null +++ b/packages/contracts/src/preview.test.ts @@ -0,0 +1,162 @@ +import { Schema } from "effect"; +import { describe, expect, it } from "vitest"; + +import { + DiscoveredLocalServer, + PreviewEvent, + PreviewNavStatus, + PreviewSessionSnapshot, +} from "./preview.ts"; + +const decodePreviewEvent = Schema.decodeUnknownSync(PreviewEvent); +const decodeSnapshot = Schema.decodeUnknownSync(PreviewSessionSnapshot); +const decodeNavStatus = Schema.decodeUnknownSync(PreviewNavStatus); +const decodeServer = Schema.decodeUnknownSync(DiscoveredLocalServer); + +describe("PreviewNavStatus", () => { + it("decodes Idle", () => { + expect(decodeNavStatus({ _tag: "Idle" })).toEqual({ _tag: "Idle" }); + }); + + it("decodes Loading with title", () => { + expect(decodeNavStatus({ _tag: "Loading", url: "http://localhost:5173/", title: "" })).toEqual({ + _tag: "Loading", + url: "http://localhost:5173/", + title: "", + }); + }); + + it("decodes LoadFailed with code/description", () => { + expect( + decodeNavStatus({ + _tag: "LoadFailed", + url: "https://example.com/", + title: "Example", + code: -105, + description: "ERR_NAME_NOT_RESOLVED", + }), + ).toEqual({ + _tag: "LoadFailed", + url: "https://example.com/", + title: "Example", + code: -105, + description: "ERR_NAME_NOT_RESOLVED", + }); + }); + + it("rejects empty url", () => { + expect(() => decodeNavStatus({ _tag: "Loading", url: "", title: "" })).toThrow(); + }); +}); + +describe("PreviewSessionSnapshot", () => { + it("round-trips a Success snapshot", () => { + const snapshot = decodeSnapshot({ + threadId: "thread-1", + tabId: "preview-thread-1", + navStatus: { + _tag: "Success", + url: "http://localhost:5173/", + title: "Vite App", + }, + canGoBack: false, + canGoForward: false, + updatedAt: "2026-01-01T00:00:00.000Z", + }); + expect(snapshot.tabId).toBe("preview-thread-1"); + expect(snapshot.navStatus._tag).toBe("Success"); + }); +}); + +describe("PreviewEvent", () => { + it("decodes opened", () => { + const event = decodePreviewEvent({ + type: "opened", + threadId: "t", + tabId: "preview-t", + createdAt: "2026-01-01T00:00:00.000Z", + snapshot: { + threadId: "t", + tabId: "preview-t", + navStatus: { _tag: "Idle" }, + canGoBack: false, + canGoForward: false, + updatedAt: "2026-01-01T00:00:00.000Z", + }, + }); + expect(event.type).toBe("opened"); + }); + + it("decodes failed with code/description", () => { + const event = decodePreviewEvent({ + type: "failed", + threadId: "t", + tabId: "preview-t", + createdAt: "2026-01-01T00:00:00.000Z", + url: "https://example.com/", + title: "", + code: -105, + description: "ERR_NAME_NOT_RESOLVED", + }); + expect(event.type).toBe("failed"); + if (event.type === "failed") { + expect(event.code).toBe(-105); + } + }); + + it("decodes closed without snapshot", () => { + const event = decodePreviewEvent({ + type: "closed", + threadId: "t", + tabId: "preview-t", + createdAt: "2026-01-01T00:00:00.000Z", + }); + expect(event.type).toBe("closed"); + }); +}); + +describe("DiscoveredLocalServer", () => { + it("decodes a server with process metadata", () => { + const server = decodeServer({ + host: "localhost", + port: 5173, + url: "http://localhost:5173", + processName: "node", + pid: 12345, + }); + expect(server.port).toBe(5173); + expect(server.processName).toBe("node"); + }); + + it("decodes a server without process metadata", () => { + const server = decodeServer({ + host: "localhost", + port: 3000, + url: "http://localhost:3000", + processName: null, + pid: null, + }); + expect(server.processName).toBeNull(); + }); + + it("rejects invalid ports", () => { + expect(() => + decodeServer({ + host: "localhost", + port: 0, + url: "http://localhost:0", + processName: null, + pid: null, + }), + ).toThrow(); + expect(() => + decodeServer({ + host: "localhost", + port: 70000, + url: "http://localhost:70000", + processName: null, + pid: null, + }), + ).toThrow(); + }); +}); diff --git a/packages/contracts/src/preview.ts b/packages/contracts/src/preview.ts new file mode 100644 index 00000000000..f9ef049c386 --- /dev/null +++ b/packages/contracts/src/preview.ts @@ -0,0 +1,181 @@ +/** + * Preview - Schemas for the in-app browser preview surface. + * + * The preview is desktop-only (Chromium ); the server tracks per-thread + * tab metadata so it survives client reconnects and multi-window. The desktop + * renderer mediates: it owns the actual and reports navigation back to + * the server via these RPCs, the server fans events to all subscribers. + * + * @module Preview + */ +import { Schema } from "effect"; +import { ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; + +const Url = TrimmedNonEmptyString.check(Schema.isMaxLength(2048)); +const Title = Schema.String.check(Schema.isMaxLength(512)); + +export const PreviewTabId = TrimmedNonEmptyString.check(Schema.isMaxLength(128)); +export type PreviewTabId = typeof PreviewTabId.Type; + +export const PreviewNavStatus = Schema.Union([ + Schema.TaggedStruct("Idle", {}), + Schema.TaggedStruct("Loading", { + url: Url, + title: Title, + }), + Schema.TaggedStruct("Success", { + url: Url, + title: Title, + }), + Schema.TaggedStruct("LoadFailed", { + url: Url, + title: Title, + 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: ThreadId, + /** Omit to create an empty (Idle) tab the user can type into. */ + url: Schema.optional(Url), +}); +export type PreviewOpenInput = typeof PreviewOpenInput.Type; + +export const PreviewNavigateInput = Schema.Struct({ + threadId: ThreadId, + tabId: PreviewTabId, + url: Url, + resolvedTitle: Schema.optional(Title), +}); +export type PreviewNavigateInput = typeof PreviewNavigateInput.Type; + +export const PreviewReportStatusInput = Schema.Struct({ + threadId: ThreadId, + tabId: PreviewTabId, + navStatus: PreviewNavStatus, + canGoBack: Schema.Boolean, + canGoForward: Schema.Boolean, +}); +export type PreviewReportStatusInput = typeof PreviewReportStatusInput.Type; + +export const PreviewRefreshInput = Schema.Struct({ + threadId: ThreadId, + tabId: PreviewTabId, +}); +export type PreviewRefreshInput = typeof PreviewRefreshInput.Type; + +export const PreviewCloseInput = Schema.Struct({ + threadId: ThreadId, + tabId: Schema.optional(PreviewTabId), +}); +export type PreviewCloseInput = typeof PreviewCloseInput.Type; + +export const PreviewListInput = Schema.Struct({ + threadId: ThreadId, +}); +export type PreviewListInput = typeof PreviewListInput.Type; + +export const PreviewListResult = Schema.Struct({ + sessions: Schema.Array(PreviewSessionSnapshot), +}); +export type PreviewListResult = typeof PreviewListResult.Type; + +const PreviewEventBaseSchema = Schema.Struct({ + threadId: TrimmedNonEmptyString, + tabId: PreviewTabId, + createdAt: Schema.String, +}); + +const PreviewOpenedEvent = Schema.Struct({ + ...PreviewEventBaseSchema.fields, + type: Schema.Literal("opened"), + snapshot: PreviewSessionSnapshot, +}); + +const PreviewNavigatedEvent = Schema.Struct({ + ...PreviewEventBaseSchema.fields, + type: Schema.Literal("navigated"), + snapshot: PreviewSessionSnapshot, +}); + +const PreviewFailedEvent = Schema.Struct({ + ...PreviewEventBaseSchema.fields, + type: Schema.Literal("failed"), + url: Url, + title: Title, + code: Schema.Int, + description: Schema.String, +}); + +const PreviewClosedEvent = Schema.Struct({ + ...PreviewEventBaseSchema.fields, + type: Schema.Literal("closed"), +}); + +export const PreviewEvent = Schema.Union([ + PreviewOpenedEvent, + PreviewNavigatedEvent, + PreviewFailedEvent, + PreviewClosedEvent, +]); +export type PreviewEvent = typeof PreviewEvent.Type; + +/** + * A localhost server detected by the port scanner. Used to populate the + * "Local" recommendations in the empty-state of the preview panel. + */ +export const DiscoveredLocalServer = Schema.Struct({ + host: TrimmedNonEmptyString, + port: Schema.Int.check(Schema.isGreaterThan(0)).check(Schema.isLessThan(65536)), + url: Url, + processName: Schema.NullOr(TrimmedNonEmptyString), + pid: Schema.NullOr(Schema.Int.check(Schema.isGreaterThan(0))), +}); +export type DiscoveredLocalServer = typeof DiscoveredLocalServer.Type; + +export const DiscoveredLocalServerList = Schema.Struct({ + servers: Schema.Array(DiscoveredLocalServer), + scannedAt: Schema.String, +}); +export type DiscoveredLocalServerList = typeof DiscoveredLocalServerList.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, + detail: Schema.optional(Schema.String), + }, +) { + override get message() { + return this.detail + ? `Invalid preview URL: ${this.rawUrl} (${this.detail})` + : `Invalid preview URL: ${this.rawUrl}`; + } +} + +export const PreviewError = Schema.Union([PreviewSessionLookupError, PreviewInvalidUrlError]); +export type PreviewError = typeof PreviewError.Type; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 5a145f3f657..e8cba6ebe78 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -85,6 +85,19 @@ import { TerminalSessionSnapshot, TerminalWriteInput, } from "./terminal.ts"; +import { + DiscoveredLocalServerList, + PreviewCloseInput, + PreviewError, + PreviewEvent, + PreviewListInput, + PreviewListResult, + PreviewNavigateInput, + PreviewOpenInput, + PreviewRefreshInput, + PreviewReportStatusInput, + PreviewSessionSnapshot, +} from "./preview.ts"; import { ServerConfigStreamEvent, ServerConfig, @@ -157,6 +170,14 @@ export const WS_METHODS = { terminalRestart: "terminal.restart", terminalClose: "terminal.close", + // Preview methods + previewOpen: "preview.open", + previewNavigate: "preview.navigate", + previewRefresh: "preview.refresh", + previewClose: "preview.close", + previewList: "preview.list", + previewReportStatus: "preview.reportStatus", + // Server meta serverGetConfig: "server.getConfig", serverRefreshProviders: "server.refreshProviders", @@ -184,6 +205,8 @@ export const WS_METHODS = { subscribeVcsStatus: "subscribeVcsStatus", subscribeTerminalEvents: "subscribeTerminalEvents", subscribeTerminalMetadata: "subscribeTerminalMetadata", + subscribePreviewEvents: "subscribePreviewEvents", + subscribeDiscoveredLocalServers: "subscribeDiscoveredLocalServers", subscribeServerConfig: "subscribeServerConfig", subscribeServerLifecycle: "subscribeServerLifecycle", subscribeAuthAccess: "subscribeAuthAccess", @@ -454,6 +477,53 @@ export const WsTerminalCloseRpc = Rpc.make(WS_METHODS.terminalClose, { error: Schema.Union([TerminalError, EnvironmentAuthorizationError]), }); +export const WsPreviewOpenRpc = Rpc.make(WS_METHODS.previewOpen, { + payload: PreviewOpenInput, + success: PreviewSessionSnapshot, + error: PreviewError, +}); + +export const WsPreviewNavigateRpc = Rpc.make(WS_METHODS.previewNavigate, { + payload: PreviewNavigateInput, + success: PreviewSessionSnapshot, + error: PreviewError, +}); + +export const WsPreviewRefreshRpc = Rpc.make(WS_METHODS.previewRefresh, { + payload: PreviewRefreshInput, + error: PreviewError, +}); + +export const WsPreviewCloseRpc = Rpc.make(WS_METHODS.previewClose, { + payload: PreviewCloseInput, + error: PreviewError, +}); + +export const WsPreviewListRpc = Rpc.make(WS_METHODS.previewList, { + payload: PreviewListInput, + success: PreviewListResult, +}); + +export const WsPreviewReportStatusRpc = Rpc.make(WS_METHODS.previewReportStatus, { + payload: PreviewReportStatusInput, + error: PreviewError, +}); + +export const WsSubscribePreviewEventsRpc = Rpc.make(WS_METHODS.subscribePreviewEvents, { + payload: Schema.Struct({}), + success: PreviewEvent, + stream: true, +}); + +export const WsSubscribeDiscoveredLocalServersRpc = Rpc.make( + WS_METHODS.subscribeDiscoveredLocalServers, + { + payload: Schema.Struct({}), + success: DiscoveredLocalServerList, + stream: true, + }, +); + export const WsOrchestrationDispatchCommandRpc = Rpc.make( ORCHESTRATION_WS_METHODS.dispatchCommand, { @@ -589,6 +659,14 @@ export const WsRpcGroup = RpcGroup.make( WsTerminalCloseRpc, WsSubscribeTerminalEventsRpc, WsSubscribeTerminalMetadataRpc, + WsPreviewOpenRpc, + WsPreviewNavigateRpc, + WsPreviewRefreshRpc, + WsPreviewCloseRpc, + WsPreviewListRpc, + WsPreviewReportStatusRpc, + WsSubscribePreviewEventsRpc, + WsSubscribeDiscoveredLocalServersRpc, WsSubscribeServerConfigRpc, WsSubscribeServerLifecycleRpc, WsSubscribeAuthAccessRpc, diff --git a/packages/shared/package.json b/packages/shared/package.json index 97af1fa5840..791e66951bd 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -150,6 +150,10 @@ "./relayClient": { "types": "./src/relayClient.ts", "import": "./src/relayClient.ts" + }, + "./preview": { + "types": "./src/preview.ts", + "import": "./src/preview.ts" } }, "scripts": { diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index 3cc2e913621..ca4942cf9fd 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -24,6 +24,13 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+n", command: "terminal.new", when: "terminalFocus" }, { key: "mod+w", command: "terminal.close", when: "terminalFocus" }, { key: "mod+d", command: "diff.toggle", when: "!terminalFocus" }, + { key: "mod+shift+j", command: "preview.toggle" }, + { key: "mod+r", command: "preview.refresh", when: "previewFocus" }, + { key: "mod+l", command: "preview.focusUrl", when: "previewFocus" }, + { key: "mod+=", command: "preview.zoomIn", when: "previewFocus" }, + { key: "mod++", command: "preview.zoomIn", when: "previewFocus" }, + { key: "mod+-", command: "preview.zoomOut", when: "previewFocus" }, + { key: "mod+0", command: "preview.resetZoom", when: "previewFocus" }, { key: "mod+k", command: "commandPalette.toggle", when: "!terminalFocus" }, { key: "mod+n", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, diff --git a/packages/shared/src/preview.test.ts b/packages/shared/src/preview.test.ts new file mode 100644 index 00000000000..e9b72172477 --- /dev/null +++ b/packages/shared/src/preview.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; + +import { + isLoopbackHost, + isPreviewableUrl, + newPreviewTabId, + normalizePreviewUrl, + PreviewUrlNormalizationError, +} from "./preview.ts"; + +describe("newPreviewTabId", () => { + it("returns a unique tab id every call", () => { + const a = newPreviewTabId(); + const b = newPreviewTabId(); + expect(a).not.toBe(b); + expect(a.startsWith("tab_")).toBe(true); + }); +}); + +describe("isLoopbackHost", () => { + it.each(["localhost", "127.0.0.1", "0.0.0.0", "::1", "[::1]"])("%s is loopback", (host) => { + expect(isLoopbackHost(host)).toBe(true); + }); + + it.each(["example.com", "192.168.1.10", "10.0.0.1", ""])("%s is not loopback", (host) => { + expect(isLoopbackHost(host)).toBe(false); + }); +}); + +describe("isPreviewableUrl", () => { + it.each([ + "http://localhost:5173", + "http://127.0.0.1:3000/path", + "http://0.0.0.0:8080", + "http://[::1]:5173", + ])("%s is previewable", (url) => { + expect(isPreviewableUrl(url)).toBe(true); + }); + + it.each(["https://example.com", "ws://localhost:5173", "file:///etc/passwd", "not-a-url", ""])( + "%s is not previewable", + (url) => { + expect(isPreviewableUrl(url)).toBe(false); + }, + ); +}); + +describe("normalizePreviewUrl", () => { + it("treats bare loopback hosts as http", () => { + expect(normalizePreviewUrl("localhost:5173")).toBe("http://localhost:5173/"); + expect(normalizePreviewUrl("127.0.0.1:3000")).toBe("http://127.0.0.1:3000/"); + }); + + it("treats bare public hosts as https", () => { + expect(normalizePreviewUrl("example.com")).toBe("https://example.com/"); + }); + + it("respects explicit schemes", () => { + expect(normalizePreviewUrl("https://localhost:5173")).toBe("https://localhost:5173/"); + expect(normalizePreviewUrl("http://example.com/path?q=1")).toBe("http://example.com/path?q=1"); + }); + + it("rejects empty input", () => { + expect(() => normalizePreviewUrl(" ")).toThrow(PreviewUrlNormalizationError); + }); + + it("rejects unsupported protocols", () => { + expect(() => normalizePreviewUrl("ftp://example.com")).toThrow(PreviewUrlNormalizationError); + expect(() => normalizePreviewUrl("file:///etc/passwd")).toThrow(PreviewUrlNormalizationError); + }); + + it("rejects unparseable junk", () => { + expect(() => normalizePreviewUrl("http://")).toThrow(PreviewUrlNormalizationError); + }); +}); diff --git a/packages/shared/src/preview.ts b/packages/shared/src/preview.ts new file mode 100644 index 00000000000..963023f0ee3 --- /dev/null +++ b/packages/shared/src/preview.ts @@ -0,0 +1,92 @@ +/** + * Pure URL helpers shared between the preview server, desktop main process, + * and web renderer. Centralising these guarantees the four call sites agree + * on what counts as "loopback" and how to normalise a free-form URL string. + */ + +const TAB_ID_PREFIX = "tab_"; + +/** + * Generate a fresh preview tab id. Lives in shared (not contracts) because + * the contracts package is schema-only — runtime helpers belong here. + */ +export function newPreviewTabId(): string { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return `${TAB_ID_PREFIX}${crypto.randomUUID()}`; + } + return `${TAB_ID_PREFIX}${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`; +} + +const LOOPBACK_HOSTS: ReadonlySet = new Set(["localhost", "127.0.0.1", "0.0.0.0", "::1"]); + +/** Internal — used by `lsof` parsing where the host string is wire-formatted. */ +export const LSOF_LOCAL_HOST_TOKENS: ReadonlySet = new Set([ + ...LOOPBACK_HOSTS, + "*", + "[::]", + "[::1]", +]); + +const LOOPBACK_PREFIX_PATTERN = /^(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1?\])(?::|\/|$)/i; + +export function isLoopbackHost(host: string): boolean { + if (LOOPBACK_HOSTS.has(host)) return true; + if (host === "[::1]") return true; + return false; +} + +/** True when a raw URL string looks like a loopback dev URL we can preview. */ +export function isPreviewableUrl(rawUrl: string): boolean { + try { + const parsed = new URL(rawUrl); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false; + return isLoopbackHost(parsed.hostname); + } catch { + return false; + } +} + +export class PreviewUrlNormalizationError extends Error { + readonly rawUrl: string; + readonly detail: string; + constructor(rawUrl: string, detail: string) { + super(`Invalid preview URL: ${rawUrl} (${detail})`); + this.name = "PreviewUrlNormalizationError"; + this.rawUrl = rawUrl; + this.detail = detail; + } +} + +/** + * Normalise a free-form URL string into a fully-qualified `http(s)://` URL. + * + * - Bare loopback hosts (`localhost`, `localhost:5173`) become `http://...`. + * - Bare public hosts (`example.com`) become `https://...`. + * - Already-qualified URLs are validated and returned as `URL.href`. + * + * Throws `PreviewUrlNormalizationError` for empty, unparseable, or + * unsupported-protocol inputs. + */ +export function normalizePreviewUrl(rawUrl: string): string { + const trimmed = rawUrl.trim(); + if (trimmed.length === 0) { + throw new PreviewUrlNormalizationError(rawUrl, "empty"); + } + const useHttp = LOOPBACK_PREFIX_PATTERN.test(trimmed); + const candidate = trimmed.includes("://") + ? trimmed + : `${useHttp ? "http" : "https"}://${trimmed}`; + let parsed: URL; + try { + parsed = new URL(candidate); + } catch (cause) { + throw new PreviewUrlNormalizationError( + rawUrl, + cause instanceof Error ? cause.message : "unparseable", + ); + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new PreviewUrlNormalizationError(rawUrl, `unsupported protocol ${parsed.protocol}`); + } + return parsed.href; +} From 62ee2008fa7af3c2142cf5dede690af2ea09e5d4 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Sun, 3 May 2026 05:22:47 -0700 Subject: [PATCH 03/25] fix --- 404.html | 1600 ------------------------------ app-preview-browser-plan.md | 1853 ----------------------------------- 2 files changed, 3453 deletions(-) delete mode 100644 404.html delete mode 100644 app-preview-browser-plan.md diff --git a/404.html b/404.html deleted file mode 100644 index ebdbd1fcf78..00000000000 --- a/404.html +++ /dev/null @@ -1,1600 +0,0 @@ - - - - - - asdasddddd.com - - - - - - - - - -
-
-
-
-
-

- This site can’t be reached -

- -

asdasddddd.com’s server IP address could not be found.

- - - -
-

Try:

- -
- - -
ERR_NAME_NOT_RESOLVED
- - -
-
- - -
- -
-
Check your Internet connection
-
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 b6675daf898..00000000000 --- 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 ` -
- -
- - - } - > - - - Open in system browser - - - - } - > - - - Close preview - -
-
- ); -} - -function PreviewTab({ - tab, - onActivate, - onClose, -}: { - tab: PreviewTabDescriptor; - onActivate: () => void; - onClose: () => void; -}) { - return ( -
- - -
- ); -} -``` - -### 15.3 Favicon helper (`apps/web/src/lib/favicon.ts`) - -Borrowed from ami's `custom-tab.tsx:57` — Google's s2 favicon endpoint with `onError` fallback to a `` icon. Lives in its own module so we can swap providers later (e.g. self-hosted favicon proxy). - -```ts -// apps/web/src/lib/favicon.ts -const FAVICON_PROVIDER = "https://www.google.com/s2/favicons"; - -export function faviconUrlForOrigin(rawUrl: string | null, size = 32): string | null { - if (!rawUrl) return null; - try { - const url = new URL(rawUrl); - if (!url.host) return null; - return `${FAVICON_PROVIDER}?domain=${encodeURIComponent(url.host)}&sz=${size}`; - } catch { - return null; - } -} -``` - -```tsx -// apps/web/src/components/preview/TabFavicon.tsx -import { Globe } from "lucide-react"; -import { useEffect, useState } from "react"; -import { faviconUrlForOrigin } from "~/lib/favicon"; - -export function TabFavicon({ url }: { url: string | null }) { - const src = faviconUrlForOrigin(url, 32); - const [errored, setErrored] = useState(false); - useEffect(() => setErrored(false), [src]); - - if (!src || errored) { - return ; - } - return ( - setErrored(true)} - /> - ); -} -``` - -### 15.4 Unreachable / error state (`PreviewUnreachable.tsx`) — port of 404.html - -When `navStatus._tag === "LoadFailed"`, render this instead of the failed ``. The original Chromium `404.html` (`asdasddddd.com` example) uses `--google-gray-*` vars; we map every Google gray to the closest theme variable so it auto-themes. - -Color mapping (Chromium → t3code theme): - -| Chromium | Tailwind/t3code | -| ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `--background-color: #fff` / `--google-gray-900` (dark) | `bg-background` | -| `--text-color: --google-gray-700` / `--google-gray-500` (dark) | `text-muted-foreground` | -| `--heading-color: --google-gray-900` / `--google-gray-500` (dark) | `text-foreground` | -| `--error-code-color: --google-gray-700` / `--google-gray-500` (dark) | `text-muted-foreground/70` | -| `--quiet-background-color: rgb(247,247,247)` / `--background` (dark) | `bg-muted/40` | -| `--primary-button-fill-color: --google-blue-600` / `--google-blue-300` (dark) | `bg-primary text-primary-foreground` (theme primary is `oklch(0.488 0.217 264)` light / `oklch(0.588 0.217 264)` dark — a very close blue) | -| `--secondary-button-*` | `Button variant="outline"` | -| `--link-color: rgb(88,88,88)` / `--google-blue-300` (dark) | `text-primary underline-offset-4 hover:underline` | -| Body font `system-ui, sans-serif; font-size: 75%` | We keep DM Sans (already in `apps/web/src/index.css:148`) and `text-sm` — the Chromium font tweak just compensates for their `html { font-size: 125% }` — we don't replicate that | - -Skeleton: - -```tsx -// apps/web/src/components/preview/PreviewUnreachable.tsx -"use client"; - -import { useState } from "react"; -import { Button } from "~/components/ui/button"; -import { cn } from "~/lib/utils"; - -interface Props { - url: string; - errorCode: string; // e.g. "ERR_NAME_NOT_RESOLVED", "ERR_CONNECTION_REFUSED" - description: string; - onReload: () => void; -} - -const ICON_GENERIC = ( - // Replace with an inline SVG that matches the Chromium "icon-generic" - // (a stylized broken page). For v1 a Lucide MapPinOff is a fine stand-in — - // we want a visual that reads "destination unreachable". - - - - - -); - -export function PreviewUnreachable({ url, errorCode, description, onReload }: Props) { - const [showDetails, setShowDetails] = useState(false); - const host = safeHost(url) ?? url; - - return ( -
-
- {/* Icon + headline */} -
- {ICON_GENERIC} -

- This site can’t be reached -

-
- - {/* Summary — uses dangerouslySetInnerHTML only for the bold host treatment */} -

- {host} - ’s server{" "} - - DNS address - {" "} - could not be found. -

- - {/* Suggestions list (when details open) */} - {showDetails && ( -
-

Try:

-
    -
  • Checking the connection
  • -
  • Checking the proxy and the firewall
  • -
  • Running Network Diagnostics
  • -
-
- )} - - {/* Error code */} -
- {errorCode} -
- - {/* Actions */} -
- -
- -
-
-
- ); -} - -function safeHost(url: string): string | null { - try { - return new URL(url).host; - } catch { - return null; - } -} -``` - -The component intentionally: - -- Uses theme tokens only — looks correct in light + dark with no extra wiring. -- Drops Chromium's "Diagnose connection" / "Portal sign-in" buttons (irrelevant in our shell). -- Drops the dinosaur game (RIP). -- Maps `errorCode` → human description in `PreviewView.tsx` via a small lookup (`ERR_CONNECTION_REFUSED` → "Connection refused", etc.) before rendering. - -### 15.5 Loading bar - -A 1.5px primary-colored bar that fills as the page loads, anchored to the bottom of the chrome row. Direct port of ami's pattern (`browser-view.tsx:434`): - -```tsx -{ - loadProgress > 0 && ( -
- ); -} -``` - -Progress is computed locally with `useLoadingProgress(isLoading)` — same hook signature as ami's, ~30 lines. - ---- - -## 16. Local server discovery (port scanner) - -The "Local" recommendations in §15.1 need a feed. New backend service. - -### `apps/server/src/preview/Services/PortScanner.ts` - -```ts -import { Context, Effect } from "effect"; - -export interface DiscoveredLocalServer { - host: string; // "localhost" - port: number; // 5175 - url: string; // "http://localhost:5175" - processName: string | null; // "node", "vite", "next-server" - pid: number | null; -} - -export interface PreviewPortScannerShape { - /** One-shot snapshot of currently listening localhost ports. */ - readonly scan: () => Effect.Effect>; - /** Subscribe to changes. Listener is called on every diff. */ - readonly subscribe: ( - listener: (servers: ReadonlyArray) => Effect.Effect, - ) => Effect.Effect<() => void>; - /** Hint that at least one client is interested → starts polling. Returns release fn. */ - readonly retain: () => Effect.Effect<() => void>; -} - -export class PreviewPortScanner extends Context.Service< - PreviewPortScanner, - PreviewPortScannerShape ->()("t3/preview/Services/PortScanner") {} -``` - -### `apps/server/src/preview/Layers/PortScanner.ts` - -Two strategies, picked at startup: - -1. **Preferred — `lsof`** on macOS/Linux: `lsof -iTCP -sTCP:LISTEN -P -n -F pcn` returns `pid`, `command`, `name` (host:port). Fast (<50ms typical), gives process name for free. Parsed via the `-F` field-format which is stable across versions. -2. **Fallback — TCP connect probe**: iterate a curated list of common dev ports `[3000, 3001, 3333, 4173, 4200, 4321, 5000, 5173, 5174, 5175, 5500, 8000, 8080, 8081, 8888, 9000]` against `127.0.0.1`, mark any that accepts a connection. Used on Windows and as the safety net if `lsof` is missing. - -Polling cadence: - -- When `retain()` count is 0 → not polling. -- When ≥1 → poll every 3s. Diff against last result; only emit `subscribe` callbacks when the set differs. -- `retain()` returns a release fn that decrements the counter; goes idle automatically when the empty state is hidden. - -The renderer side (`useDiscoveredLocalServers(threadId)`) calls `EnvironmentApi.preview.subscribePorts(callback)` and the WS handler calls `retain()` on first subscribe / releases on the last unsubscribe. - -### Augmenting the discovery feed - -The card list in §15.1 is the union of: - -1. **Listening ports** from the scanner (above). -2. **Recently seen URLs** — `apps/web/src/previewStateStore.ts` already has a `recentEvents` ring buffer; we additionally maintain a small per-thread `recentUrlsFromTerminal: string[]` populated from the existing `terminal-links` extraction (already wired in `ThreadTerminalDrawer.tsx:454`). -3. **Configured URLs** — every active `ProjectScript.previewUrl` from the active project. - -Deduped by URL string. Sort: configured > listening > recent. This yields the "smart, contextual, no-thought-required" feel the screenshot implies. - ---- - -## 17. File map (additions to §3) - -Append these to the file list: - -| File | Purpose | -| -------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | -| `apps/server/src/preview/Services/PortScanner.ts` | Service tag + interface | -| `apps/server/src/preview/Layers/PortScanner.ts` | `lsof` strategy + TCP probe fallback + polling | -| `apps/server/src/preview/Layers/PortScanner.test.ts` | Parser tests for `lsof -F pcn` output | -| `apps/web/src/lib/favicon.ts` | `faviconUrlForOrigin(url, size)` helper | -| `apps/web/src/components/preview/PreviewTabStrip.tsx` | Tab strip (screenshot 2) | -| `apps/web/src/components/preview/TabFavicon.tsx` | Favicon `` w/ `` fallback | -| `apps/web/src/components/preview/BrowserMockup.tsx` | Tiny tailwind browser thumbnail icon | -| `apps/web/src/components/preview/PreviewLocalServerCard.tsx` | Card row for a discovered server | -| `apps/web/src/components/preview/PreviewUnreachable.tsx` | 404.html rewritten in tailwind | -| `apps/web/src/components/preview/useDiscoveredLocalServers.ts` | Hook subscribing to `EnvironmentApi.preview.subscribePorts` + merging in `recentUrlsFromTerminal` and `ProjectScript.previewUrl` | -| `apps/web/src/components/preview/useLoadingProgress.ts` | 30-line progress simulator (port of ami's) | -| `apps/web/src/components/preview/errorCodeMessages.ts` | `ERR_*` → human-readable description map | - -Update the contract additions in §4 to add: - -```ts -// packages/contracts/src/preview.ts -export const DiscoveredLocalServer = Schema.Struct({ - host: TrimmedNonEmptyString, - port: Schema.Int.check(Schema.isGreaterThan(0)).check(Schema.isLessThan(65536)), - url: TrimmedNonEmptyString, - processName: Schema.NullOr(TrimmedNonEmptyString), - pid: Schema.NullOr(Schema.Int.check(Schema.isGreaterThan(0))), -}); -export type DiscoveredLocalServer = typeof DiscoveredLocalServer.Type; -``` - -WS routes added (§5): - -``` -preview.subscribePorts(callback) → unsubscribe -preview.scanPortsOnce() → ReadonlyArray -``` - ---- - -## 18. Implementation order (single‑shot landing) — REVISED - -To minimize cross‑file thrash while writing: - -1. `packages/contracts/src/preview.ts` (+ test) — `PreviewSession`, `PreviewEvent`, `DiscoveredLocalServer` schemas; `keybindings.ts` / `project.ts` schema edits → **build contracts first**. -2. `apps/server/src/preview/{Services,Layers}/Manager.ts` (+ test) and `{Services,Layers}/PortScanner.ts` (+ test). -3. `apps/server/src/ws.ts` and `runtimeLayer` provision (`PreviewManager.Default`, `PreviewPortScanner.Default`). -4. `apps/desktop/src/preview-view-manager.ts`, `preview-preload.ts`, `main.ts` IPC handlers, `preload.ts` `desktopBridge.preview` namespace; update `apps/desktop/tsdown.config.ts` to also bundle `preview-preload.ts` → `preview-preload.cjs`. -5. `apps/web/src/lib/favicon.ts`; `apps/web/src/previewStateStore.ts`, `apps/web/src/rightPanelStore.ts` (+ tests). -6. `apps/web/src/environmentApi.ts` — add `preview` slot. -7. Right‑panel arbiter migration in `ChatView.tsx` (replace `planSidebarOpen`, render preview alongside diff/plan). -8. Components, in dependency order: - - `BrowserMockup.tsx`, `TabFavicon.tsx`, `useLoadingProgress.ts`, `errorCodeMessages.ts`, `useDiscoveredLocalServers.ts` - - `PreviewLocalServerCard.tsx`, `PreviewEmptyState.tsx` - - `PreviewUnreachable.tsx` - - `PreviewTabStrip.tsx` - - `PreviewWebview.tsx` (the `` host, no-op on web) - - `PreviewView.tsx` (ties it all together: tab strip + chrome row + body switching between empty / webview / unreachable) - - `PreviewPanelShell.tsx`, `PreviewPanel.tsx` -9. Keybinding wiring: `_chat.tsx` `preview.toggle`/`preview.refresh`/`preview.focusUrl`; `keybindings.ts` shortcut helpers. -10. `ProjectScriptsControl.tsx` form additions (`previewUrl`, `autoOpenPreview`). -11. `ThreadTerminalDrawer.tsx` link‑activation update — context menu "Open in preview" / "Open in browser" for previewable URLs. -12. `KEYBINDINGS.md` doc update — add `preview.*` commands and `previewFocus` / `previewOpen` `when` keys. -13. Run `bun fmt && bun lint && bun typecheck && bun run test` — must all pass per `AGENTS.md`. -14. Manual smoke against the dev desktop (`bun run dev:desktop`). - -Estimated diff size: ~3,500–4,500 lines added (mostly net‑new files), ~200 lines modified across `ChatView.tsx`, `ThreadTerminalDrawer.tsx`, `ProjectScriptsControl.tsx`, `_chat.tsx`, `main.ts`, `preload.ts`, `tsdown.config.ts`, `keybindings.ts`, `ws.ts`, `KEYBINDINGS.md`. From db84e06fbe3a846032cfd5a6088a5b2ee6d3e89d Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Sun, 3 May 2026 19:56:51 -0700 Subject: [PATCH 04/25] feat(preview): element-pick attachments + sandboxed picker preload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an in-page element picker to the preview browser. Clicking the crosshair button in the chrome row activates a blue-highlight picker inside the guest webview; clicking an element captures its component name (via react-grab), source location, html/css preview, and selector, then attaches it to the chat composer as a chip that serializes into an `` block in the outgoing message. Architecture: - Per-`` preload bundle (`preview-pick-preload.cjs`) renders the overlay, hosts the picker event loop, and bubbles the picked payload back to main via the per-WebContents `wc.ipc` channel (not `sendToHost`, which only fires on the host renderer's element and never reaches main). - Main coordinates via `PreviewViewManager.pickElement(tabId)`, which cancels any in-flight session, force-focuses the guest (so the first click on a remote page actually reaches the preload), then awaits the payload. User-initiated cancels (Escape, beforeunload) echo `null` back to main; main-initiated cancels and supersession tear down silently to avoid the new-pick-resolves-with-stale-null race. - Renderer fetches partition + webPreferences + preload URL in a single `getPreviewConfig()` IPC call, snapshots the previously-focused host element before triggering a pick, and restores focus when the pick resolves so the user's textarea cursor isn't lost. Security posture for the guest webview: - `webpreferences="contextIsolation=false,sandbox=true,nodeIntegration=false"` centralized in `preview-webview-preferences.ts`. contextIsolation off is required so react-grab's `getElementContext` can reach the page's React DevTools hook on `globalThis`. sandbox stays on so the page cannot reach Node APIs even with shared globals (without it, the preload's `require` would land on the page's `globalThis` and any third-party site could send arbitrary IPC to main). - Defense in depth: a `will-attach-webview` handler in main, gated on the preview partition, force-pins `sandbox: true`, all `nodeIntegration*: false`, and the absolute preload PATH (not URL — that field rejects file:// URLs with "preload script must have absolute path" and silently disables the picker). Composer + transcript integration: - New `elementContexts` slice in `composerDraftStore` (mirrors the terminal-context slice: dedup by selector+tag+component+url, persist via partializer, restore on send-failure retry). - `ComposerPendingElementContexts` chip row above the editor. - `deriveDisplayedUserMessageState` now strips both `` AND `` blocks (element first, since it's appended last) and exposes element entries to `MessagesTimeline`, which renders them as compact chips beneath the message body. - Pick button is disabled with explanatory tooltip when the page failed to load (the React `` overlay covers the webview, so picks would silently dangle otherwise). Tests added: - `preview-webview-preferences.test.ts` locks down the security flags (contextIsolation=false, sandbox=true, nodeIntegration=false, no whitespace, only true/false literal values). - `preview-pick-label-position.test.ts` covers the floating-label clamp/flip math (no off-screen overflow, flip-below when no room above, etc.). - `picked-element-payload.test.ts` validator coverage. - `elementContext.test.ts` for the serialization round-trip, normalization, dedup, and label formatting. - `composerDraftStore.test.ts` element-contexts slice (add, dedup, remove, set, clear, persistence round-trip). - `ChatView.logic.test.ts` sendable-content-with-element-only. Build: new `tsdown` entry inlines react-grab + bippy into the picker preload bundle (~59KB / 19KB gzipped). --- apps/desktop/package.json | 3 +- apps/desktop/src/ipc/channels.ts | 4 +- apps/desktop/src/ipc/methods/preview.ts | 19 +- apps/desktop/src/main.ts | 2 + .../src/picked-element-payload.test.ts | 135 ++++++ apps/desktop/src/picked-element-payload.ts | 49 ++ apps/desktop/src/preload.ts | 11 +- .../src/preview-pick-label-position.ts | 46 ++ apps/desktop/src/preview-pick-preload.test.ts | 86 ++++ apps/desktop/src/preview-pick-preload.ts | 419 ++++++++++++++++++ apps/desktop/src/preview-view-manager.ts | 124 ++++++ .../src/preview-webview-preferences.test.ts | 78 ++++ .../src/preview-webview-preferences.ts | 42 ++ apps/desktop/src/window/DesktopWindow.ts | 10 + apps/desktop/vite.config.ts | 10 + .../web/src/components/ChatView.logic.test.ts | 24 + apps/web/src/components/ChatView.logic.ts | 12 +- apps/web/src/components/ChatView.tsx | 30 +- apps/web/src/components/chat/ChatComposer.tsx | 45 +- .../CompactComposerControlsMenu.browser.tsx | 12 +- .../chat/ComposerPendingElementContexts.tsx | 95 ++++ .../src/components/chat/MessagesTimeline.tsx | 51 ++- .../components/preview/PreviewChromeRow.tsx | 49 +- .../src/components/preview/PreviewView.tsx | 87 +++- .../src/components/preview/PreviewWebview.tsx | 46 +- apps/web/src/composerDraftStore.test.ts | 93 ++++ apps/web/src/composerDraftStore.ts | 245 +++++++++- apps/web/src/lib/elementContext.test.ts | 294 ++++++++++++ apps/web/src/lib/elementContext.ts | 245 ++++++++++ apps/web/src/lib/terminalContext.test.ts | 1 + apps/web/src/lib/terminalContext.ts | 23 +- packages/contracts/src/ipc.ts | 80 +++- 32 files changed, 2416 insertions(+), 54 deletions(-) create mode 100644 apps/desktop/src/picked-element-payload.test.ts create mode 100644 apps/desktop/src/picked-element-payload.ts create mode 100644 apps/desktop/src/preview-pick-label-position.ts create mode 100644 apps/desktop/src/preview-pick-preload.test.ts create mode 100644 apps/desktop/src/preview-pick-preload.ts create mode 100644 apps/desktop/src/preview-webview-preferences.test.ts create mode 100644 apps/desktop/src/preview-webview-preferences.ts create mode 100644 apps/web/src/components/chat/ComposerPendingElementContexts.tsx create mode 100644 apps/web/src/lib/elementContext.test.ts create mode 100644 apps/web/src/lib/elementContext.ts diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 339f7963702..f26f2cd1ae4 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -20,7 +20,8 @@ "@t3tools/tailscale": "workspace:*", "effect": "catalog:", "electron": "41.5.0", - "electron-updater": "^6.6.2" + "electron-updater": "^6.6.2", + "react-grab": "^0.1.32" }, "devDependencies": { "@effect/vitest": "catalog:", diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 7502831c07a..9c11859f236 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -53,5 +53,7 @@ export const PREVIEW_HARD_RELOAD_CHANNEL = "desktop:preview-hard-reload"; export const PREVIEW_OPEN_DEVTOOLS_CHANNEL = "desktop:preview-open-devtools"; export const PREVIEW_CLEAR_COOKIES_CHANNEL = "desktop:preview-clear-cookies"; export const PREVIEW_CLEAR_CACHE_CHANNEL = "desktop:preview-clear-cache"; -export const PREVIEW_GET_BROWSER_PARTITION_CHANNEL = "desktop:preview-get-browser-partition"; +export const PREVIEW_GET_CONFIG_CHANNEL = "desktop:preview-get-config"; +export const PREVIEW_PICK_ELEMENT_CHANNEL = "desktop:preview-pick-element"; +export const PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL = "desktop:preview-cancel-pick-element"; export const PREVIEW_STATE_CHANGE_CHANNEL = "desktop:preview-state-change"; diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index 1b00eacdf86..9b80339a2e2 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -1,7 +1,11 @@ import * as Effect from "effect/Effect"; import { BrowserWindow } from "electron"; +import * as NodeFileSystem from "node:fs"; +import * as NodePath from "node:path"; +import { pathToFileURL } from "node:url"; import { previewViewManager } from "../../preview-view-manager.ts"; +import { PREVIEW_WEBVIEW_PREFERENCES } from "../../preview-webview-preferences.ts"; import * as IpcChannels from "../channels.ts"; import type { DesktopIpcMethod } from "../DesktopIpc.ts"; @@ -57,5 +61,18 @@ export const previewMethods = [ method(IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, (raw) => previewViewManager.openDevTools(tabIdFrom(raw))), method(IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL, () => previewViewManager.clearCookies()), method(IpcChannels.PREVIEW_CLEAR_CACHE_CHANNEL, () => previewViewManager.clearCache()), - method(IpcChannels.PREVIEW_GET_BROWSER_PARTITION_CHANNEL, () => previewViewManager.getBrowserPartition()), + method(IpcChannels.PREVIEW_GET_CONFIG_CHANNEL, () => { + const preloadPath = NodePath.join(__dirname, "preview-pick-preload.cjs"); + return { + partition: previewViewManager.getBrowserPartition(), + webPreferences: PREVIEW_WEBVIEW_PREFERENCES, + preloadUrl: NodeFileSystem.existsSync(preloadPath) ? pathToFileURL(preloadPath).href : null, + }; + }), + method(IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL, (raw) => + previewViewManager.pickElement(tabIdFrom(raw)), + ), + method(IpcChannels.PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL, (raw) => + previewViewManager.cancelPickElement(tabIdFrom(raw)), + ), ] as const; diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 9356eef441b..74ffcba1a16 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -28,6 +28,7 @@ import * as DesktopApp from "./app/DesktopApp.ts"; import * as DesktopAppIdentity from "./app/DesktopAppIdentity.ts"; import * as DesktopCloudAuth from "./app/DesktopCloudAuth.ts"; import * as DesktopCloudAuthTokenStore from "./app/DesktopCloudAuthTokenStore.ts"; +import * as DesktopConnectionCatalogStore from "./app/DesktopConnectionCatalogStore.ts"; import * as DesktopApplicationMenu from "./window/DesktopApplicationMenu.ts"; import * as DesktopAssets from "./app/DesktopAssets.ts"; import * as DesktopBackendConfiguration from "./backend/DesktopBackendConfiguration.ts"; @@ -114,6 +115,7 @@ const desktopFoundationLayer = Layer.mergeAll( DesktopClientSettings.layer, DesktopSavedEnvironments.layer, DesktopCloudAuthTokenStore.layer, + DesktopConnectionCatalogStore.layer, DesktopAssets.layer, DesktopObservability.layer, ).pipe(Layer.provideMerge(desktopEnvironmentLayer)); diff --git a/apps/desktop/src/picked-element-payload.test.ts b/apps/desktop/src/picked-element-payload.test.ts new file mode 100644 index 00000000000..0b6d23b6acd --- /dev/null +++ b/apps/desktop/src/picked-element-payload.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from "vitest"; + +import { isPickedElementPayload } from "./picked-element-payload.ts"; + +function validPayload(overrides?: Record): Record { + return { + pageUrl: "https://example.com/", + pageTitle: "Example", + tagName: "button", + selector: "button.submit", + htmlPreview: "", + componentName: "SubmitButton", + source: { + functionName: "SubmitButton", + fileName: "/repo/src/Button.tsx", + lineNumber: 12, + columnNumber: 5, + }, + stack: [ + { + functionName: "SubmitButton", + fileName: "/repo/src/Button.tsx", + lineNumber: 12, + columnNumber: 5, + }, + ], + styles: ".submit { color: white; }", + pickedAt: "2026-05-03T18:00:00.000Z", + ...overrides, + }; +} + +describe("isPickedElementPayload", () => { + it("accepts a complete, well-typed payload", () => { + expect(isPickedElementPayload(validPayload())).toBe(true); + }); + + it("accepts nullable string fields when null", () => { + expect( + isPickedElementPayload( + validPayload({ pageTitle: null, selector: null, componentName: null, source: null }), + ), + ).toBe(true); + }); + + it("accepts an empty stack array", () => { + expect(isPickedElementPayload(validPayload({ stack: [] }))).toBe(true); + }); + + it("accepts stack frames with null fields", () => { + expect( + isPickedElementPayload( + validPayload({ + stack: [ + { + functionName: null, + fileName: null, + lineNumber: null, + columnNumber: null, + }, + ], + }), + ), + ).toBe(true); + }); + + it("rejects null and primitive inputs", () => { + expect(isPickedElementPayload(null)).toBe(false); + expect(isPickedElementPayload(undefined)).toBe(false); + expect(isPickedElementPayload("string")).toBe(false); + expect(isPickedElementPayload(42)).toBe(false); + expect(isPickedElementPayload([])).toBe(false); + }); + + it.each<[string, Record]>([ + ["missing pageUrl", validPayload({ pageUrl: undefined })], + ["wrong-type pageUrl", validPayload({ pageUrl: 123 })], + ["missing tagName", validPayload({ tagName: undefined })], + ["missing htmlPreview", validPayload({ htmlPreview: undefined })], + ["missing styles", validPayload({ styles: undefined })], + ["missing pickedAt", validPayload({ pickedAt: undefined })], + ["wrong-type pageTitle", validPayload({ pageTitle: 99 })], + ["wrong-type selector", validPayload({ selector: 99 })], + ["wrong-type componentName", validPayload({ componentName: 99 })], + ])("rejects payloads with %s", (_label, value) => { + expect(isPickedElementPayload(value)).toBe(false); + }); + + it("rejects malformed source frames", () => { + expect( + isPickedElementPayload( + validPayload({ + source: { + functionName: 0, + fileName: null, + lineNumber: null, + columnNumber: null, + }, + }), + ), + ).toBe(false); + }); + + it("rejects non-finite numeric line/column numbers", () => { + expect( + isPickedElementPayload( + validPayload({ + source: { + functionName: null, + fileName: null, + lineNumber: Number.POSITIVE_INFINITY, + columnNumber: null, + }, + }), + ), + ).toBe(false); + expect( + isPickedElementPayload( + validPayload({ + source: { + functionName: null, + fileName: null, + lineNumber: Number.NaN, + columnNumber: null, + }, + }), + ), + ).toBe(false); + }); + + it("rejects malformed stack arrays", () => { + expect(isPickedElementPayload(validPayload({ stack: "not-an-array" }))).toBe(false); + expect(isPickedElementPayload(validPayload({ stack: [{ bogus: true }] }))).toBe(false); + }); +}); diff --git a/apps/desktop/src/picked-element-payload.ts b/apps/desktop/src/picked-element-payload.ts new file mode 100644 index 00000000000..1b5d4df9f5b --- /dev/null +++ b/apps/desktop/src/picked-element-payload.ts @@ -0,0 +1,49 @@ +/** + * Strict structural validator for `PickedElementPayload` messages received + * from the in-page picker preload (`apps/desktop/src/preview-pick-preload.ts`) + * via `wc.ipc`. Lives in its own electron-free module so the validator is + * trivially unit-testable. + * + * Validation must be tight: downstream `normalizeElementContextSelection` + * calls `.trim()` on incoming strings, so a malformed payload (preload bug, + * future schema mismatch, malicious page that intercepts the preload's IPC + * channel via prototype pollution) would otherwise throw deep in the + * renderer and the chip silently never appears. + */ +import type { PickedElementPayload } from "@t3tools/contracts"; + +function isStringOrNull(value: unknown): value is string | null { + return value === null || typeof value === "string"; +} + +function isFiniteNumberOrNull(value: unknown): value is number | null { + return value === null || (typeof value === "number" && Number.isFinite(value)); +} + +function isPickedStackFrame(value: unknown): boolean { + if (typeof value !== "object" || value === null) return false; + const frame = value as Record; + return ( + isStringOrNull(frame["functionName"]) && + isStringOrNull(frame["fileName"]) && + isFiniteNumberOrNull(frame["lineNumber"]) && + isFiniteNumberOrNull(frame["columnNumber"]) + ); +} + +export function isPickedElementPayload(value: unknown): value is PickedElementPayload { + if (typeof value !== "object" || value === null) return false; + const c = value as Record; + if (typeof c["pageUrl"] !== "string") return false; + if (typeof c["tagName"] !== "string") return false; + if (typeof c["htmlPreview"] !== "string") return false; + if (typeof c["styles"] !== "string") return false; + if (typeof c["pickedAt"] !== "string") return false; + if (!isStringOrNull(c["pageTitle"])) return false; + if (!isStringOrNull(c["selector"])) return false; + if (!isStringOrNull(c["componentName"])) return false; + if (c["source"] !== null && !isPickedStackFrame(c["source"])) return false; + if (!Array.isArray(c["stack"])) return false; + if (!c["stack"].every(isPickedStackFrame)) return false; + return true; +} diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 86a0ed576a9..b9deced7ffb 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -47,6 +47,10 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.invoke(IpcChannels.SET_SAVED_ENVIRONMENT_SECRET_CHANNEL, { environmentId, secret }), removeSavedEnvironmentSecret: (environmentId) => ipcRenderer.invoke(IpcChannels.REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId), + getConnectionCatalog: () => ipcRenderer.invoke(IpcChannels.GET_CONNECTION_CATALOG_CHANNEL), + setConnectionCatalog: (catalog) => + ipcRenderer.invoke(IpcChannels.SET_CONNECTION_CATALOG_CHANNEL, catalog), + clearConnectionCatalog: () => ipcRenderer.invoke(IpcChannels.CLEAR_CONNECTION_CATALOG_CHANNEL), discoverSshHosts: () => ipcRenderer.invoke(IpcChannels.DISCOVER_SSH_HOSTS_CHANNEL), ensureSshEnvironment: async (target, options) => unwrapEnsureSshEnvironmentResult( @@ -159,8 +163,11 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.invoke(IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, { tabId }), clearCookies: () => ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL), clearCache: () => ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_CACHE_CHANNEL), - getBrowserPartition: () => - ipcRenderer.invoke(IpcChannels.PREVIEW_GET_BROWSER_PARTITION_CHANNEL), + getPreviewConfig: () => ipcRenderer.invoke(IpcChannels.PREVIEW_GET_CONFIG_CHANNEL), + pickElement: (tabId) => + ipcRenderer.invoke(IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL, { tabId }), + cancelPickElement: (tabId) => + ipcRenderer.invoke(IpcChannels.PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL, { tabId }), onStateChange: (listener) => { const wrappedListener = ( _event: Electron.IpcRendererEvent, diff --git a/apps/desktop/src/preview-pick-label-position.ts b/apps/desktop/src/preview-pick-label-position.ts new file mode 100644 index 00000000000..e07f64f13d5 --- /dev/null +++ b/apps/desktop/src/preview-pick-label-position.ts @@ -0,0 +1,46 @@ +/** + * Pure clamp/flip math for the floating label that follows the cursor while + * the user is picking an element in the in-app browser. Lives in its own + * electron-free module so the geometry can be unit-tested without spinning + * up an Electron preload context (`preview-pick-preload.ts` itself imports + * `electron` and `react-grab/primitives`, which can't load under vitest). + * + * - Horizontally pins the label to `targetLeft`, clamped into + * `[VIEWPORT_MARGIN, viewportWidth - labelWidth - VIEWPORT_MARGIN]`. + * - Vertically prefers above the target. If the label would overflow the + * top, flips below; if THAT also overflows the bottom, pins to the + * bottom margin (better to overlap the highlight than disappear). + */ + +/** Distance in CSS pixels between the highlight and the floating label. */ +export const LABEL_GAP = 4; +/** Minimum padding the label keeps from any viewport edge. */ +export const VIEWPORT_MARGIN = 4; + +export function computeLabelPosition(input: { + targetLeft: number; + targetTop: number; + targetBottom: number; + labelWidth: number; + labelHeight: number; + viewportWidth: number; + viewportHeight: number; +}): { x: number; y: number } { + const { targetLeft, targetTop, targetBottom, labelWidth, labelHeight } = input; + const { viewportWidth, viewportHeight } = input; + + let x = targetLeft; + const maxX = viewportWidth - labelWidth - VIEWPORT_MARGIN; + if (x > maxX) x = maxX; + if (x < VIEWPORT_MARGIN) x = VIEWPORT_MARGIN; + + let y = targetTop - labelHeight - LABEL_GAP; + if (y < VIEWPORT_MARGIN) { + y = targetBottom + LABEL_GAP; + if (y + labelHeight > viewportHeight - VIEWPORT_MARGIN) { + y = Math.max(VIEWPORT_MARGIN, viewportHeight - labelHeight - VIEWPORT_MARGIN); + } + } + + return { x, y }; +} diff --git a/apps/desktop/src/preview-pick-preload.test.ts b/apps/desktop/src/preview-pick-preload.test.ts new file mode 100644 index 00000000000..dbb6eb964aa --- /dev/null +++ b/apps/desktop/src/preview-pick-preload.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; + +import { computeLabelPosition } from "./preview-pick-label-position.ts"; + +const VIEWPORT = { viewportWidth: 1280, viewportHeight: 800 }; + +describe("computeLabelPosition", () => { + it("anchors to the element's top-left when there's room above and to the right", () => { + const { x, y } = computeLabelPosition({ + ...VIEWPORT, + targetLeft: 200, + targetTop: 200, + targetBottom: 240, + labelWidth: 120, + labelHeight: 18, + }); + expect(x).toBe(200); + // 200 (top) - 18 (height) - 4 (gap) + expect(y).toBe(200 - 18 - 4); + }); + + it("clamps left edge so the label stays inside the viewport", () => { + const { x } = computeLabelPosition({ + ...VIEWPORT, + targetLeft: -50, + targetTop: 200, + targetBottom: 240, + labelWidth: 120, + labelHeight: 18, + }); + expect(x).toBe(4); + }); + + it("clamps right edge when the label would overflow the viewport (the bug we shipped)", () => { + const { x } = computeLabelPosition({ + ...VIEWPORT, + targetLeft: 1240, + targetTop: 200, + targetBottom: 240, + labelWidth: 200, + labelHeight: 18, + }); + // viewportWidth (1280) - labelWidth (200) - margin (4) = 1076 + expect(x).toBe(1076); + }); + + it("flips the label below the element when there's no room above", () => { + const { y } = computeLabelPosition({ + ...VIEWPORT, + targetLeft: 200, + targetTop: 4, + targetBottom: 44, + labelWidth: 120, + labelHeight: 18, + }); + // labelY = 4 - 18 - 4 = -18 → flip → 44 + 4 = 48 + expect(y).toBe(48); + }); + + it("pins to the bottom margin when the element fills the viewport (no room above OR below)", () => { + const { y } = computeLabelPosition({ + ...VIEWPORT, + targetLeft: 200, + targetTop: 0, + targetBottom: 800, + labelWidth: 120, + labelHeight: 18, + }); + // Above overflows top → flip below = 800 + 4 = 804 → also overflows + // bottom → pin to viewportHeight - labelHeight - margin = 778. + expect(y).toBe(800 - 18 - 4); + }); + + it("never returns a negative coordinate", () => { + const { x, y } = computeLabelPosition({ + ...VIEWPORT, + targetLeft: -1000, + targetTop: -1000, + targetBottom: -900, + labelWidth: 5000, + labelHeight: 5000, + }); + expect(x).toBeGreaterThanOrEqual(0); + expect(y).toBeGreaterThanOrEqual(0); + }); +}); diff --git a/apps/desktop/src/preview-pick-preload.ts b/apps/desktop/src/preview-pick-preload.ts new file mode 100644 index 00000000000..9f1fee9da15 --- /dev/null +++ b/apps/desktop/src/preview-pick-preload.ts @@ -0,0 +1,419 @@ +/** + * Preview pick preload — runs in the isolated world of the Chromium + * `` that hosts the in-app browser. Loaded via the + * `` attribute set by the renderer. + * + * Responsibilities: + * + * 1. Listen for `preview:start-pick` from main (sent through + * `WebContents.send`, received here via `ipcRenderer.on`). + * 2. Mount a minimal blue-highlight element picker on the page. + * 3. On click → call `react-grab/primitives.getElementContext` and bubble + * a `PickedElementPayload` to main via `ipcRenderer.send`. Main resolves + * the in-flight `pickElement(tabId)` promise via its per-WebContents + * `wc.ipc.on(...)` listener. + * 4. Tear down the picker on Escape, blur, navigation, or explicit cancel. + * + * Design notes: + * + * - We never modify the page's DOM tree — the highlight + crosshair cursor + * live on a single fixed-position overlay layer that we own. This keeps + * us safe against pages that reset DOM or do MutationObserver tracking. + * - The overlay uses `pointer-events: none` so clicks fall through to the + * real element behind it; we do hit-testing with `elementFromPoint`. + * - Only a single pick session is ever active per webview; re-activating + * silently replaces the previous session. + * - Cancellations triggered BY MAIN (CANCEL_PICK_CHANNEL, or a follow-up + * START_PICK_CHANNEL that supersedes the current session) tear down + * silently — they do NOT echo a `null` ELEMENT_PICKED back, because main + * already knows it cancelled and would otherwise resolve the freshly- + * registered next-pick listener with that stale `null`. Cancellations + * triggered by the USER (Escape, beforeunload, click on empty) DO send + * `null` back so main can resolve the in-flight pick promise. + */ +import { ipcRenderer } from "electron"; +import { getElementContext } from "react-grab/primitives"; +import type { PickedElementPayload, PickedElementStackFrame } from "@t3tools/contracts"; + +import { computeLabelPosition } from "./preview-pick-label-position.ts"; + +const START_PICK_CHANNEL = "preview:start-pick"; +const CANCEL_PICK_CHANNEL = "preview:cancel-pick"; +const ELEMENT_PICKED_CHANNEL = "preview:element-picked"; + +const HIGHLIGHT_COLOR = "rgba(37, 99, 235, 0.9)"; // blue-600 +const HIGHLIGHT_FILL = "rgba(37, 99, 235, 0.16)"; +const LABEL_BG = "rgba(37, 99, 235, 0.96)"; +const Z_INDEX_OVERLAY = 2147483646; + +interface PickSession { + readonly overlay: HTMLDivElement; + readonly outline: HTMLDivElement; + readonly label: HTMLDivElement; + /** + * Tear down listeners + DOM WITHOUT notifying main. Used when main itself + * initiated the cancel (CANCEL_PICK_CHANNEL) or when a follow-up startPick + * supersedes us — main is already waiting on a fresh listener and would + * otherwise resolve it with the stale `null` we'd send. + */ + readonly teardownSilent: () => void; +} + +let activeSession: PickSession | null = null; + +function endActiveSession(): void { + if (!activeSession) return; + const session = activeSession; + activeSession = null; + // Supersession from a new startPick is a main-initiated transition: tear + // down silently so the new pick's main-side listener doesn't receive a + // ghost `null` from the old session. + session.teardownSilent(); +} + +interface OverlayHandles { + readonly overlay: HTMLDivElement; + readonly outline: HTMLDivElement; + readonly label: HTMLDivElement; + readonly destroyDom: () => void; +} + +function createOverlay(): OverlayHandles { + const overlay = document.createElement("div"); + overlay.setAttribute("data-t3code-pick-overlay", ""); + overlay.style.cssText = [ + "position:fixed", + "inset:0", + "z-index:" + String(Z_INDEX_OVERLAY), + "pointer-events:none", + "cursor:crosshair", + // Some apps register `pointer-events: auto !important` on body — using a + // dedicated overlay element ensures we don't fight with that. + ].join(";"); + + const outline = document.createElement("div"); + outline.setAttribute("data-t3code-pick-outline", ""); + outline.style.cssText = [ + "position:absolute", + "left:0", + "top:0", + "width:0", + "height:0", + "border:2px solid " + HIGHLIGHT_COLOR, + "background:" + HIGHLIGHT_FILL, + "border-radius:2px", + "box-shadow:0 0 0 1px rgba(255,255,255,0.6) inset", + "transition:none", + "display:none", + "pointer-events:none", + ].join(";"); + + const label = document.createElement("div"); + label.setAttribute("data-t3code-pick-label", ""); + // `top:0; left:0` so transform translate() positions are absolute viewport + // coordinates (we re-anchor the label every paint via a single transform). + // `max-width: calc(100vw - 8px)` + ellipsis prevents an overly long + // tag#id.class string from overflowing the viewport horizontally before we + // even get to the clamp logic. + label.style.cssText = [ + "position:absolute", + "left:0", + "top:0", + "padding:2px 6px", + "background:" + LABEL_BG, + "color:white", + "font:600 11px/1.2 ui-sans-serif,system-ui,-apple-system,sans-serif", + "border-radius:3px", + "pointer-events:none", + "white-space:nowrap", + "max-width:calc(100vw - 8px)", + "overflow:hidden", + "text-overflow:ellipsis", + "box-shadow:0 1px 2px rgba(0,0,0,0.25)", + "display:none", + ].join(";"); + + overlay.appendChild(outline); + overlay.appendChild(label); + document.documentElement.appendChild(overlay); + + // Force the crosshair cursor across the whole page even though we set it + // on the overlay (because pointer-events: none means it's never the + // cursor target). We add a stylesheet rule and revert on cleanup. + const styleEl = document.createElement("style"); + styleEl.textContent = `html[data-t3code-picking="1"], html[data-t3code-picking="1"] *, html[data-t3code-picking="1"] *::before, html[data-t3code-picking="1"] *::after { cursor: crosshair !important; }`; + document.documentElement.appendChild(styleEl); + document.documentElement.setAttribute("data-t3code-picking", "1"); + + const destroyDom = (): void => { + overlay.remove(); + styleEl.remove(); + document.documentElement.removeAttribute("data-t3code-picking"); + }; + + return { overlay, outline, label, destroyDom }; +} + +/** + * Resolve the element under the cursor while ignoring our own overlay. + * We render at z-index 2147483646 with `pointer-events: none`, which means + * `elementFromPoint` already skips the overlay — but we double-guard against + * pages that mutate `pointer-events` via MutationObservers. + */ +function pickFromPoint(clientX: number, clientY: number): Element | null { + const candidates = document.elementsFromPoint(clientX, clientY); + for (const candidate of candidates) { + if (!(candidate instanceof Element)) continue; + if (candidate.hasAttribute("data-t3code-pick-overlay")) continue; + if (candidate.hasAttribute("data-t3code-pick-outline")) continue; + if (candidate.hasAttribute("data-t3code-pick-label")) continue; + if (candidate === document.documentElement) continue; + if (candidate === document.body) continue; + return candidate; + } + return null; +} + +function describeRawElement(element: Element): string { + const tag = element.tagName.toLowerCase(); + const id = element.id ? `#${element.id}` : ""; + const className = + element instanceof HTMLElement && typeof element.className === "string" + ? element.className + .trim() + .split(/\s+/) + .filter((token) => token.length > 0) + .slice(0, 2) + .map((token) => `.${token}`) + .join("") + : ""; + return `${tag}${id}${className}`; +} + +/** + * Per-session cache of the resolved React component name for elements we've + * already inspected. Stores `null` when react-grab couldn't find a component + * (raw HTML / unmounted) so repeat hovers don't re-pay the async lookup. + */ +const componentNameCache = new WeakMap(); +const componentNameInFlight = new WeakSet(); + +function describeElement(element: Element): string { + const cached = componentNameCache.get(element); + if (cached) return `<${cached}> ${describeRawElement(element)}`; + return describeRawElement(element); +} + +function paintOutline(handles: OverlayHandles, element: Element): void { + const rect = element.getBoundingClientRect(); + if (rect.width === 0 && rect.height === 0) { + handles.outline.style.display = "none"; + handles.label.style.display = "none"; + return; + } + handles.outline.style.display = "block"; + handles.outline.style.transform = `translate(${rect.left}px, ${rect.top}px)`; + handles.outline.style.width = `${rect.width}px`; + handles.outline.style.height = `${rect.height}px`; + + // Two-pass label paint: first apply the new text and force-block display + // so we can measure the rendered size, then clamp to the viewport and + // flip below the element when there isn't room above. + const text = describeElement(element); + if (handles.label.textContent !== text) { + handles.label.textContent = text; + } + handles.label.style.display = "block"; + + const labelRect = handles.label.getBoundingClientRect(); + const { x, y } = computeLabelPosition({ + targetLeft: rect.left, + targetTop: rect.top, + targetBottom: rect.bottom, + labelWidth: labelRect.width, + labelHeight: labelRect.height, + viewportWidth: document.documentElement.clientWidth || window.innerWidth || labelRect.width, + viewportHeight: document.documentElement.clientHeight || window.innerHeight || labelRect.height, + }); + handles.label.style.transform = `translate(${x}px, ${y}px)`; +} + +/** + * Kick off (at most once per element) an async react-grab lookup for the + * component name. When the answer arrives, repaint the label iff the element + * is still under the cursor — otherwise the user has moved on and a stale + * paint would clobber the next element's label. + */ +function ensureComponentName(element: Element, onResolve: (resolvedFor: Element) => void): void { + if (componentNameCache.has(element)) return; + if (componentNameInFlight.has(element)) return; + componentNameInFlight.add(element); + void getElementContext(element) + .then((context) => { + const trimmed = context.componentName?.trim(); + componentNameCache.set(element, trimmed && trimmed.length > 0 ? trimmed : null); + }) + .catch(() => { + componentNameCache.set(element, null); + }) + .finally(() => { + componentNameInFlight.delete(element); + onResolve(element); + }); +} + +function clearOutline(handles: OverlayHandles): void { + handles.outline.style.display = "none"; + handles.label.style.display = "none"; +} + +function toStackFrame(frame: { + functionName?: string; + fileName?: string; + lineNumber?: number; + columnNumber?: number; +}): PickedElementStackFrame { + return { + functionName: frame.functionName ?? null, + fileName: frame.fileName ?? null, + lineNumber: frame.lineNumber ?? null, + columnNumber: frame.columnNumber ?? null, + }; +} + +async function captureElement(element: Element): Promise { + try { + const context = await getElementContext(element); + const stack = (context.stack ?? []).map((frame) => toStackFrame(frame)); + return { + pageUrl: location.href, + pageTitle: document.title?.trim() ? document.title.trim() : null, + tagName: element.tagName.toLowerCase(), + selector: context.selector, + htmlPreview: context.htmlPreview ?? "", + componentName: context.componentName, + source: stack[0] ?? null, + stack, + styles: context.styles ?? "", + pickedAt: new Date().toISOString(), + }; + } catch { + return null; + } +} + +function startPick(): void { + endActiveSession(); + if (typeof document === "undefined" || !document.body) { + ipcRenderer.send(ELEMENT_PICKED_CHANNEL, null); + return; + } + + const handles = createOverlay(); + let lastTarget: Element | null = null; + let resolved = false; + + // Tear down listeners + DOM. Does NOT notify main. `notifyMain` controls + // whether we additionally bubble a `null` ELEMENT_PICKED back so main can + // resolve the in-flight pick promise (only true for USER-initiated cancels; + // main already knows about its own cancellations). + const teardown = (notifyMain: boolean, payload: PickedElementPayload | null): void => { + if (resolved) return; + resolved = true; + window.removeEventListener("mousemove", onMove, { capture: true } as EventListenerOptions); + window.removeEventListener("click", onClick, { capture: true } as EventListenerOptions); + window.removeEventListener("keydown", onKey, { capture: true } as EventListenerOptions); + window.removeEventListener("scroll", onScrollOrResize, { + capture: true, + } as EventListenerOptions); + window.removeEventListener("resize", onScrollOrResize); + window.removeEventListener("beforeunload", onBeforeUnload); + ipcRenderer.off(CANCEL_PICK_CHANNEL, onMainCancel); + handles.destroyDom(); + if (notifyMain) { + // `ipcRenderer.send` (NOT `sendToHost`) reaches main, where the + // PreviewViewManager's per-WebContents `wc.ipc.on(...)` listener + // resolves the in-flight pick promise. + ipcRenderer.send(ELEMENT_PICKED_CHANNEL, payload); + } + }; + + // Re-paint when an in-flight component-name lookup resolves, but only if + // the same element is still under the cursor — otherwise the user moved on + // and we'd clobber the next element's label. + const onComponentNameResolved = (resolvedFor: Element): void => { + if (lastTarget !== resolvedFor) return; + paintOutline(handles, resolvedFor); + }; + + const onMove = (event: MouseEvent): void => { + const target = pickFromPoint(event.clientX, event.clientY); + if (target === lastTarget) return; + lastTarget = target; + if (target) { + paintOutline(handles, target); + ensureComponentName(target, onComponentNameResolved); + } else { + clearOutline(handles); + } + }; + + const onScrollOrResize = (): void => { + if (lastTarget) paintOutline(handles, lastTarget); + }; + + const onClick = (event: MouseEvent): void => { + event.preventDefault(); + event.stopPropagation(); + const target = pickFromPoint(event.clientX, event.clientY); + if (!target) { + teardown(true, null); + return; + } + void captureElement(target).then((payload) => teardown(true, payload)); + }; + + const onKey = (event: KeyboardEvent): void => { + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + teardown(true, null); + } + }; + + const onBeforeUnload = (): void => { + teardown(true, null); + }; + + // Cancellation initiated by main (CANCEL_PICK_CHANNEL). Tear down silently + // — main already knows it cancelled and is either done waiting or about to + // register a fresh listener for a new pick. If we sent `null` here, that + // fresh listener would receive it and resolve the new pick instantly (the + // C1 race we previously hit). + const onMainCancel = (): void => { + teardown(false, null); + }; + + // Capture-phase listeners on `window` to outrun page handlers that + // `stopPropagation()` early. `passive: false` because we need preventDefault. + window.addEventListener("mousemove", onMove, { capture: true, passive: true }); + window.addEventListener("click", onClick, { capture: true }); + window.addEventListener("keydown", onKey, { capture: true }); + window.addEventListener("scroll", onScrollOrResize, { capture: true, passive: true }); + window.addEventListener("resize", onScrollOrResize, { passive: true }); + window.addEventListener("beforeunload", onBeforeUnload); + ipcRenderer.on(CANCEL_PICK_CHANNEL, onMainCancel); + + // Hand a "silent teardown" to `activeSession` so that a follow-up + // `startPick()` can supersede us without echoing `null` back to main. + activeSession = { + overlay: handles.overlay, + outline: handles.outline, + label: handles.label, + teardownSilent: () => teardown(false, null), + }; +} + +ipcRenderer.on(START_PICK_CHANNEL, () => { + startPick(); +}); diff --git a/apps/desktop/src/preview-view-manager.ts b/apps/desktop/src/preview-view-manager.ts index 6e27850bd6f..127fb216b00 100644 --- a/apps/desktop/src/preview-view-manager.ts +++ b/apps/desktop/src/preview-view-manager.ts @@ -5,10 +5,22 @@ * elements live in the renderer; we only attach listeners and forward state * here). Single layer-scoped browser session partition. */ +import type { PickedElementPayload } from "@t3tools/contracts"; import { normalizePreviewUrl } from "@t3tools/shared/preview"; import { type BrowserWindow, type Session, session, webContents } from "electron"; +import { isPickedElementPayload } from "./picked-element-payload.ts"; + const PREVIEW_PARTITION = "persist:t3code-preview"; +const START_PICK_CHANNEL = "preview:start-pick"; +const CANCEL_PICK_CHANNEL = "preview:cancel-pick"; +const ELEMENT_PICKED_CHANNEL = "preview:element-picked"; + +// Re-export the guest webview security posture from its dedicated module so +// the constant is unit-testable in isolation. See +// `preview-webview-preferences.ts` for the full security rationale. +export { PREVIEW_WEBVIEW_PREFERENCES } from "./preview-webview-preferences.ts"; +import { PREVIEW_WEBVIEW_PREFERENCES } from "./preview-webview-preferences.ts"; export type PreviewNavStatus = | { kind: "Idle" } @@ -63,6 +75,11 @@ interface ManagedListeners { failed: (event: Event, code: number, description: string) => void; } +interface PickSession { + readonly resolve: (payload: PickedElementPayload | null) => void; + readonly cleanup: () => void; +} + const APP_FORWARDED_SHORTCUTS: ReadonlyArray<{ key: string; meta: boolean; @@ -85,6 +102,8 @@ export class PreviewViewManager { private readonly attached = new Map(); private browserSession: Session | null = null; private readonly listeners = new Set(); + /** In-flight element-pick sessions, keyed by tabId (one pick per tab). */ + private readonly pickSessions = new Map(); setMainWindow(window: BrowserWindow): void { this.mainWindow = window; @@ -94,6 +113,16 @@ export class PreviewViewManager { return PREVIEW_PARTITION; } + /** + * Returns the canonical `` string. Renderer + * fetches this via the desktop bridge so the security posture for guest + * surfaces lives in exactly one place (here) and any future guest webview + * (docs panel, OAuth popup, etc.) can opt in by calling the same getter. + */ + getWebviewPreferences(): string { + return PREVIEW_WEBVIEW_PREFERENCES; + } + getBrowserSession(): Session { if (this.browserSession) return this.browserSession; const sess = session.fromPartition(PREVIEW_PARTITION); @@ -130,6 +159,7 @@ export class PreviewViewManager { closeTab(tabId: string): void { const tab = this.tabs.get(tabId); if (!tab) return; + this.cancelPickElement(tabId); if (tab.webContentsId != null) { this.detachListeners(tab.webContentsId); } @@ -169,6 +199,10 @@ export class PreviewViewManager { } if (tab.webContentsId != null && tab.webContentsId !== webContentsId) { this.detachListeners(tab.webContentsId); + // Any in-flight pick is bound to the OLD WebContents via `wc.ipc.on`. + // Cancel it so the toggle button doesn't get stuck pressed waiting + // forever for a click on a webview that no longer hosts the listener. + this.cancelPickElement(tabId); } this.attachListeners(tabId, wc); // Restore the persisted zoom factor onto the freshly-attached WebContents @@ -254,6 +288,96 @@ export class PreviewViewManager { await sess.clearCache(); } + /** + * Activate the in-page element picker for `tabId`. Resolves with the + * `PickedElementPayload` the preload script bubbles back via `ipc-message`, + * or `null` when the user cancels (Escape, navigation, manual cancel). + * + * Exactly one pick session may be active per tab — re-invoking while a + * pick is in flight cleanly resolves the old session with `null` first. + */ + async pickElement(tabId: string): Promise { + const wc = this.requireWebContents(tabId); + this.cancelPickElement(tabId); + return new Promise((resolve) => { + // `wc.ipc` is the per-WebContents IpcMain that receives messages the + // webview's preload sends with `ipcRenderer.send(...)`. We use that + // (not the global `wc.on("ipc-message", ...)`, which is for + // `sendToHost` and only fires on the host renderer's + // element) so the main process actually observes the picked payload. + const cleanup = () => { + wc.ipc.removeListener(ELEMENT_PICKED_CHANNEL, onMessage); + wc.off("destroyed", onDestroyed); + wc.off("did-start-navigation", onNavigated); + this.pickSessions.delete(tabId); + }; + const session: PickSession = { resolve, cleanup }; + const settle = (payload: PickedElementPayload | null) => { + if (this.pickSessions.get(tabId) !== session) return; + cleanup(); + resolve(payload); + }; + const onMessage = (_event: Electron.IpcMainEvent, ...args: unknown[]): void => { + const payload = args[0]; + if (payload == null) { + settle(null); + return; + } + if (!isPickedElementPayload(payload)) { + settle(null); + return; + } + settle(payload); + }; + const onDestroyed = () => settle(null); + const onNavigated = () => settle(null); + wc.ipc.on(ELEMENT_PICKED_CHANNEL, onMessage); + wc.once("destroyed", onDestroyed); + // A page navigation (incl. SPA → same-document) tears down the + // preload's listeners, so we cancel proactively to avoid hanging. + wc.once("did-start-navigation", onNavigated); + this.pickSessions.set(tabId, session); + // Force-focus the guest webContents BEFORE sending start-pick. Without + // this, Electron's input router will deliver the user's first + // mousemove/click to the host renderer (where the pick button lives) + // instead of to the guest's window listeners — manifest as "the + // picker overlay never appears on remote pages I haven't clicked + // into yet". The renderer-side handler in `PreviewView` is responsible + // for restoring focus to the previously-active host element when the + // pick promise resolves so the user's textarea cursor isn't lost. + try { + if (!wc.isFocused()) wc.focus(); + } catch { + // wc may be torn down; the next try/catch settles. + } + try { + wc.send(START_PICK_CHANNEL); + } catch { + settle(null); + } + }); + } + + cancelPickElement(tabId: string): void { + const session = this.pickSessions.get(tabId); + if (!session) return; + session.cleanup(); + // Best-effort: tell the page to dismiss the overlay even if it's still + // alive — keeps the next invoke fresh. + const tab = this.tabs.get(tabId); + if (tab?.webContentsId != null) { + const wc = webContents.fromId(tab.webContentsId); + if (wc && !wc.isDestroyed()) { + try { + wc.send(CANCEL_PICK_CHANNEL); + } catch { + // wc may have been torn down; nothing to clean up. + } + } + } + session.resolve(null); + } + zoomIn(tabId: string): void { this.applyZoom(tabId, (current) => nextZoomLevel(current, "in")); } diff --git a/apps/desktop/src/preview-webview-preferences.test.ts b/apps/desktop/src/preview-webview-preferences.test.ts new file mode 100644 index 00000000000..82e8a88ce02 --- /dev/null +++ b/apps/desktop/src/preview-webview-preferences.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; + +import { PREVIEW_WEBVIEW_PREFERENCES } from "./preview-webview-preferences.ts"; + +/** + * Mirrors Electron's webview attribute parser closely enough to catch the + * regressions we've already hit: + * + * - whitespace inside the comma-separated list silently drops keys (so + * `" sandbox=true"` becomes an unknown key and Electron falls back to + * defaults — re-opening the Node-leak window we closed), + * - non-`true`/`false` values (`"yes"`, `"no"`, etc.) are kept as truthy + * strings and assigned to a boolean preference, which silently flips + * `contextIsolation=no` to ENABLED (then react-grab can't see the React + * DevTools hook and componentName resolution always returns null). + * + * The actual Electron parser does roughly: + * + * for (const pair of webpreferences.split(',')) { + * const [key, value] = pair.split('='); + * prefs[key] = value; // value left as a string + * } + * + * then later coerces booleans via `Boolean(value)`. Replicating that here + * keeps the test independent of Electron internals while still failing if + * we accidentally ship `"contextIsolation=no"` again. + */ +function parseWebPreferences(input: string): Record { + const out: Record = {}; + for (const pair of input.split(",")) { + if (pair !== pair.trim()) { + // Electron's parser doesn't trim; surface the bug as undefined-key. + out[pair] = pair.split("=")[1]; + continue; + } + const [key, value] = pair.split("="); + if (!key) continue; + out[key] = value; + } + return out; +} + +describe("PREVIEW_WEBVIEW_PREFERENCES", () => { + const parsed = parseWebPreferences(PREVIEW_WEBVIEW_PREFERENCES); + + it("contains exactly the three security-critical keys", () => { + expect(Object.keys(parsed).toSorted()).toEqual( + ["contextIsolation", "nodeIntegration", "sandbox"].toSorted(), + ); + }); + + it("uses canonical JS-boolean string literals (not yes/no, on/off, 1/0)", () => { + // `value="no"` is a TRUTHY string when assigned to webPreferences.X — so + // `contextIsolation="no"` would silently leave isolation ENABLED. Lock + // the values to `"true"` / `"false"` so the parser does the right thing. + for (const value of Object.values(parsed)) { + expect(value).toMatch(/^(true|false)$/); + } + }); + + it("disables context isolation (so react-grab can see the page's React DevTools hook)", () => { + expect(parsed["contextIsolation"]).toBe("false"); + }); + + it("keeps the renderer sandbox enabled (so the page cannot reach Node APIs)", () => { + expect(parsed["sandbox"]).toBe("true"); + }); + + it("disables nodeIntegration (defense in depth — page never gets Node)", () => { + expect(parsed["nodeIntegration"]).toBe("false"); + }); + + it("contains no whitespace (Electron's parser does not trim)", () => { + // Electron splits on `,` without trimming, so any whitespace would turn + // a key into an unknown one and silently drop the security flag. + expect(PREVIEW_WEBVIEW_PREFERENCES).not.toMatch(/\s/); + }); +}); diff --git a/apps/desktop/src/preview-webview-preferences.ts b/apps/desktop/src/preview-webview-preferences.ts new file mode 100644 index 00000000000..2d96bff456c --- /dev/null +++ b/apps/desktop/src/preview-webview-preferences.ts @@ -0,0 +1,42 @@ +/** + * webPreferences override applied to every preview `` element via + * its `webpreferences="..."` attribute. Single source of truth so all guest + * surfaces inherit the same security posture. + * + * Lives in its own electron-free module so the value is unit-testable + * without importing `preview-view-manager.ts` (which transitively imports + * `electron` and blows up under vitest). + * + * - `contextIsolation=false`: the picker preload needs to share `globalThis` + * with the page so react-grab/bippy can read the React DevTools hook + * (`__REACT_DEVTOOLS_GLOBAL_HOOK__`) and resolve component names. Without + * this every pick comes back with `componentName: null` even on dev React + * apps. + * - `sandbox=true`: keeps the OS-level renderer sandbox enabled. Critical + * when paired with `contextIsolation=false` — without sandbox, the preload + * has full Node access (`require`, `fs`, `child_process`, ...) and that + * `require` would land on the page's shared `globalThis`, giving any + * third-party page in the preview full Node + IPC access to the host. + * In sandboxed mode Electron still synthesizes the `electron` module for + * the preload's `import { ipcRenderer }` line, but no Node globals leak. + * - `nodeIntegration=false`: pinned for clarity (the page itself never gets + * Node access). + * + * Format notes (locked down by `preview-webview-preferences.test.ts`): + * - Whitespace-free. Electron's webpreferences parser splits on `,` and + * does not trim, so a leading space would turn a key into an unknown one + * and silently drop it. + * - Values are JS-boolean strings (`true`/`false`) — `yes`/`no` are not + * special-cased by the parser; `value="no"` becomes the truthy STRING + * `"no"` when assigned to a boolean webPreferences key. Most critically, + * `contextIsolation="no"` is truthy → contextIsolation stays ENABLED → + * react-grab can't see the React DevTools hook. + * + * Defense in depth: `apps/desktop/src/main.ts` also runs a + * `will-attach-webview` handler that force-sets `sandbox: true` and + * `nodeIntegration*: false` on the actual webPreferences object, gated on + * the preview partition, so even if this string is ever wrong, the + * security-critical flags can't regress on preview tabs. + */ +export const PREVIEW_WEBVIEW_PREFERENCES = + "contextIsolation=false,sandbox=true,nodeIntegration=false"; diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 6a498136749..a19eb0ce263 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -198,6 +198,16 @@ const make = Effect.gen(function* () { }); previewViewManager.setMainWindow(window); + window.webContents.on("will-attach-webview", (event, webPreferences, params) => { + if (params.partition !== previewViewManager.getBrowserPartition()) { + event.preventDefault(); + return; + } + webPreferences.sandbox = true; + webPreferences.nodeIntegration = false; + webPreferences.nodeIntegrationInSubFrames = false; + webPreferences.contextIsolation = false; + }); window.webContents.on("context-menu", (event, params) => { event.preventDefault(); diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index d42d2230946..4c1e9f8c153 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -56,5 +56,15 @@ export default defineConfig({ define: publicConfigDefine, entry: ["src/preload.ts"], }, + { + format: "cjs", + outDir: "dist-electron", + sourcemap: true, + outExtensions: () => ({ js: ".cjs" }), + entry: ["src/preview-pick-preload.ts"], + deps: { + alwaysBundle: (id) => id === "react-grab" || id.startsWith("react-grab/"), + }, + }, ], }); diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 13bd175e0c9..e08563b234b 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -73,6 +73,30 @@ describe("deriveComposerSendState", () => { expect(state.expiredTerminalContextCount).toBe(1); expect(state.hasSendableContent).toBe(true); }); + + it("treats element contexts as sendable content (no text, no images, no terminals)", () => { + const state = deriveComposerSendState({ + prompt: "", + imageCount: 0, + terminalContexts: [], + elementContextCount: 1, + }); + + expect(state.trimmedPrompt).toBe(""); + expect(state.expiredTerminalContextCount).toBe(0); + expect(state.hasSendableContent).toBe(true); + }); + + it("does NOT treat zero element contexts as sendable", () => { + expect( + deriveComposerSendState({ + prompt: "", + imageCount: 0, + terminalContexts: [], + elementContextCount: 0, + }).hasSendableContent, + ).toBe(false); + }); }); describe("buildExpiredTerminalContextToastCopy", () => { diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index de69c573046..e1417773c32 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -185,6 +185,12 @@ export function deriveComposerSendState(options: { prompt: string; imageCount: number; terminalContexts: ReadonlyArray; + /** + * Optional element-pick attachment count. Element contexts contribute to + * "sendable content" exactly like images and (text-bearing) terminal + * contexts do: a prompt of just element chips is still a valid send. + */ + elementContextCount?: number; }): { trimmedPrompt: string; sendableTerminalContexts: TerminalContextDraft[]; @@ -195,12 +201,16 @@ export function deriveComposerSendState(options: { const sendableTerminalContexts = filterTerminalContextsWithText(options.terminalContexts); const expiredTerminalContextCount = options.terminalContexts.length - sendableTerminalContexts.length; + const elementContextCount = options.elementContextCount ?? 0; return { trimmedPrompt, sendableTerminalContexts, expiredTerminalContextCount, hasSendableContent: - trimmedPrompt.length > 0 || options.imageCount > 0 || sendableTerminalContexts.length > 0, + trimmedPrompt.length > 0 || + options.imageCount > 0 || + sendableTerminalContexts.length > 0 || + elementContextCount > 0, }; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c417f7fd314..adc40747f74 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -149,6 +149,11 @@ import { } from "../lib/terminalContext"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../terminalSessionState"; +import { + appendElementContextsToPrompt, + type ElementContextDraft, + formatElementContextLabel, +} from "../lib/elementContext"; import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; @@ -873,6 +878,9 @@ export default function ChatView(props: ChatViewProps) { const setComposerDraftTerminalContexts = useComposerDraftStore( (store) => store.setTerminalContexts, ); + const setComposerDraftElementContexts = useComposerDraftStore( + (store) => store.setElementContexts, + ); const setComposerDraftModelSelection = useComposerDraftStore((store) => store.setModelSelection); const setComposerDraftRuntimeMode = useComposerDraftStore((store) => store.setRuntimeMode); const setComposerDraftInteractionMode = useComposerDraftStore( @@ -897,6 +905,7 @@ export default function ChatView(props: ChatViewProps) { const promptRef = useRef(""); const composerImagesRef = useRef([]); const composerTerminalContextsRef = useRef([]); + const composerElementContextsRef = useRef([]); const localComposerRef = useRef(null); const composerRef = useComposerHandleContext() ?? localComposerRef; const [showScrollToBottom, setShowScrollToBottom] = useState(false); @@ -2982,6 +2991,7 @@ export default function ChatView(props: ChatViewProps) { const { images: composerImages, terminalContexts: composerTerminalContexts, + elementContexts: composerElementContexts, selectedProvider: ctxSelectedProvider, selectedModel: ctxSelectedModel, selectedProviderModels: ctxSelectedProviderModels, @@ -2998,6 +3008,7 @@ export default function ChatView(props: ChatViewProps) { prompt: promptForSend, imageCount: composerImages.length, terminalContexts: composerTerminalContexts, + elementContextCount: composerElementContexts.length, }); if (showPlanFollowUpPrompt && activeProposedPlan) { const followUp = resolvePlanFollowUpSubmission({ @@ -3014,7 +3025,9 @@ export default function ChatView(props: ChatViewProps) { return; } const standaloneSlashCommand = - composerImages.length === 0 && sendableComposerTerminalContexts.length === 0 + composerImages.length === 0 && + sendableComposerTerminalContexts.length === 0 && + composerElementContexts.length === 0 ? parseStandaloneComposerSlashCommand(trimmed) : null; if (standaloneSlashCommand) { @@ -3062,9 +3075,10 @@ export default function ChatView(props: ChatViewProps) { const composerImagesSnapshot = [...composerImages]; const composerTerminalContextsSnapshot = [...sendableComposerTerminalContexts]; - const messageTextForSend = appendTerminalContextsToPrompt( - promptForSend, - composerTerminalContextsSnapshot, + const composerElementContextsSnapshot = [...composerElementContexts]; + const messageTextForSend = appendElementContextsToPrompt( + appendTerminalContextsToPrompt(promptForSend, composerTerminalContextsSnapshot), + composerElementContextsSnapshot, ); const messageIdForSend = newMessageId(); const messageCreatedAt = new Date().toISOString(); @@ -3145,6 +3159,8 @@ export default function ChatView(props: ChatViewProps) { titleSeed = `Image: ${firstComposerImageName}`; } else if (composerTerminalContextsSnapshot.length > 0) { titleSeed = formatTerminalContextLabel(composerTerminalContextsSnapshot[0]!); + } else if (composerElementContextsSnapshot.length > 0) { + titleSeed = formatElementContextLabel(composerElementContextsSnapshot[0]!); } else { titleSeed = "New thread"; } @@ -3230,7 +3246,8 @@ export default function ChatView(props: ChatViewProps) { !turnStartSucceeded && promptRef.current.length === 0 && composerImagesRef.current.length === 0 && - composerTerminalContextsRef.current.length === 0 + composerTerminalContextsRef.current.length === 0 && + composerElementContextsRef.current.length === 0 ) { setOptimisticUserMessages((existing) => { const removed = existing.filter((message) => message.id === messageIdForSend); @@ -3244,9 +3261,11 @@ export default function ChatView(props: ChatViewProps) { const retryComposerImages = composerImagesSnapshot.map(cloneComposerImageForRetry); composerImagesRef.current = retryComposerImages; composerTerminalContextsRef.current = composerTerminalContextsSnapshot; + composerElementContextsRef.current = composerElementContextsSnapshot; setComposerDraftPrompt(composerDraftTarget, promptForSend); addComposerDraftImages(composerDraftTarget, retryComposerImages); setComposerDraftTerminalContexts(composerDraftTarget, composerTerminalContextsSnapshot); + setComposerDraftElementContexts(composerDraftTarget, composerElementContextsSnapshot); composerRef.current?.resetCursorState({ cursor: collapseExpandedComposerCursor(promptForSend, promptForSend.length), prompt: promptForSend, @@ -4045,6 +4064,7 @@ export default function ChatView(props: ChatViewProps) { promptRef={promptRef} composerImagesRef={composerImagesRef} composerTerminalContextsRef={composerTerminalContextsRef} + composerElementContextsRef={composerElementContextsRef} shouldAutoScrollRef={isAtEndRef} scheduleStickToBottom={scrollToEnd} onSend={onSend} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 8d89ccdd396..5696c6f069c 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -17,6 +17,10 @@ import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, } from "@t3tools/contracts"; +import { + connectionStatusText, + type EnvironmentConnectionPresentation, +} from "@t3tools/client-runtime/connection"; import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; import { @@ -53,6 +57,8 @@ import { removeInlineTerminalContextPlaceholder, } from "../../lib/terminalContext"; import { useComposerPathSearch } from "../../lib/composerPathSearchState"; +import { type ElementContextDraft } from "../../lib/elementContext"; +import { ComposerPendingElementContexts } from "./ComposerPendingElementContexts"; import { shouldUseCompactComposerPrimaryActions, shouldUseCompactComposerFooter, @@ -400,6 +406,7 @@ export interface ChatComposerHandle { prompt: string; images: ComposerImageAttachment[]; terminalContexts: TerminalContextDraft[]; + elementContexts: ElementContextDraft[]; selectedPromptEffort: string | null; selectedModelOptionsForDispatch: unknown; selectedModelSelection: ModelSelection; @@ -434,7 +441,7 @@ export interface ChatComposerProps { isPreparingWorktree: boolean; environmentUnavailable: { readonly label: string; - readonly connectionState: "connecting" | "disconnected" | "error"; + readonly connection: EnvironmentConnectionPresentation; } | null; // Pending approvals / inputs @@ -486,6 +493,7 @@ export interface ChatComposerProps { promptRef: React.RefObject; composerImagesRef: React.RefObject; composerTerminalContextsRef: React.RefObject; + composerElementContextsRef: React.RefObject; composerRef: React.RefObject; // Scroll @@ -576,6 +584,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerRef, composerImagesRef, composerTerminalContextsRef, + composerElementContextsRef, shouldAutoScrollRef, scheduleStickToBottom, onSend, @@ -605,6 +614,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const prompt = composerDraft.prompt; const composerImages = composerDraft.images; const composerTerminalContexts = composerDraft.terminalContexts; + const composerElementContexts = composerDraft.elementContexts; const nonPersistedComposerImageIds = composerDraft.nonPersistedImageIds; const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); @@ -620,6 +630,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const setComposerDraftTerminalContexts = useComposerDraftStore( (store) => store.setTerminalContexts, ); + const removeComposerDraftElementContext = useComposerDraftStore( + (store) => store.removeElementContext, + ); const clearComposerDraftPersistedAttachments = useComposerDraftStore( (store) => store.clearPersistedAttachments, ); @@ -880,8 +893,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) prompt, imageCount: composerImages.length, terminalContexts: composerTerminalContexts, + elementContextCount: composerElementContexts.length, }), - [composerImages.length, composerTerminalContexts, prompt], + [composerElementContexts.length, composerImages.length, composerTerminalContexts, prompt], ); // ------------------------------------------------------------------ @@ -1170,6 +1184,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerTerminalContextsRef.current = composerTerminalContexts; }, [composerTerminalContexts, composerTerminalContextsRef]); + useEffect(() => { + composerElementContextsRef.current = composerElementContexts; + }, [composerElementContexts, composerElementContextsRef]); + // ------------------------------------------------------------------ // Composer menu highlight sync // ------------------------------------------------------------------ @@ -1969,6 +1987,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) prompt: promptRef.current, images: composerImagesRef.current, terminalContexts: composerTerminalContextsRef.current, + elementContexts: composerElementContextsRef.current, selectedPromptEffort, selectedModelOptionsForDispatch, selectedModelSelection, @@ -1986,6 +2005,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) promptRef, composerImagesRef, composerTerminalContextsRef, + composerElementContextsRef, isConnecting, isComposerApprovalState, pendingUserInputs.length, @@ -2223,6 +2243,19 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
)} + {!isComposerCollapsedMobile && + !isComposerApprovalState && + pendingUserInputs.length === 0 && + composerElementContexts.length > 0 && ( + + removeComposerDraftElementContext(composerDraftTarget, contextId) + } + className="mb-3" + /> + )} + {!isComposerCollapsedMobile && !isComposerApprovalState && pendingUserInputs.length === 0 && @@ -2321,11 +2354,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) : showPlanFollowUpPrompt && activeProposedPlan ? "Add feedback to refine the plan, or leave this blank to implement it" : environmentUnavailable - ? `${environmentUnavailable.label} is ${ - environmentUnavailable.connectionState === "connecting" - ? "connecting" - : "disconnected" - }` + ? `${environmentUnavailable.label}: ${connectionStatusText( + environmentUnavailable.connection, + )}` : phase === "disconnected" ? "Ask for follow-up changes or attach images" : "Ask anything, @tag files/folders, $use skills, or / for commands" diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx index 27a42aa5467..d9bb28f696a 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx @@ -17,7 +17,7 @@ import { createModelCapabilities, createModelSelection } from "@t3tools/shared/m import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu"; import { TraitsMenuContent } from "./TraitsPicker"; -import { useComposerDraftStore } from "../../composerDraftStore"; +import { createEmptyThreadDraft, useComposerDraftStore } from "../../composerDraftStore"; const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); @@ -60,18 +60,16 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str useComposerDraftStore.setState({ draftsByThreadKey: { + // Compose from the canonical empty-draft factory so adding a new + // ComposerThreadDraftState slice (e.g. a future attachment kind) doesn't + // silently break this stub via `Property X is missing in type ...`. [threadKey]: { + ...createEmptyThreadDraft(), prompt: props?.prompt ?? "", - images: [], - nonPersistedImageIds: [], - persistedAttachments: [], - terminalContexts: [], modelSelectionByProvider: { [instanceId]: createModelSelection(instanceId, model, props?.modelSelection?.options), }, activeProvider: instanceId, - runtimeMode: null, - interactionMode: null, }, }, draftThreadsByThreadKey: {}, diff --git a/apps/web/src/components/chat/ComposerPendingElementContexts.tsx b/apps/web/src/components/chat/ComposerPendingElementContexts.tsx new file mode 100644 index 00000000000..7373403a7c3 --- /dev/null +++ b/apps/web/src/components/chat/ComposerPendingElementContexts.tsx @@ -0,0 +1,95 @@ +import { MousePointerClick, X } from "lucide-react"; + +import { + COMPOSER_INLINE_CHIP_CLASS_NAME, + COMPOSER_INLINE_CHIP_DISMISS_BUTTON_CLASS_NAME, + COMPOSER_INLINE_CHIP_ICON_CLASS_NAME, + COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME, +} from "../composerInlineChip"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { cn } from "~/lib/utils"; +import { + type ElementContextDraft, + formatElementContextLabel, + formatElementContextSourceLabel, +} from "~/lib/elementContext"; + +interface ComposerPendingElementContextsProps { + contexts: ReadonlyArray; + onRemove: (contextId: string) => void; + className?: string; +} + +interface ComposerPendingElementContextChipProps { + context: ElementContextDraft; + onRemove: (contextId: string) => void; +} + +function buildTooltipContent(context: ElementContextDraft): string { + const lines: string[] = []; + lines.push(formatElementContextLabel(context)); + const source = formatElementContextSourceLabel(context); + if (source) lines.push(source); + if (context.pageUrl) lines.push(context.pageUrl); + if (context.selector) lines.push(context.selector); + if (context.htmlPreview.trim().length > 0) { + lines.push(""); + lines.push(context.htmlPreview.trim().slice(0, 600)); + } + return lines.join("\n"); +} + +export function ComposerPendingElementContextChip({ + context, + onRemove, +}: ComposerPendingElementContextChipProps) { + const label = formatElementContextLabel(context); + const sourceLabel = formatElementContextSourceLabel(context); + return ( + + + + {label} + {sourceLabel ? ( + + {sourceLabel} + + ) : null} + + + } + /> + + {buildTooltipContent(context)} + + + ); +} + +export function ComposerPendingElementContexts({ + contexts, + onRemove, + className, +}: ComposerPendingElementContextsProps) { + if (contexts.length === 0) return null; + return ( +
+ {contexts.map((context) => ( + + ))} +
+ ); +} diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 627213ef4f2..1267e935bb2 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -46,6 +46,7 @@ import { GlobeIcon, HammerIcon, MessageCircleIcon, + MousePointerClickIcon, MinusIcon, SquarePenIcon, TerminalIcon, @@ -76,6 +77,10 @@ import { deriveDisplayedUserMessageState, type ParsedTerminalContextEntry, } from "~/lib/terminalContext"; +import { + extractTrailingElementContexts, + type ParsedElementContextEntry, +} from "~/lib/elementContext"; import { cn } from "~/lib/utils"; import { useUiStateStore } from "~/uiStateStore"; import { type TimestampFormat } from "@t3tools/contracts/settings"; @@ -424,6 +429,7 @@ function UserTimelineRow({ row }: { row: Extract )} + {elementContextState.contexts.length > 0 ? ( +
+ {elementContextState.contexts.map((context, index) => ( + + ))} +
+ ) : null} } > - {formatShortTimestamp( - row.message.completedAt ?? row.message.createdAt, - ctx.timestampFormat, - )} + {formatShortTimestamp(row.message.updatedAt, ctx.timestampFormat)} - {formatChatTimestampTooltip( - row.message.completedAt ?? row.message.createdAt, - ctx.timestampFormat, - )} + {formatChatTimestampTooltip(row.message.updatedAt, ctx.timestampFormat)} )} @@ -899,6 +909,29 @@ const UserMessageTerminalContextInlineLabel = memo( }, ); +const UserMessageElementContextChip = memo(function UserMessageElementContextChip(props: { + context: ParsedElementContextEntry; +}) { + const tooltipText = props.context.body + ? `${props.context.header}\n${props.context.body}` + : props.context.header; + return ( + + + + {props.context.header} + + } + /> + + {tooltipText} + + + ); +}); + const MAX_COLLAPSED_USER_MESSAGE_LINES = 8; const MAX_COLLAPSED_USER_MESSAGE_LENGTH = 600; const COLLAPSED_USER_MESSAGE_FADE_HEIGHT_REM = 1.75; diff --git a/apps/web/src/components/preview/PreviewChromeRow.tsx b/apps/web/src/components/preview/PreviewChromeRow.tsx index 2780d6bf409..cdb46501de0 100644 --- a/apps/web/src/components/preview/PreviewChromeRow.tsx +++ b/apps/web/src/components/preview/PreviewChromeRow.tsx @@ -1,4 +1,11 @@ -import { ArrowLeft, ArrowRight, ExternalLink, Globe, RotateCw } from "lucide-react"; +import { + ArrowLeft, + ArrowRight, + ExternalLink, + Globe, + MousePointerClick, + RotateCw, +} from "lucide-react"; import { type FormEvent, type KeyboardEvent, @@ -29,6 +36,16 @@ interface Props { onSubmit: (url: string) => void; /** When provided, renders an "Open in browser" affordance to the right. */ onOpenInBrowser?: (() => void) | undefined; + /** + * When provided, renders a "Select element" toggle button to the right of + * the URL input. Pressed while a pick is active (button shows in `pressed` + * state). Disabled in `pickDisabled` mode. + */ + onPickElement?: (() => void) | undefined; + pickActive?: boolean | undefined; + pickDisabled?: boolean | undefined; + /** Optional reason string surfaced in the disabled tooltip. */ + pickDisabledReason?: string | undefined; /** * Trailing slot rendered after the URL input. Used by the preview view * to mount the three-dot menu (hard reload, devtools, zoom, clear data). @@ -52,6 +69,10 @@ export function PreviewChromeRow({ onRefresh, onSubmit, onOpenInBrowser, + onPickElement, + pickActive, + pickDisabled, + pickDisabledReason, trailingActions, }: Props) { const inputRef = useRef(null); @@ -162,6 +183,32 @@ export function PreviewChromeRow({ /> + {onPickElement ? ( + + + } + > + + + + {pickDisabled && pickDisabledReason + ? pickDisabledReason + : pickActive + ? "Cancel pick (Esc)" + : "Select element to attach"} + + + ) : null} {onOpenInBrowser ? ( selectThreadPreviewState(state.byThreadKey, threadRef), ); const rememberUrl = usePreviewStateStore((state) => state.rememberUrl); + const addElementContext = useComposerDraftStore((store) => store.addElementContext); usePreviewSession(threadRef); + useEffect(() => { + isMountedRef.current = true; + return () => { + isMountedRef.current = false; + }; + }, []); + const { snapshot, desktopOverlay } = previewState; const tabId = snapshot?.tabId ?? null; const navStatus = snapshot?.navStatus ?? { _tag: "Idle" as const }; @@ -101,6 +114,67 @@ export function PreviewView({ threadRef, configuredUrls, visible }: Props) { void localApi.shell.openExternal(url).catch(() => undefined); }, [url]); + const handlePickElement = useCallback(() => { + if (!previewBridge || !tabId) return; + if (pickActiveRef.current) { + void previewBridge.cancelPickElement(tabId).catch(() => undefined); + return; + } + // Snapshot whatever the user was focused on (typically the chat + // composer textarea or the chrome-row pick button) BEFORE main steals + // focus into the guest webContents. We restore it when the pick + // resolves so the user's typing context isn't lost — otherwise after + // every pick they'd have to click back into the textarea. + const previouslyFocused = + typeof document !== "undefined" ? (document.activeElement as HTMLElement | null) : null; + pickActiveRef.current = true; + setPickActive(true); + void (async () => { + try { + const payload = await previewBridge.pickElement(tabId); + if (!payload) return; + const selection = normalizeElementContextSelection(payload); + if (!selection) return; + addElementContext(threadRef, selection); + } catch { + // Picker failed (e.g. webview navigated). Treat as silent cancel. + } finally { + pickActiveRef.current = false; + // Avoid `setState on unmounted component` if the panel/thread closed + // while the pick was in flight. + if (isMountedRef.current) setPickActive(false); + // Best-effort: restore focus to whatever the user had before the + // pick stole it into the guest webContents. Skip if the previously- + // focused element was unmounted or is no longer focusable. + if ( + previouslyFocused && + previouslyFocused.isConnected && + typeof previouslyFocused.focus === "function" + ) { + try { + previouslyFocused.focus({ preventScroll: true }); + } catch { + // Some elements throw on .focus() (detached iframes, etc.). + } + } + } + })(); + }, [addElementContext, tabId, threadRef]); + + // If the active tab changes mid-pick (close, thread switch, hot restart), + // tell main to tear down the in-flight session AND reset our local toggle + // state so the button doesn't get stuck pressed against a stale tab id. + useEffect(() => { + return () => { + if (!pickActiveRef.current) return; + pickActiveRef.current = false; + if (previewBridge && tabId) { + void previewBridge.cancelPickElement(tabId).catch(() => undefined); + } + if (isMountedRef.current) setPickActive(false); + }; + }, [tabId]); + // Subscribe only while visible; `toggle-panel` is owned by ChatView's // URL-aware handler regardless of whether the panel is currently mounted. useEffect(() => { @@ -146,6 +220,15 @@ export function PreviewView({ threadRef, configuredUrls, visible }: Props) { onRefresh={handleRefresh} onSubmit={(next) => void handleSubmitUrl(next)} onOpenInBrowser={tabId ? handleOpenInBrowser : undefined} + onPickElement={previewBridge && tabId ? handlePickElement : undefined} + pickActive={pickActive} + // Disable when there's no tab (nothing to pick on) OR the page + // failed to load (a React overlay covers the webview, so the + // user wouldn't be able to actually click anything underneath). + pickDisabled={!tabId || isUnreachable} + pickDisabledReason={ + isUnreachable ? "Page didn't load — pick unavailable until the page renders" : undefined + } trailingActions={ previewBridge ? ( ) : null} - {isUnreachable && navStatus._tag === "LoadFailed" ? ( + {navStatus._tag === "LoadFailed" ? (
void; getWebContentsId: () => number; } @@ -44,7 +50,7 @@ declare global { * after mount throws. */ export function PreviewWebview({ threadRef, tabId, initialUrl, className }: Props) { - const [config, setConfig] = useState<{ partition: string } | null>(null); + const [config, setConfig] = useState(null); const webviewRef = useRef(null); // Capture once at mount; never re-derived from `initialUrl` props later. const initialSrcRef = useRef(initialUrl ?? "about:blank"); @@ -57,11 +63,11 @@ export function PreviewWebview({ threadRef, tabId, initialUrl, className }: Prop useEffect(() => { if (!bridge) return; let cancelled = false; - void bridge - .getBrowserPartition() - .then((partition) => { + bridge + .getPreviewConfig() + .then((next) => { if (cancelled) return; - setConfig({ partition }); + setConfig(next); }) .catch(() => undefined); return () => { @@ -69,6 +75,21 @@ export function PreviewWebview({ threadRef, tabId, initialUrl, className }: Prop }; }, [bridge]); + // Stable callback ref so React doesn't re-invoke the closure on every + // commit (otherwise we'd be re-asserting `allowpopups` and re-storing the + // ref pointer per render, which is harmless but wasteful). + const setWebviewRef = useCallback((node: HTMLElement | null) => { + webviewRef.current = node as ElectronWebview | null; + if (node && !node.hasAttribute("allowpopups")) { + // React's @types declare `allowpopups` as `boolean`, but the runtime + // doesn't list in its boolean-attribute allowlist, so + // passing it via JSX triggers a "received true for a non-boolean + // attribute" warning. Setting it imperatively bypasses React's prop + // normalization entirely. + node.setAttribute("allowpopups", "true"); + } + }, []); + useEffect(() => { if (!bridge || !config) return; const webview = webviewRef.current; @@ -103,12 +124,17 @@ export function PreviewWebview({ threadRef, tabId, initialUrl, className }: Prop if (!isElectron || !bridge || !config) return null; + // `preload` and `webpreferences` are HTML attributes Electron only reads + // at element-attach time — a state update later in the lifecycle would + // have no effect. The renderer-attached values are also enforced by the + // main-process `will-attach-webview` validator (defense in depth). return ( diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index e6caedaa2f9..37470af4a6b 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -522,6 +522,99 @@ describe("composerDraftStore terminal contexts", () => { }); }); +describe("composerDraftStore element contexts", () => { + const threadId = ThreadId.make("thread-element"); + const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); + const baseSelection = { + pageUrl: "https://example.com/dashboard", + pageTitle: "Dashboard", + tagName: "button", + selector: "button.submit", + htmlPreview: "", + componentName: "SubmitButton", + source: { + functionName: "SubmitButton", + fileName: "/repo/Button.tsx", + lineNumber: 12, + columnNumber: 5, + }, + styles: ".submit { color: white; }", + } as const; + + beforeEach(() => { + resetComposerDraftStore(); + }); + + it("adds an element context and stamps id + threadId + pickedAt", () => { + const accepted = useComposerDraftStore.getState().addElementContext(threadRef, baseSelection); + expect(accepted).toBe(true); + const draft = draftFor(threadId, TEST_ENVIRONMENT_ID); + expect(draft?.elementContexts).toHaveLength(1); + const entry = draft?.elementContexts[0]!; + expect(entry.id.startsWith("el_")).toBe(true); + expect(entry.threadId).toBe(threadId); + expect(entry.pickedAt.length).toBeGreaterThan(0); + expect(entry.componentName).toBe("SubmitButton"); + }); + + it("dedupes by selector + tag + componentName + pageUrl signature", () => { + const store = useComposerDraftStore.getState(); + expect(store.addElementContext(threadRef, baseSelection)).toBe(true); + const second = store.addElementContext(threadRef, { + ...baseSelection, + htmlPreview: "", + }); + expect(second).toBe(false); + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.elementContexts).toHaveLength(1); + }); + + it("removeElementContext drops by id + leaves siblings intact", () => { + const store = useComposerDraftStore.getState(); + store.addElementContext(threadRef, baseSelection); + store.addElementContext(threadRef, { ...baseSelection, selector: "button.cancel" }); + const ids = draftFor(threadId, TEST_ENVIRONMENT_ID)!.elementContexts.map((c) => c.id); + store.removeElementContext(threadRef, ids[0]!); + const remaining = draftFor(threadId, TEST_ENVIRONMENT_ID)?.elementContexts; + expect(remaining?.map((c) => c.id)).toEqual([ids[1]]); + }); + + it("setElementContexts replaces the slice and clearComposerContent wipes it", () => { + const store = useComposerDraftStore.getState(); + store.addElementContext(threadRef, baseSelection); + store.setElementContexts(threadRef, []); + // Fully empty draft should be removed via shouldRemoveDraft. + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)).toBeUndefined(); + + store.addElementContext(threadRef, baseSelection); + store.clearComposerContent(threadRef); + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)).toBeUndefined(); + }); + + it("persists element contexts via the partializer (round-trippable)", () => { + useComposerDraftStore.getState().addElementContext(threadRef, baseSelection); + const persistApi = useComposerDraftStore.persist as unknown as { + getOptions: () => { + partialize: (state: ReturnType) => unknown; + }; + }; + const persisted = persistApi.getOptions().partialize(useComposerDraftStore.getState()) as { + draftsByThreadKey?: Record> }>; + }; + const entry = + persisted.draftsByThreadKey?.[threadKeyFor(threadId, TEST_ENVIRONMENT_ID)] + ?.elementContexts?.[0]; + expect(entry).toMatchObject({ + pageUrl: baseSelection.pageUrl, + tagName: baseSelection.tagName, + selector: baseSelection.selector, + componentName: baseSelection.componentName, + }); + // Persistence does NOT include htmlPreview / styles oversize-clamping — + // that happens at normalization time, before the value reaches the store. + expect(typeof entry?.htmlPreview).toBe("string"); + }); +}); + describe("composerDraftStore project draft thread mapping", () => { const projectId = ProjectId.make("project-a"); const otherProjectId = ProjectId.make("project-b"); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 3de3b5d706d..d7f4e2d2049 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -36,6 +36,12 @@ import { ensureInlineTerminalContextPlaceholders, normalizeTerminalContextText, } from "./lib/terminalContext"; +import { + type ElementContextDraft, + type ElementContextSelection, + elementContextDedupKey, + newElementContextId, +} from "./lib/elementContext"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; import { useShallow } from "zustand/react/shallow"; @@ -92,10 +98,33 @@ const PersistedTerminalContextDraft = Schema.Struct({ }); type PersistedTerminalContextDraft = typeof PersistedTerminalContextDraft.Type; +const PersistedElementContextStackFrame = Schema.Struct({ + functionName: Schema.NullOr(Schema.String), + fileName: Schema.NullOr(Schema.String), + lineNumber: Schema.NullOr(Schema.Number), + columnNumber: Schema.NullOr(Schema.Number), +}); + +const PersistedElementContextDraft = Schema.Struct({ + id: Schema.String, + threadId: ThreadId, + pickedAt: Schema.String, + pageUrl: Schema.String, + pageTitle: Schema.NullOr(Schema.String), + tagName: Schema.String, + selector: Schema.NullOr(Schema.String), + htmlPreview: Schema.String, + componentName: Schema.NullOr(Schema.String), + source: Schema.NullOr(PersistedElementContextStackFrame), + styles: Schema.String, +}); +type PersistedElementContextDraft = typeof PersistedElementContextDraft.Type; + const PersistedComposerThreadDraftState = Schema.Struct({ prompt: Schema.String, attachments: Schema.Array(PersistedComposerImageAttachment), terminalContexts: Schema.optionalKey(Schema.Array(PersistedTerminalContextDraft)), + elementContexts: Schema.optionalKey(Schema.Array(PersistedElementContextDraft)), // Keyed by `ProviderInstanceId` (open branded slug) so custom provider // instances (e.g. `codex_personal`) round-trip alongside the built-in // `codex` / `claudeAgent` / ... entries. Every prior `ProviderDriverKind` @@ -216,6 +245,13 @@ export interface ComposerThreadDraftState { nonPersistedImageIds: string[]; persistedAttachments: PersistedComposerImageAttachment[]; terminalContexts: TerminalContextDraft[]; + /** + * Element-pick attachments captured from the in-app preview browser. The + * full payload (selector / html / styles / source frame) is persisted + * inline because — unlike terminal contexts — there's no live session to + * re-derive the snapshot from on reload. + */ + elementContexts: ElementContextDraft[]; /** * Per-instance model selection. Keyed by `ProviderInstanceId` (open * branded slug) so a default `codex` instance and a user-authored @@ -397,6 +433,24 @@ interface ComposerDraftStoreState { addTerminalContexts: (threadRef: ComposerThreadTarget, contexts: TerminalContextDraft[]) => void; removeTerminalContext: (threadRef: ComposerThreadTarget, contextId: string) => void; clearTerminalContexts: (threadRef: ComposerThreadTarget) => void; + /** + * Append a fresh element pick to the draft. Returns true when accepted, + * false when deduped against an existing pick of the same element. + */ + addElementContext: ( + threadRef: ComposerThreadTarget, + selection: ElementContextSelection, + ) => boolean; + /** + * Replace the entire element-contexts list (used by send-failure retry to + * restore the pre-send snapshot). + */ + setElementContexts: ( + threadRef: ComposerThreadTarget, + contexts: ReadonlyArray, + ) => void; + removeElementContext: (threadRef: ComposerThreadTarget, contextId: string) => void; + clearElementContexts: (threadRef: ComposerThreadTarget) => void; clearPersistedAttachments: (threadRef: ComposerThreadTarget) => void; syncPersistedAttachments: ( threadRef: ComposerThreadTarget, @@ -472,9 +526,11 @@ const EMPTY_IMAGES: ComposerImageAttachment[] = []; const EMPTY_IDS: string[] = []; const EMPTY_PERSISTED_ATTACHMENTS: PersistedComposerImageAttachment[] = []; const EMPTY_TERMINAL_CONTEXTS: TerminalContextDraft[] = []; +const EMPTY_ELEMENT_CONTEXTS: ElementContextDraft[] = []; Object.freeze(EMPTY_IMAGES); Object.freeze(EMPTY_IDS); Object.freeze(EMPTY_PERSISTED_ATTACHMENTS); +Object.freeze(EMPTY_ELEMENT_CONTEXTS); const EMPTY_MODEL_SELECTION_BY_PROVIDER: Partial> = Object.freeze({}); const EMPTY_COMPOSER_DRAFT_MODEL_STATE = Object.freeze({ @@ -488,19 +544,27 @@ const EMPTY_THREAD_DRAFT = Object.freeze({ nonPersistedImageIds: EMPTY_IDS, persistedAttachments: EMPTY_PERSISTED_ATTACHMENTS, terminalContexts: EMPTY_TERMINAL_CONTEXTS, + elementContexts: EMPTY_ELEMENT_CONTEXTS, modelSelectionByProvider: EMPTY_MODEL_SELECTION_BY_PROVIDER, activeProvider: null, runtimeMode: null, interactionMode: null, }); -function createEmptyThreadDraft(): ComposerThreadDraftState { +/** + * Canonical factory for a blank `ComposerThreadDraftState`. Exported so tests + * (and any other call sites) can build a draft without re-declaring every + * slice — adding a new field to the interface (e.g. `elementContexts`) only + * has to be reflected here, not in every stub. + */ +export function createEmptyThreadDraft(): ComposerThreadDraftState { return { prompt: "", images: [], nonPersistedImageIds: [], persistedAttachments: [], terminalContexts: [], + elementContexts: [], modelSelectionByProvider: {}, activeProvider: null, runtimeMode: null, @@ -571,6 +635,7 @@ function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean { draft.images.length === 0 && draft.persistedAttachments.length === 0 && draft.terminalContexts.length === 0 && + draft.elementContexts.length === 0 && Object.keys(draft.modelSelectionByProvider).length === 0 && draft.activeProvider === null && draft.runtimeMode === null && @@ -964,6 +1029,63 @@ function normalizePersistedAttachment(value: unknown): PersistedComposerImageAtt }; } +function normalizePersistedElementContextDraft( + value: unknown, +): PersistedElementContextDraft | null { + if (!value || typeof value !== "object") return null; + const candidate = value as Record; + const id = candidate.id; + const threadId = candidate.threadId; + const pickedAt = candidate.pickedAt; + const pageUrl = candidate.pageUrl; + const tagName = candidate.tagName; + if ( + typeof id !== "string" || + id.length === 0 || + typeof threadId !== "string" || + threadId.length === 0 || + typeof pickedAt !== "string" || + pickedAt.length === 0 || + typeof pageUrl !== "string" || + pageUrl.length === 0 || + typeof tagName !== "string" || + tagName.length === 0 + ) { + return null; + } + const sourceCandidate = candidate.source; + let source: PersistedElementContextDraft["source"] = null; + if (sourceCandidate && typeof sourceCandidate === "object") { + const sourceRecord = sourceCandidate as Record; + source = { + functionName: + typeof sourceRecord.functionName === "string" ? sourceRecord.functionName : null, + fileName: typeof sourceRecord.fileName === "string" ? sourceRecord.fileName : null, + lineNumber: + typeof sourceRecord.lineNumber === "number" && Number.isFinite(sourceRecord.lineNumber) + ? sourceRecord.lineNumber + : null, + columnNumber: + typeof sourceRecord.columnNumber === "number" && Number.isFinite(sourceRecord.columnNumber) + ? sourceRecord.columnNumber + : null, + }; + } + return { + id, + threadId: threadId as ThreadId, + pickedAt, + pageUrl, + pageTitle: typeof candidate.pageTitle === "string" ? candidate.pageTitle : null, + tagName, + selector: typeof candidate.selector === "string" ? candidate.selector : null, + htmlPreview: typeof candidate.htmlPreview === "string" ? candidate.htmlPreview : "", + componentName: typeof candidate.componentName === "string" ? candidate.componentName : null, + source, + styles: typeof candidate.styles === "string" ? candidate.styles : "", + }; +} + function normalizePersistedTerminalContextDraft( value: unknown, ): PersistedTerminalContextDraft | null { @@ -1478,6 +1600,12 @@ function normalizePersistedDraftsByThreadId( return normalized ? [normalized] : []; }) : []; + const elementContexts = Array.isArray(draftCandidate.elementContexts) + ? draftCandidate.elementContexts.flatMap((entry) => { + const normalized = normalizePersistedElementContextDraft(entry); + return normalized ? [normalized] : []; + }) + : []; const runtimeMode = isRuntimeMode(draftCandidate.runtimeMode) ? draftCandidate.runtimeMode : null; @@ -1541,6 +1669,7 @@ function normalizePersistedDraftsByThreadId( promptCandidate.length === 0 && attachments.length === 0 && terminalContexts.length === 0 && + elementContexts.length === 0 && !hasModelData && !runtimeMode && !interactionMode @@ -1563,6 +1692,7 @@ function normalizePersistedDraftsByThreadId( prompt, attachments, ...(terminalContexts.length > 0 ? { terminalContexts } : {}), + ...(elementContexts.length > 0 ? { elementContexts } : {}), ...(hasModelData ? { modelSelectionByProvider: compactModelSelectionByProvider(modelSelectionByProvider), @@ -1645,6 +1775,7 @@ function partializeComposerDraftStoreState( draft.prompt.length === 0 && draft.persistedAttachments.length === 0 && draft.terminalContexts.length === 0 && + draft.elementContexts.length === 0 && !hasModelData && draft.runtimeMode === null && draft.interactionMode === null @@ -1667,6 +1798,23 @@ function partializeComposerDraftStoreState( })), } : {}), + ...(draft.elementContexts.length > 0 + ? { + elementContexts: draft.elementContexts.map((context) => ({ + id: context.id, + threadId: context.threadId, + pickedAt: context.pickedAt, + pageUrl: context.pageUrl, + pageTitle: context.pageTitle, + tagName: context.tagName, + selector: context.selector, + htmlPreview: context.htmlPreview, + componentName: context.componentName, + source: context.source, + styles: context.styles, + })), + } + : {}), ...(hasModelData ? { modelSelectionByProvider: compactModelSelectionByProvider( @@ -1903,6 +2051,10 @@ function toHydratedThreadDraft( ...context, text: "", })) ?? [], + elementContexts: + persistedDraft.elementContexts?.map((context) => ({ + ...context, + })) ?? [], modelSelectionByProvider, activeProvider, runtimeMode: persistedDraft.runtimeMode ?? null, @@ -2815,6 +2967,96 @@ const composerDraftStore = create()( return { draftsByThreadKey: nextDraftsByThreadKey }; }); }, + addElementContext: (threadRef, selection) => { + const threadKey = resolveComposerDraftKey(get(), threadRef); + const threadId = resolveComposerThreadId(get(), threadRef); + if (!threadKey || !threadId) return false; + let accepted = false; + set((state) => { + const existing = state.draftsByThreadKey[threadKey] ?? createEmptyThreadDraft(); + const dedupKey = elementContextDedupKey(selection); + if ( + existing.elementContexts.some((entry) => elementContextDedupKey(entry) === dedupKey) + ) { + return state; + } + accepted = true; + const draft: ElementContextDraft = { + ...selection, + id: newElementContextId(), + threadId, + pickedAt: new Date().toISOString(), + }; + return { + draftsByThreadKey: { + ...state.draftsByThreadKey, + [threadKey]: { + ...existing, + elementContexts: [...existing.elementContexts, draft], + }, + }, + }; + }); + return accepted; + }, + setElementContexts: (threadRef, contexts) => { + const threadKey = resolveComposerDraftKey(get(), threadRef); + if (!threadKey) return; + set((state) => { + const existing = state.draftsByThreadKey[threadKey] ?? createEmptyThreadDraft(); + const nextDraft: ComposerThreadDraftState = { + ...existing, + elementContexts: [...contexts], + }; + const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; + if (shouldRemoveDraft(nextDraft)) { + delete nextDraftsByThreadKey[threadKey]; + } else { + nextDraftsByThreadKey[threadKey] = nextDraft; + } + return { draftsByThreadKey: nextDraftsByThreadKey }; + }); + }, + removeElementContext: (threadRef, contextId) => { + const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; + if (threadKey.length === 0 || contextId.length === 0) return; + set((state) => { + const current = state.draftsByThreadKey[threadKey]; + if (!current) return state; + const filtered = current.elementContexts.filter((entry) => entry.id !== contextId); + if (filtered.length === current.elementContexts.length) return state; + const nextDraft: ComposerThreadDraftState = { + ...current, + elementContexts: filtered, + }; + const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; + if (shouldRemoveDraft(nextDraft)) { + delete nextDraftsByThreadKey[threadKey]; + } else { + nextDraftsByThreadKey[threadKey] = nextDraft; + } + return { draftsByThreadKey: nextDraftsByThreadKey }; + }); + }, + clearElementContexts: (threadRef) => { + const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; + if (threadKey.length === 0) return; + set((state) => { + const current = state.draftsByThreadKey[threadKey]; + if (!current || current.elementContexts.length === 0) return state; + const nextDraft: ComposerThreadDraftState = { + ...current, + elementContexts: [], + }; + const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; + if (shouldRemoveDraft(nextDraft)) { + delete nextDraftsByThreadKey[threadKey]; + } else { + nextDraftsByThreadKey[threadKey] = nextDraft; + } + return { draftsByThreadKey: nextDraftsByThreadKey }; + }); + }, clearPersistedAttachments: (threadRef) => { const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; if (threadKey.length === 0) { @@ -2887,6 +3129,7 @@ const composerDraftStore = create()( nonPersistedImageIds: [], persistedAttachments: [], terminalContexts: [], + elementContexts: [], }; const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; if (shouldRemoveDraft(nextDraft)) { diff --git a/apps/web/src/lib/elementContext.test.ts b/apps/web/src/lib/elementContext.test.ts new file mode 100644 index 00000000000..d3fa3f2215d --- /dev/null +++ b/apps/web/src/lib/elementContext.test.ts @@ -0,0 +1,294 @@ +import type { PickedElementPayload } from "@t3tools/contracts"; +import { describe, expect, it } from "vitest"; + +import { + appendElementContextsToPrompt, + buildElementContextBlock, + type ElementContextSelection, + elementContextDedupKey, + extractTrailingElementContexts, + formatElementContextLabel, + formatElementContextSourceLabel, + newElementContextId, + normalizeElementContextSelection, +} from "./elementContext"; + +function makePayload(overrides?: Partial): PickedElementPayload { + return { + pageUrl: "https://example.com/dashboard", + pageTitle: "Dashboard", + tagName: "BUTTON", + selector: "button.submit", + htmlPreview: '', + componentName: "SubmitButton", + source: { + functionName: "SubmitButton", + fileName: "/repo/src/Button.tsx", + lineNumber: 12, + columnNumber: 5, + }, + stack: [ + { + functionName: "SubmitButton", + fileName: "/repo/src/Button.tsx", + lineNumber: 12, + columnNumber: 5, + }, + ], + styles: ".submit { color: white; }", + pickedAt: "2026-05-03T18:00:00.000Z", + ...overrides, + }; +} + +function makeSelection(overrides?: Partial): ElementContextSelection { + return { + pageUrl: "https://example.com/dashboard", + pageTitle: "Dashboard", + tagName: "button", + selector: "button.submit", + htmlPreview: '', + componentName: "SubmitButton", + source: { + functionName: "SubmitButton", + fileName: "/repo/src/Button.tsx", + lineNumber: 12, + columnNumber: 5, + }, + styles: ".submit { color: white; }", + ...overrides, + }; +} + +describe("normalizeElementContextSelection", () => { + it("lowercases the tag, trims strings, and prefers `source` over `stack[0]`", () => { + const result = normalizeElementContextSelection( + makePayload({ + tagName: " Button ", + pageUrl: " https://example.com ", + pageTitle: " Dashboard ", + selector: " ", + componentName: " ", + source: { + functionName: " Outer ", + fileName: " /repo/Outer.tsx ", + lineNumber: 7, + columnNumber: 0, + }, + stack: [ + { + functionName: "Inner", + fileName: "/repo/Inner.tsx", + lineNumber: 99, + columnNumber: 9, + }, + ], + }), + ); + expect(result).not.toBeNull(); + expect(result?.tagName).toBe("button"); + expect(result?.pageUrl).toBe("https://example.com"); + expect(result?.pageTitle).toBe("Dashboard"); + expect(result?.selector).toBeNull(); + expect(result?.componentName).toBeNull(); + expect(result?.source).toEqual({ + functionName: "Outer", + fileName: "/repo/Outer.tsx", + lineNumber: 7, + columnNumber: 0, + }); + }); + + it("returns null when pageUrl or tagName is empty", () => { + expect(normalizeElementContextSelection(makePayload({ pageUrl: "" }))).toBeNull(); + expect(normalizeElementContextSelection(makePayload({ tagName: " " }))).toBeNull(); + }); + + it("clamps oversized htmlPreview / styles so we don't blow localStorage", () => { + const huge = "x".repeat(10_000); + const result = normalizeElementContextSelection( + makePayload({ htmlPreview: huge, styles: huge }), + ); + expect(result).not.toBeNull(); + expect(result!.htmlPreview.length).toBeLessThanOrEqual(4000); + expect(result!.styles.length).toBeLessThanOrEqual(4000); + // Truncated values should end with the ellipsis sentinel + expect(result!.htmlPreview.endsWith("…")).toBe(true); + expect(result!.styles.endsWith("…")).toBe(true); + }); + + it("normalizes Windows line endings inside html/styles", () => { + const result = normalizeElementContextSelection( + makePayload({ htmlPreview: "\r\nhi\r\n", styles: ".a {\r\n color: red;\r\n}" }), + ); + expect(result?.htmlPreview).toBe("\nhi\n"); + expect(result?.styles).toBe(".a {\n color: red;\n}"); + }); + + it("falls back to stack[0] when payload.source is null", () => { + const result = normalizeElementContextSelection( + makePayload({ + source: null, + stack: [ + { + functionName: "FromStack", + fileName: "/repo/FromStack.tsx", + lineNumber: 3, + columnNumber: null, + }, + ], + }), + ); + expect(result?.source).toEqual({ + functionName: "FromStack", + fileName: "/repo/FromStack.tsx", + lineNumber: 3, + columnNumber: null, + }); + }); +}); + +describe("formatElementContextLabel", () => { + it("prefers component name over tag name", () => { + expect(formatElementContextLabel(makeSelection())).toBe(""); + }); + + it("falls back to tag name when no component name is present", () => { + expect(formatElementContextLabel(makeSelection({ componentName: null }))).toBe("', + " styles:", + " .submit { color: white; }", + "", + ].join("\n"), + ); + }); + + it("emits no leading blank when prompt is empty", () => { + expect( + appendElementContextsToPrompt("", [makeSelection()]).startsWith(""), + ).toBe(true); + }); +}); + +describe("extractTrailingElementContexts", () => { + it("round-trips appendElementContextsToPrompt and recovers prompt + entries", () => { + const prompt = appendElementContextsToPrompt("Investigate this", [ + makeSelection(), + makeSelection({ selector: "button.cancel", componentName: "CancelButton" }), + ]); + const result = extractTrailingElementContexts(prompt); + expect(result.promptText).toBe("Investigate this"); + expect(result.contextCount).toBe(2); + expect(result.contexts.map((c) => c.header)).toEqual([ + " (Button.tsx:12)", + " (Button.tsx:12)", + ]); + expect(result.contexts[0]?.body).toContain("url: https://example.com/dashboard"); + expect(result.contexts[0]?.body).toContain("selector: button.submit"); + }); + + it("returns the original prompt unchanged when no trailing block exists", () => { + expect(extractTrailingElementContexts("hi")).toEqual({ + promptText: "hi", + contextCount: 0, + contexts: [], + }); + }); +}); + +describe("newElementContextId", () => { + it("returns a non-empty string with the element prefix", () => { + const id = newElementContextId(); + expect(id.startsWith("el_")).toBe(true); + expect(id.length).toBeGreaterThan(3); + }); + + it("returns unique ids on repeated calls", () => { + const ids = new Set(Array.from({ length: 10 }, () => newElementContextId())); + expect(ids.size).toBe(10); + }); +}); diff --git a/apps/web/src/lib/elementContext.ts b/apps/web/src/lib/elementContext.ts new file mode 100644 index 00000000000..b5f7d9342f7 --- /dev/null +++ b/apps/web/src/lib/elementContext.ts @@ -0,0 +1,245 @@ +import { type ThreadId } from "@t3tools/contracts"; +import type { PickedElementPayload, PickedElementStackFrame } from "@t3tools/contracts"; + +const ELEMENT_CONTEXT_HTML_PREVIEW_LIMIT = 4000; +const ELEMENT_CONTEXT_STYLES_LIMIT = 4000; +const ELEMENT_CONTEXT_LABEL_TAG_MAX = 24; + +const TRAILING_ELEMENT_CONTEXT_BLOCK_PATTERN = + /\n*\n([\s\S]*?)\n<\/element_context>\s*$/; + +/** + * Stable, persistable element selection captured from the in-app preview + * browser. We deliberately keep the shape JSON-serializable so it can ride + * through `localStorage` persistence, draft restoration, and transcript + * snapshots without bespoke marshalling. + */ +export interface ElementContextSelection { + /** Page URL where the element was picked. */ + pageUrl: string; + /** Best-effort ``. */ + pageTitle: string | null; + /** Lowercase tag, e.g. `"button"`. */ + tagName: string; + /** CSS selector — may be null when react-grab can't compute one. */ + selector: string | null; + /** Truncated outer-HTML preview. */ + htmlPreview: string; + /** Nearest React component display name, or null. */ + componentName: string | null; + /** Source frame (file + line) — null when unavailable. */ + source: PickedElementStackFrame | null; + /** Author CSS (no UA defaults). May be empty. */ + styles: string; +} + +export interface ElementContextDraft extends ElementContextSelection { + /** Stable composer-side id used for keyed rendering + dedupe. */ + id: string; + threadId: ThreadId; + /** ISO-8601 wall clock pick time. */ + pickedAt: string; +} + +export interface ParsedElementContextEntry { + header: string; + body: string; +} + +export interface ExtractedElementContexts { + promptText: string; + contextCount: number; + contexts: ParsedElementContextEntry[]; +} + +function truncateString(value: string, limit: number): string { + if (value.length <= limit) return value; + return `${value.slice(0, Math.max(0, limit - 1))}…`; +} + +function normalizeText(value: string): string { + return value.replace(/\r\n/g, "\n").replace(/^\n+|\n+$/g, ""); +} + +/** + * Sanitize a payload coming back from the desktop bridge before it lands in + * the composer draft. Trims/clamps every string field so we never persist a + * 5MB outerHTML blob and silently break `localStorage`. + */ +export function normalizeElementContextSelection( + raw: PickedElementPayload, +): ElementContextSelection | null { + const pageUrl = raw.pageUrl.trim(); + const tagName = raw.tagName.trim().toLowerCase(); + if (pageUrl.length === 0 || tagName.length === 0) { + return null; + } + const stackFrame = raw.source ?? raw.stack[0] ?? null; + return { + pageUrl, + pageTitle: raw.pageTitle?.trim() ?? null, + tagName, + selector: raw.selector?.trim() || null, + htmlPreview: truncateString(normalizeText(raw.htmlPreview), ELEMENT_CONTEXT_HTML_PREVIEW_LIMIT), + componentName: raw.componentName?.trim() || null, + source: stackFrame + ? { + functionName: stackFrame.functionName?.trim() || null, + fileName: stackFrame.fileName?.trim() || null, + lineNumber: stackFrame.lineNumber ?? null, + columnNumber: stackFrame.columnNumber ?? null, + } + : null, + styles: truncateString(normalizeText(raw.styles), ELEMENT_CONTEXT_STYLES_LIMIT), + }; +} + +/** + * Stable dedupe key. Two picks of the same element on the same page produce + * the same key, so we don't end up with a runaway chip row from spam-clicks. + */ +export function elementContextDedupKey(context: ElementContextSelection): string { + return [context.pageUrl, context.selector ?? "", context.tagName, context.componentName ?? ""] + .join("|") + .toLowerCase(); +} + +function shortenTagLabel(tagName: string): string { + if (tagName.length <= ELEMENT_CONTEXT_LABEL_TAG_MAX) return tagName; + return `${tagName.slice(0, ELEMENT_CONTEXT_LABEL_TAG_MAX - 1)}…`; +} + +/** + * Compact chip label — `<Button>` for component picks, `<button>` otherwise. + * Component name takes priority because it's higher-signal for the agent. + */ +export function formatElementContextLabel(context: ElementContextSelection): string { + if (context.componentName) return `<${context.componentName}>`; + return `<${shortenTagLabel(context.tagName)}>`; +} + +function basenameFromPath(filePath: string): string { + const parts = filePath.split(/[\\/]/); + return parts[parts.length - 1] ?? filePath; +} + +export function formatElementContextSourceLabel(context: ElementContextSelection): string | null { + const source = context.source; + if (!source?.fileName) return null; + const base = basenameFromPath(source.fileName); + if (source.lineNumber == null) return base; + return `${base}:${source.lineNumber}`; +} + +function buildContextHeader(context: ElementContextSelection): string { + const label = formatElementContextLabel(context); + const source = formatElementContextSourceLabel(context); + return source ? `${label} (${source})` : label; +} + +function indentLines(value: string): string[] { + return value.split("\n").map((line) => ` ${line}`); +} + +function buildSingleContextLines(context: ElementContextSelection): string[] { + const lines: string[] = []; + lines.push(`- ${buildContextHeader(context)}:`); + if (context.pageUrl.length > 0) { + lines.push(` url: ${context.pageUrl}`); + } + if (context.selector) { + lines.push(` selector: ${context.selector}`); + } + if (context.source?.fileName) { + const { fileName, lineNumber, columnNumber } = context.source; + const location = + lineNumber != null + ? `${fileName}:${lineNumber}${columnNumber != null ? `:${columnNumber}` : ""}` + : fileName; + lines.push(` source: ${location}`); + } + const html = context.htmlPreview.trim(); + if (html.length > 0) { + lines.push(" html:"); + lines.push(...indentLines(html)); + } + const styles = context.styles.trim(); + if (styles.length > 0) { + lines.push(" styles:"); + lines.push(...indentLines(styles)); + } + return lines; +} + +/** + * Serialize element-context drafts into the `<element_context>` block we + * append to the user's outgoing message text. Mirrors the `<terminal_context>` + * block format so it composes cleanly when both are present. + */ +export function buildElementContextBlock(contexts: ReadonlyArray<ElementContextSelection>): string { + if (contexts.length === 0) return ""; + const lines: string[] = []; + for (let index = 0; index < contexts.length; index += 1) { + const context = contexts[index]!; + lines.push(...buildSingleContextLines(context)); + if (index < contexts.length - 1) lines.push(""); + } + return ["<element_context>", ...lines, "</element_context>"].join("\n"); +} + +export function appendElementContextsToPrompt( + prompt: string, + contexts: ReadonlyArray<ElementContextSelection>, +): string { + const block = buildElementContextBlock(contexts); + if (block.length === 0) return prompt; + const trimmed = prompt.trim(); + return trimmed.length > 0 ? `${trimmed}\n\n${block}` : block; +} + +const ELEMENT_CONTEXT_ID_PREFIX = "el_"; + +export function newElementContextId(): string { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return `${ELEMENT_CONTEXT_ID_PREFIX}${crypto.randomUUID()}`; + } + return `${ELEMENT_CONTEXT_ID_PREFIX}${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`; +} + +/** + * Mirror image of `appendElementContextsToPrompt` for transcript display: + * detects (and strips) a trailing `<element_context>` block so we can render + * the original prompt body and chips separately in user-message bubbles. + */ +export function extractTrailingElementContexts(prompt: string): ExtractedElementContexts { + const match = TRAILING_ELEMENT_CONTEXT_BLOCK_PATTERN.exec(prompt); + if (!match) { + return { promptText: prompt, contextCount: 0, contexts: [] }; + } + const promptText = prompt.slice(0, match.index).replace(/\n+$/, ""); + const contexts = parseElementContextEntries(match[1] ?? ""); + return { promptText, contextCount: contexts.length, contexts }; +} + +function parseElementContextEntries(block: string): ParsedElementContextEntry[] { + const entries: ParsedElementContextEntry[] = []; + let current: { header: string; bodyLines: string[] } | null = null; + const commit = () => { + if (!current) return; + entries.push({ header: current.header, body: current.bodyLines.join("\n").trimEnd() }); + current = null; + }; + for (const line of block.split("\n")) { + const headerMatch = /^- (.+):$/.exec(line); + if (headerMatch) { + commit(); + current = { header: headerMatch[1]!, bodyLines: [] }; + continue; + } + if (!current) continue; + if (line.startsWith(" ")) current.bodyLines.push(line.slice(2)); + else if (line.length === 0) current.bodyLines.push(""); + } + commit(); + return entries; +} diff --git a/apps/web/src/lib/terminalContext.test.ts b/apps/web/src/lib/terminalContext.test.ts index 3215ffc8615..4b520c9bef4 100644 --- a/apps/web/src/lib/terminalContext.test.ts +++ b/apps/web/src/lib/terminalContext.test.ts @@ -122,6 +122,7 @@ describe("terminalContext", () => { body: "12 | git status\n13 | On branch main", }, ], + elementContexts: [], }); }); diff --git a/apps/web/src/lib/terminalContext.ts b/apps/web/src/lib/terminalContext.ts index ba63fd5a02b..72f49a2f22d 100644 --- a/apps/web/src/lib/terminalContext.ts +++ b/apps/web/src/lib/terminalContext.ts @@ -1,5 +1,7 @@ import { type ThreadId } from "@t3tools/contracts"; +import { extractTrailingElementContexts, type ParsedElementContextEntry } from "./elementContext"; + export interface TerminalContextSelection { terminalId: string; terminalLabel: string; @@ -27,6 +29,12 @@ export interface DisplayedUserMessageState { contextCount: number; previewTitle: string | null; contexts: ParsedTerminalContextEntry[]; + /** + * Element-context entries extracted from the trailing `<element_context>` + * block (if any). Stripped from `visibleText` so the raw block doesn't + * leak into the user's bubble. + */ + elementContexts: ParsedElementContextEntry[]; } export interface ParsedTerminalContextEntry { @@ -238,13 +246,18 @@ export function extractTrailingTerminalContexts(prompt: string): ExtractedTermin } export function deriveDisplayedUserMessageState(prompt: string): DisplayedUserMessageState { - const extractedContexts = extractTrailingTerminalContexts(prompt); + // Order matters: send-time appends `<terminal_context>` first, then + // `<element_context>` last. Strip element first so the (now-trailing) + // terminal block can be matched by `extractTrailingTerminalContexts`. + const extractedElement = extractTrailingElementContexts(prompt); + const extractedTerminal = extractTrailingTerminalContexts(extractedElement.promptText); return { - visibleText: extractedContexts.promptText, + visibleText: extractedTerminal.promptText, copyText: prompt, - contextCount: extractedContexts.contextCount, - previewTitle: extractedContexts.previewTitle, - contexts: extractedContexts.contexts, + contextCount: extractedTerminal.contextCount, + previewTitle: extractedTerminal.previewTitle, + contexts: extractedTerminal.contexts, + elementContexts: extractedElement.contexts, }; } diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index d003e868de4..5bb68c6575a 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -441,6 +441,70 @@ export interface DesktopPreviewTabState { updatedAt: string; } +/** + * Static config a renderer needs to mount a preview `<webview>`. Returned + * atomically by `DesktopPreviewBridge.getPreviewConfig()` so the renderer + * doesn't have to wait on three separate IPC round-trips before the webview + * can attach. + */ +export interface DesktopPreviewWebviewConfig { + /** `persist:t3code-preview` (or whatever the desktop chose). */ + partition: string; + /** + * Canonical `<webview webpreferences="...">` string. Encodes the security + * posture (sandboxed but contextIsolation off so the picker preload can + * read the page's React DevTools hook). Always present. + */ + webPreferences: string; + /** + * Absolute `file://`-style URL to the picker preload bundle. Set to null + * when the bundle isn't present (older builds, broken install) — the + * renderer must then disable element-pick affordances. + */ + preloadUrl: string | null; +} + +/** + * Single stack frame captured by react-grab's `getElementContext`. We surface + * the source file/line so coding agents can jump straight to the JSX that + * produced the picked DOM node. + */ +export interface PickedElementStackFrame { + functionName: string | null; + fileName: string | null; + lineNumber: number | null; + columnNumber: number | null; +} + +/** + * A successful element pick from the preview webview. All fields are + * best-effort — pages that don't ship a React fiber tree (or aren't running + * in dev) will still produce a usable payload (selector + html preview), + * just without component / source attribution. + */ +export interface PickedElementPayload { + /** URL of the page the element was picked on. */ + pageUrl: string; + /** Optional `<title>` of that page (best-effort). */ + pageTitle: string | null; + /** Lowercase tag name, e.g. `"button"`. */ + tagName: string; + /** CSS selector resolving back to the element on a re-render. */ + selector: string | null; + /** Truncated outer-HTML preview (matches react-grab's `htmlPreview`). */ + htmlPreview: string; + /** Nearest React component display name, or null when unavailable. */ + componentName: string | null; + /** First source-mapped stack frame (file + line of the JSX source). */ + source: PickedElementStackFrame | null; + /** Full owner-stack frames; can be empty. Useful for richer context. */ + stack: ReadonlyArray<PickedElementStackFrame>; + /** Author CSS only (UA defaults stripped) — react-grab's `styles`. */ + styles: string; + /** Wall-clock pick time as ISO-8601 string. */ + pickedAt: string; +} + export interface DesktopBridge { getAppBranding: () => DesktopAppBranding | null; getLocalEnvironmentBootstrap: () => DesktopEnvironmentBootstrap | null; @@ -525,7 +589,21 @@ export interface DesktopPreviewBridge { clearCookies: () => Promise<void>; /** Drop the HTTP cache for the preview partition (all tabs). */ clearCache: () => Promise<void>; - getBrowserPartition: () => Promise<string>; + /** + * One-shot config for mounting a preview `<webview>`. Replaces three + * earlier round-trip calls (`getBrowserPartition`, `getWebviewPreferences`, + * `getPickPreloadPath`) so adding a new field here only requires touching + * the contract + main, not the renderer's mount logic. + */ + getPreviewConfig: () => Promise<DesktopPreviewWebviewConfig>; + /** + * Activate the in-page element picker for the given tab. Resolves with + * the picked payload, or `null` when the user cancels (Escape / nav). The + * promise rejects if the picker can't be activated (no webview, etc.). + */ + pickElement: (tabId: string) => Promise<PickedElementPayload | null>; + /** Cancel an in-flight `pickElement(tabId)` (renderer-side teardown). */ + cancelPickElement: (tabId: string) => Promise<void>; onStateChange: (listener: (tabId: string, state: DesktopPreviewTabState) => void) => () => void; } From 16e30db9d4250a6ce2d985f9cac40e2a86780923 Mon Sep 17 00:00:00 2001 From: Julius Marminge <julius0216@outlook.com> Date: Thu, 11 Jun 2026 15:51:47 -0700 Subject: [PATCH 05/25] fix(preview): port browser preview to current main Co-authored-by: codex <codex@users.noreply.github.com> --- apps/desktop/src/ipc/methods/preview.ts | 56 ++++-- apps/desktop/src/main.ts | 2 - .../src/picked-element-payload.test.ts | 2 +- apps/desktop/src/preload.ts | 10 +- apps/desktop/src/preview-pick-preload.test.ts | 2 +- apps/desktop/src/preview-pick-preload.ts | 1 + apps/desktop/src/preview-view-manager.ts | 1 + .../src/preview-webview-preferences.test.ts | 2 +- .../server/src/preview/Layers/Manager.test.ts | 2 +- apps/server/src/preview/Layers/Manager.ts | 46 ++--- .../src/preview/Layers/PortScanner.test.ts | 88 +++++----- apps/server/src/preview/Layers/PortScanner.ts | 35 ++-- .../src/preview/Services/PortScanner.ts | 2 +- apps/server/src/server.test.ts | 38 ++--- apps/server/src/server.ts | 9 +- apps/server/src/ws.ts | 9 +- apps/web/src/components/chat/ChatComposer.tsx | 14 +- apps/web/src/components/chat/ChatHeader.tsx | 1 - .../src/components/chat/MessagesTimeline.tsx | 14 +- .../preview/useDiscoveredLocalServers.test.ts | 2 +- .../service.threadSubscriptions.test.ts | 10 ++ apps/web/src/lib/elementContext.test.ts | 2 +- apps/web/src/lib/elementContext.ts | 7 +- apps/web/src/previewStateStore.test.ts | 2 +- apps/web/src/rightPanelStore.test.ts | 2 +- packages/contracts/src/preview.test.ts | 2 +- packages/contracts/src/rpc.ts | 13 +- packages/shared/src/preview.test.ts | 2 +- packages/shared/src/preview.ts | 7 +- pnpm-lock.yaml | 161 ++++++++++++++++++ 30 files changed, 381 insertions(+), 163 deletions(-) diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index 9b80339a2e2..b4476fe6fc1 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -1,7 +1,7 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import { BrowserWindow } from "electron"; -import * as NodeFileSystem from "node:fs"; -import * as NodePath from "node:path"; import { pathToFileURL } from "node:url"; import { previewViewManager } from "../../preview-view-manager.ts"; @@ -29,18 +29,38 @@ const tabIdFrom = (raw: unknown): string => { return tabId; }; -const method = (channel: string, handler: (raw: unknown) => unknown | Promise<unknown>): DesktopIpcMethod<unknown, never> => ({ +class PreviewIpcError extends Data.TaggedError("PreviewIpcError")<{ + readonly cause: unknown; +}> {} + +const method = ( + channel: string, + handler: (raw: unknown) => unknown | Promise<unknown>, +): DesktopIpcMethod<PreviewIpcError, never> => ({ channel, - handler: (raw) => Effect.tryPromise({ try: () => Promise.resolve(handler(raw)), catch: (error) => error }), + handler: (raw) => + Effect.tryPromise({ + try: () => Promise.resolve(handler(raw)), + catch: (cause) => new PreviewIpcError({ cause }), + }), }); export const previewMethods = [ - method(IpcChannels.PREVIEW_CREATE_TAB_CHANNEL, (raw) => previewViewManager.createTab(tabIdFrom(raw))), - method(IpcChannels.PREVIEW_CLOSE_TAB_CHANNEL, (raw) => previewViewManager.closeTab(tabIdFrom(raw))), + method(IpcChannels.PREVIEW_CREATE_TAB_CHANNEL, (raw) => + previewViewManager.createTab(tabIdFrom(raw)), + ), + method(IpcChannels.PREVIEW_CLOSE_TAB_CHANNEL, (raw) => + previewViewManager.closeTab(tabIdFrom(raw)), + ), method(IpcChannels.PREVIEW_REGISTER_WEBVIEW_CHANNEL, (raw) => { const tabId = tabIdFrom(raw); - const webContentsId = typeof raw === "object" && raw !== null && "webContentsId" in raw ? raw.webContentsId : null; - if (typeof webContentsId !== "number" || !Number.isInteger(webContentsId) || webContentsId <= 0) { + const webContentsId = + typeof raw === "object" && raw !== null && "webContentsId" in raw ? raw.webContentsId : null; + if ( + typeof webContentsId !== "number" || + !Number.isInteger(webContentsId) || + webContentsId <= 0 + ) { throw new Error("preview webContentsId must be a positive integer"); } return previewViewManager.registerWebview(tabId, webContentsId); @@ -52,21 +72,29 @@ export const previewMethods = [ return previewViewManager.navigate(tabId, url); }), method(IpcChannels.PREVIEW_GO_BACK_CHANNEL, (raw) => previewViewManager.goBack(tabIdFrom(raw))), - method(IpcChannels.PREVIEW_GO_FORWARD_CHANNEL, (raw) => previewViewManager.goForward(tabIdFrom(raw))), + method(IpcChannels.PREVIEW_GO_FORWARD_CHANNEL, (raw) => + previewViewManager.goForward(tabIdFrom(raw)), + ), method(IpcChannels.PREVIEW_REFRESH_CHANNEL, (raw) => previewViewManager.refresh(tabIdFrom(raw))), method(IpcChannels.PREVIEW_ZOOM_IN_CHANNEL, (raw) => previewViewManager.zoomIn(tabIdFrom(raw))), method(IpcChannels.PREVIEW_ZOOM_OUT_CHANNEL, (raw) => previewViewManager.zoomOut(tabIdFrom(raw))), - method(IpcChannels.PREVIEW_RESET_ZOOM_CHANNEL, (raw) => previewViewManager.resetZoom(tabIdFrom(raw))), - method(IpcChannels.PREVIEW_HARD_RELOAD_CHANNEL, (raw) => previewViewManager.hardReload(tabIdFrom(raw))), - method(IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, (raw) => previewViewManager.openDevTools(tabIdFrom(raw))), + method(IpcChannels.PREVIEW_RESET_ZOOM_CHANNEL, (raw) => + previewViewManager.resetZoom(tabIdFrom(raw)), + ), + method(IpcChannels.PREVIEW_HARD_RELOAD_CHANNEL, (raw) => + previewViewManager.hardReload(tabIdFrom(raw)), + ), + method(IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, (raw) => + previewViewManager.openDevTools(tabIdFrom(raw)), + ), method(IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL, () => previewViewManager.clearCookies()), method(IpcChannels.PREVIEW_CLEAR_CACHE_CHANNEL, () => previewViewManager.clearCache()), method(IpcChannels.PREVIEW_GET_CONFIG_CHANNEL, () => { - const preloadPath = NodePath.join(__dirname, "preview-pick-preload.cjs"); + const preloadPath = `${__dirname}/preview-pick-preload.cjs`; return { partition: previewViewManager.getBrowserPartition(), webPreferences: PREVIEW_WEBVIEW_PREFERENCES, - preloadUrl: NodeFileSystem.existsSync(preloadPath) ? pathToFileURL(preloadPath).href : null, + preloadUrl: pathToFileURL(preloadPath).href, }; }), method(IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL, (raw) => diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 74ffcba1a16..9356eef441b 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -28,7 +28,6 @@ import * as DesktopApp from "./app/DesktopApp.ts"; import * as DesktopAppIdentity from "./app/DesktopAppIdentity.ts"; import * as DesktopCloudAuth from "./app/DesktopCloudAuth.ts"; import * as DesktopCloudAuthTokenStore from "./app/DesktopCloudAuthTokenStore.ts"; -import * as DesktopConnectionCatalogStore from "./app/DesktopConnectionCatalogStore.ts"; import * as DesktopApplicationMenu from "./window/DesktopApplicationMenu.ts"; import * as DesktopAssets from "./app/DesktopAssets.ts"; import * as DesktopBackendConfiguration from "./backend/DesktopBackendConfiguration.ts"; @@ -115,7 +114,6 @@ const desktopFoundationLayer = Layer.mergeAll( DesktopClientSettings.layer, DesktopSavedEnvironments.layer, DesktopCloudAuthTokenStore.layer, - DesktopConnectionCatalogStore.layer, DesktopAssets.layer, DesktopObservability.layer, ).pipe(Layer.provideMerge(desktopEnvironmentLayer)); diff --git a/apps/desktop/src/picked-element-payload.test.ts b/apps/desktop/src/picked-element-payload.test.ts index 0b6d23b6acd..8e5c3ad54ba 100644 --- a/apps/desktop/src/picked-element-payload.test.ts +++ b/apps/desktop/src/picked-element-payload.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from "vite-plus/test"; import { isPickedElementPayload } from "./picked-element-payload.ts"; diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index b9deced7ffb..a66a1680401 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -47,10 +47,6 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.invoke(IpcChannels.SET_SAVED_ENVIRONMENT_SECRET_CHANNEL, { environmentId, secret }), removeSavedEnvironmentSecret: (environmentId) => ipcRenderer.invoke(IpcChannels.REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId), - getConnectionCatalog: () => ipcRenderer.invoke(IpcChannels.GET_CONNECTION_CATALOG_CHANNEL), - setConnectionCatalog: (catalog) => - ipcRenderer.invoke(IpcChannels.SET_CONNECTION_CATALOG_CHANNEL, catalog), - clearConnectionCatalog: () => ipcRenderer.invoke(IpcChannels.CLEAR_CONNECTION_CATALOG_CHANNEL), discoverSshHosts: () => ipcRenderer.invoke(IpcChannels.DISCOVER_SSH_HOSTS_CHANNEL), ensureSshEnvironment: async (target, options) => unwrapEnsureSshEnvironmentResult( @@ -164,8 +160,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { clearCookies: () => ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL), clearCache: () => ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_CACHE_CHANNEL), getPreviewConfig: () => ipcRenderer.invoke(IpcChannels.PREVIEW_GET_CONFIG_CHANNEL), - pickElement: (tabId) => - ipcRenderer.invoke(IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL, { tabId }), + pickElement: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL, { tabId }), cancelPickElement: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL, { tabId }), onStateChange: (listener) => { @@ -178,7 +173,8 @@ contextBridge.exposeInMainWorld("desktopBridge", { listener(tabId, state as DesktopPreviewTabState); }; ipcRenderer.on(IpcChannels.PREVIEW_STATE_CHANGE_CHANNEL, wrappedListener); - return () => ipcRenderer.removeListener(IpcChannels.PREVIEW_STATE_CHANGE_CHANNEL, wrappedListener); + return () => + ipcRenderer.removeListener(IpcChannels.PREVIEW_STATE_CHANGE_CHANNEL, wrappedListener); }, }, } satisfies DesktopBridge); diff --git a/apps/desktop/src/preview-pick-preload.test.ts b/apps/desktop/src/preview-pick-preload.test.ts index dbb6eb964aa..9f239e1d2e8 100644 --- a/apps/desktop/src/preview-pick-preload.test.ts +++ b/apps/desktop/src/preview-pick-preload.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from "vite-plus/test"; import { computeLabelPosition } from "./preview-pick-label-position.ts"; diff --git a/apps/desktop/src/preview-pick-preload.ts b/apps/desktop/src/preview-pick-preload.ts index 9f1fee9da15..f5dacf1edb9 100644 --- a/apps/desktop/src/preview-pick-preload.ts +++ b/apps/desktop/src/preview-pick-preload.ts @@ -1,3 +1,4 @@ +// @effect-diagnostics globalDate:off /** * Preview pick preload — runs in the isolated world of the Chromium * `<webview>` that hosts the in-app browser. Loaded via the diff --git a/apps/desktop/src/preview-view-manager.ts b/apps/desktop/src/preview-view-manager.ts index 127fb216b00..a49695430ca 100644 --- a/apps/desktop/src/preview-view-manager.ts +++ b/apps/desktop/src/preview-view-manager.ts @@ -1,3 +1,4 @@ +// @effect-diagnostics globalDate:off /** * PreviewViewManager — desktop side of the in-app browser preview. * diff --git a/apps/desktop/src/preview-webview-preferences.test.ts b/apps/desktop/src/preview-webview-preferences.test.ts index 82e8a88ce02..dc88714a51d 100644 --- a/apps/desktop/src/preview-webview-preferences.test.ts +++ b/apps/desktop/src/preview-webview-preferences.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from "vite-plus/test"; import { PREVIEW_WEBVIEW_PREFERENCES } from "./preview-webview-preferences.ts"; diff --git a/apps/server/src/preview/Layers/Manager.test.ts b/apps/server/src/preview/Layers/Manager.test.ts index 197b79d5651..97a64ec844f 100644 --- a/apps/server/src/preview/Layers/Manager.test.ts +++ b/apps/server/src/preview/Layers/Manager.test.ts @@ -1,7 +1,7 @@ import { it } from "@effect/vitest"; import { type PreviewEvent, ThreadId } from "@t3tools/contracts"; import { Effect, PubSub } from "effect"; -import { expect } from "vitest"; +import { expect } from "vite-plus/test"; import { PreviewManager } from "../Services/Manager.ts"; import { PreviewManagerLive } from "./Manager.ts"; diff --git a/apps/server/src/preview/Layers/Manager.ts b/apps/server/src/preview/Layers/Manager.ts index 3a70f0dceaf..dc58b3f9f13 100644 --- a/apps/server/src/preview/Layers/Manager.ts +++ b/apps/server/src/preview/Layers/Manager.ts @@ -21,7 +21,7 @@ import { normalizePreviewUrl, PreviewUrlNormalizationError, } from "@t3tools/shared/preview"; -import { Effect, Layer, PubSub, Stream, SynchronizedRef } from "effect"; +import { DateTime, Effect, Layer, PubSub, Stream, SynchronizedRef } from "effect"; import { PreviewManager, type PreviewManagerShape } from "../Services/Manager.ts"; @@ -66,30 +66,34 @@ const normalizeUrl = (rawUrl: string): Effect.Effect<string, PreviewInvalidUrlEr }), }); +const currentIsoTimestamp = DateTime.now.pipe(Effect.map(DateTime.formatIso)); + const buildLoadingSnapshot = (input: { readonly threadId: string; readonly tabId: string; readonly url: string; readonly title: string; + readonly updatedAt: string; }): PreviewSessionSnapshot => ({ threadId: input.threadId, tabId: input.tabId, navStatus: { _tag: "Loading", url: input.url, title: input.title }, canGoBack: false, canGoForward: false, - updatedAt: new Date().toISOString(), + updatedAt: input.updatedAt, }); const buildIdleSnapshot = (input: { readonly threadId: string; readonly tabId: string; + readonly updatedAt: string; }): PreviewSessionSnapshot => ({ threadId: input.threadId, tabId: input.tabId, navStatus: { _tag: "Idle" }, canGoBack: false, canGoForward: false, - updatedAt: new Date().toISOString(), + updatedAt: input.updatedAt, }); export const makePreviewManager = Effect.gen(function* () { @@ -152,14 +156,16 @@ export const makePreviewManager = Effect.gen(function* () { const open: PreviewManagerShape["open"] = (input) => Effect.gen(function* () { const tabId = newPreviewTabId(); + const updatedAt = yield* currentIsoTimestamp; const snapshot = input.url ? buildLoadingSnapshot({ threadId: input.threadId, tabId, url: yield* normalizeUrl(input.url), title: "", + updatedAt, }) - : buildIdleSnapshot({ threadId: input.threadId, tabId }); + : buildIdleSnapshot({ threadId: input.threadId, tabId, updatedAt }); yield* SynchronizedRef.update(stateRef, (state) => { const sessions = new Map(state.sessions); sessions.set(compositeKey(input.threadId, tabId), { @@ -183,7 +189,8 @@ export const makePreviewManager = Effect.gen(function* () { Effect.gen(function* () { const url = yield* normalizeUrl(input.url); return yield* mutateExistingSession(input.threadId, input.tabId, (session) => - Effect.sync(() => { + Effect.gen(function* () { + const updatedAt = yield* currentIsoTimestamp; const previousTitle = session.snapshot.navStatus._tag === "Idle" ? "" : session.snapshot.navStatus.title; const resolvedTitle = input.resolvedTitle ?? previousTitle; @@ -193,7 +200,7 @@ export const makePreviewManager = Effect.gen(function* () { navStatus: { _tag: "Success", url, title: resolvedTitle }, canGoBack: session.snapshot.canGoBack, canGoForward: session.snapshot.canGoForward, - updatedAt: new Date().toISOString(), + updatedAt, }; return { next: { ...session, snapshot }, @@ -212,14 +219,15 @@ export const makePreviewManager = Effect.gen(function* () { const reportStatus: PreviewManagerShape["reportStatus"] = (input) => mutateExistingSession(input.threadId, input.tabId, (session) => - Effect.sync(() => { + Effect.gen(function* () { + const updatedAt = yield* currentIsoTimestamp; const snapshot: PreviewSessionSnapshot = { threadId: session.threadId, tabId: session.tabId, navStatus: input.navStatus, canGoBack: input.canGoBack, canGoForward: input.canGoForward, - updatedAt: new Date().toISOString(), + updatedAt, }; const emit: PreviewEvent = input.navStatus._tag === "LoadFailed" @@ -256,8 +264,9 @@ export const makePreviewManager = Effect.gen(function* () { ); const close: PreviewManagerShape["close"] = (input) => - Effect.flatMap( - SynchronizedRef.modify(stateRef, (state) => { + Effect.gen(function* () { + const createdAt = yield* currentIsoTimestamp; + const events = yield* SynchronizedRef.modify(stateRef, (state) => { const eventsToEmit: PreviewEvent[] = []; const sessions = new Map(state.sessions); const targets = input.tabId @@ -271,21 +280,20 @@ export const makePreviewManager = Effect.gen(function* () { type: "closed", threadId: target.threadId, tabId: target.tabId, - createdAt: new Date().toISOString(), + createdAt, }); } if (eventsToEmit.length === 0) { return [eventsToEmit, state] as const; } return [eventsToEmit, { sessions }] as const; - }), - (events) => - events.length === 0 - ? Effect.void - : Effect.forEach(events, (event) => PubSub.publish(eventsPubSub, event), { - discard: true, - }), - ); + }); + if (events.length > 0) { + yield* Effect.forEach(events, (event) => PubSub.publish(eventsPubSub, event), { + discard: true, + }); + } + }); const list: PreviewManagerShape["list"] = (input) => SynchronizedRef.get(stateRef).pipe( diff --git a/apps/server/src/preview/Layers/PortScanner.test.ts b/apps/server/src/preview/Layers/PortScanner.test.ts index b81103c1512..e0bec8cd4d3 100644 --- a/apps/server/src/preview/Layers/PortScanner.test.ts +++ b/apps/server/src/preview/Layers/PortScanner.test.ts @@ -1,12 +1,18 @@ import * as net from "node:net"; -import { Effect } from "effect"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { it as effectIt } from "@effect/vitest"; +import { Effect, Layer } from "effect"; +import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; -import { PreviewPortScanner } from "../Services/PortScanner.ts"; +import { COMMON_DEV_PORTS, PreviewPortScanner } from "../Services/PortScanner.ts"; +import { ProcessRunner } from "../../processRunner.ts"; import { __testing, PreviewPortScannerLive } from "./PortScanner.ts"; const { parseLsofOutput, parsePortFromLsofName, serversEqual } = __testing; +const TestProcessRunner = Layer.succeed(ProcessRunner, { + run: () => Effect.die("ProcessRunner should not be used by Windows TCP probe tests"), +}); +const TestPreviewPortScannerLive = PreviewPortScannerLive.pipe(Layer.provide(TestProcessRunner)); describe("parsePortFromLsofName", () => { it("parses *:port", () => { @@ -121,16 +127,25 @@ describe("serversEqual", () => { describe("PreviewPortScanner integration (TCP probe path)", () => { let originalPlatform: NodeJS.Platform; let server: net.Server; + let port: number; beforeEach(async () => { originalPlatform = process.platform; Object.defineProperty(process, "platform", { value: "win32", configurable: true }); - // 3001 is in COMMON_DEV_PORTS so the TCP-probe pass will check it. - server = net.createServer(); - await new Promise<void>((resolve, reject) => { - server.once("error", reject); - server.listen(3001, "127.0.0.1", () => resolve()); - }); + for (const candidate of COMMON_DEV_PORTS) { + const candidateServer = net.createServer(); + const listening = await new Promise<boolean>((resolve) => { + candidateServer.once("error", () => resolve(false)); + candidateServer.listen(candidate, "127.0.0.1", () => resolve(true)); + }); + if (listening) { + server = candidateServer; + port = candidate; + return; + } + candidateServer.close(); + } + throw new Error("No common development port was available for the preview scanner test"); }); afterEach(async () => { @@ -141,40 +156,29 @@ describe("PreviewPortScanner integration (TCP probe path)", () => { await new Promise<void>((resolve) => server.close(() => resolve())); }); - it("scan() returns a server we just opened on a curated dev port", async () => { - const result = await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const scanner = yield* PreviewPortScanner; - return yield* scanner.scan(); - }).pipe(Effect.provide(PreviewPortScannerLive)), - ), - ); - const found = result.find((s) => s.port === 3001); - expect(found).toBeDefined(); - expect(found?.host).toBe("localhost"); - }); + effectIt.effect("scan() returns a server we just opened on a curated dev port", () => + Effect.gen(function* () { + const scanner = yield* PreviewPortScanner; + const result = yield* scanner.scan(); + const found = result.find((server) => server.port === port); + expect(found).toBeDefined(); + expect(found?.host).toBe("localhost"); + }).pipe(Effect.provide(TestPreviewPortScannerLive)), + ); - it("retain() drives an immediate broadcast to subscribers", async () => { + effectIt.effect("retain() drives an immediate broadcast to subscribers", () => { const received: number[] = []; - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const scanner = yield* PreviewPortScanner; - const unsubscribe = yield* scanner.subscribe((servers) => - Effect.sync(() => { - for (const server of servers) received.push(server.port); - }), - ); - const release = yield* scanner.retain(); - // The retain() pollTick is fire-and-forget within Effect, so wait - // a frame for the broadcast to land. - yield* Effect.sleep("100 millis"); - unsubscribe(); - release(); - }).pipe(Effect.provide(PreviewPortScannerLive)), - ), - ); - expect(received).toContain(3001); + return Effect.gen(function* () { + const scanner = yield* PreviewPortScanner; + const unsubscribe = yield* scanner.subscribe((servers) => + Effect.sync(() => { + for (const server of servers) received.push(server.port); + }), + ); + const release = yield* scanner.retain(); + unsubscribe(); + release(); + expect(received).toContain(port); + }).pipe(Effect.provide(TestPreviewPortScannerLive)); }); }); diff --git a/apps/server/src/preview/Layers/PortScanner.ts b/apps/server/src/preview/Layers/PortScanner.ts index a7333015896..fa0bd2b0520 100644 --- a/apps/server/src/preview/Layers/PortScanner.ts +++ b/apps/server/src/preview/Layers/PortScanner.ts @@ -17,7 +17,7 @@ import type { DiscoveredLocalServer } from "@t3tools/contracts"; import { LSOF_LOCAL_HOST_TOKENS } from "@t3tools/shared/preview"; import { Cause, Data, Duration, Effect, Layer, Ref, Schedule } from "effect"; -import { runProcess } from "../../processRunner.ts"; +import { ProcessRunner, type ProcessRunnerShape } from "../../processRunner.ts"; import { COMMON_DEV_PORTS, PreviewPortScanner, @@ -91,20 +91,22 @@ const parsePortFromLsofName = (name: string): number | null => { return port; }; -const probeLsof = (): Effect.Effect<ReadonlyArray<DiscoveredLocalServer> | null> => - Effect.tryPromise({ - try: () => - runProcess("lsof", ["-iTCP", "-sTCP:LISTEN", "-P", "-n", "-F", "pcn"], { - timeoutMs: LSOF_TIMEOUT_MS, - allowNonZeroExit: true, - maxBufferBytes: 1024 * 1024, - outputMode: "truncate", - }), - catch: (cause) => new LsofProbeError({ cause }), - }).pipe( - Effect.map((result) => parseLsofOutput(result.stdout)), - Effect.catch(() => Effect.succeed(null)), - ); +const probeLsof = ( + processRunner: ProcessRunnerShape, +): Effect.Effect<ReadonlyArray<DiscoveredLocalServer> | null> => + processRunner + .run({ + command: "lsof", + args: ["-iTCP", "-sTCP:LISTEN", "-P", "-n", "-F", "pcn"], + timeout: Duration.millis(LSOF_TIMEOUT_MS), + maxOutputBytes: 1024 * 1024, + outputMode: "truncate", + }) + .pipe( + Effect.mapError((cause) => new LsofProbeError({ cause })), + Effect.map((result) => parseLsofOutput(result.stdout)), + Effect.orElseSucceed(() => null), + ); const probeTcpPort = (port: number): Promise<boolean> => new Promise((resolve) => { @@ -162,6 +164,7 @@ const serversEqual = ( }; export const makePreviewPortScanner = Effect.gen(function* () { + const processRunner = yield* ProcessRunner; const stateRef = yield* Ref.make<ScannerState>({ lastSnapshot: [], }); @@ -176,7 +179,7 @@ export const makePreviewPortScanner = Effect.gen(function* () { if (process.platform === "win32") { return yield* probeCommonPorts(); } - const lsof = yield* probeLsof(); + const lsof = yield* probeLsof(processRunner); if (lsof !== null) return lsof; return yield* probeCommonPorts(); }); diff --git a/apps/server/src/preview/Services/PortScanner.ts b/apps/server/src/preview/Services/PortScanner.ts index 19a83c51c20..4e8751bb669 100644 --- a/apps/server/src/preview/Services/PortScanner.ts +++ b/apps/server/src/preview/Services/PortScanner.ts @@ -28,7 +28,7 @@ export interface PreviewPortScannerShape { export class PreviewPortScanner extends Context.Service< PreviewPortScanner, PreviewPortScannerShape ->()("t3/preview/Services/PortScanner") {} +>()("t3/preview/Services/PortScanner/PreviewPortScanner") {} /** Curated list of common dev-server ports for the Windows TCP-probe fallback. */ export const COMMON_DEV_PORTS: ReadonlyArray<number> = Object.freeze([ diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 46e06085ed1..c69771181f1 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -668,25 +668,25 @@ const buildAppUnderTest = (options?: { }), ), Layer.provide( - Layer.mock(PreviewManager)({ - open: () => Effect.die("PreviewManager not stubbed in this test"), - navigate: () => Effect.die("PreviewManager not stubbed in this test"), - reportStatus: () => Effect.void, - refresh: () => Effect.void, - close: () => Effect.void, - list: () => Effect.succeed({ sessions: [] }), - events: Stream.empty, - subscribeEvents: Effect.flatMap(PubSub.unbounded<PreviewEvent>(), (pubsub) => - PubSub.subscribe(pubsub), - ), - }), - ), - Layer.provide( - Layer.mock(PreviewPortScanner)({ - scan: () => Effect.succeed([]), - subscribe: () => Effect.succeed(() => {}), - retain: () => Effect.succeed(() => {}), - }), + Layer.mergeAll( + Layer.mock(PreviewManager)({ + open: () => Effect.die("PreviewManager not stubbed in this test"), + navigate: () => Effect.die("PreviewManager not stubbed in this test"), + reportStatus: () => Effect.void, + refresh: () => Effect.void, + close: () => Effect.void, + list: () => Effect.succeed({ sessions: [] }), + events: Stream.empty, + subscribeEvents: Effect.flatMap(PubSub.unbounded<PreviewEvent>(), (pubsub) => + PubSub.subscribe(pubsub), + ), + }), + Layer.mock(PreviewPortScanner)({ + scan: () => Effect.succeed([]), + subscribe: () => Effect.succeed(() => {}), + retain: () => Effect.succeed(() => {}), + }), + ), ), Layer.provide( Layer.mock(OrchestrationEngineService)({ diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index aaea59ab62f..378e4c6060b 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -37,6 +37,7 @@ import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/Provide import { TerminalManagerLive } from "./terminal/Layers/Manager.ts"; import { PreviewManagerLive } from "./preview/Layers/Manager.ts"; import { PreviewPortScannerLive } from "./preview/Layers/PortScanner.ts"; +import * as ProcessRunner from "./processRunner.ts"; import * as GitManager from "./git/GitManager.ts"; import { KeybindingsLive } from "./keybindings.ts"; import { ServerRuntimeStartup, ServerRuntimeStartupLive } from "./serverRuntimeStartup.ts"; @@ -235,7 +236,10 @@ const CheckpointingLayerLive = Layer.empty.pipe( const TerminalLayerLive = TerminalManagerLive.pipe(Layer.provide(PtyAdapterLive)); -const PreviewLayerLive = Layer.mergeAll(PreviewManagerLive, PreviewPortScannerLive); +const PreviewLayerLive = Layer.mergeAll( + PreviewManagerLive, + PreviewPortScannerLive.pipe(Layer.provide(ProcessRunner.layer)), +); const WorkspaceEntriesLayerLive = WorkspaceEntriesLive.pipe( Layer.provide(WorkspacePathsLive), @@ -278,8 +282,7 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( Layer.provideMerge(GitLayerLive), Layer.provideMerge(VcsLayerLive), Layer.provideMerge(ProviderRuntimeLayerLive), - Layer.provideMerge(TerminalLayerLive), - Layer.provideMerge(PreviewLayerLive), + Layer.provideMerge(Layer.mergeAll(TerminalLayerLive, PreviewLayerLive)), Layer.provideMerge(PersistenceLayerLive), Layer.provideMerge(KeybindingsLive), Layer.provideMerge(ProviderRegistryLive), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 39187075fdb..83bb1641680 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1391,14 +1391,15 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => Effect.gen(function* () { const release = yield* previewPortScanner.retain(); const initial = yield* previewPortScanner.scan(); + const initialScannedAt = DateTime.formatIso(yield* DateTime.now); yield* Queue.offer(queue, { servers: initial, - scannedAt: new Date().toISOString(), + scannedAt: initialScannedAt, }); const unsubscribe = yield* previewPortScanner.subscribe((servers) => - Queue.offer(queue, { - servers, - scannedAt: new Date().toISOString(), + Effect.gen(function* () { + const scannedAt = DateTime.formatIso(yield* DateTime.now); + yield* Queue.offer(queue, { servers, scannedAt }); }), ); return { unsubscribe, release }; diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 5696c6f069c..445c4eda2df 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -17,10 +17,6 @@ import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, } from "@t3tools/contracts"; -import { - connectionStatusText, - type EnvironmentConnectionPresentation, -} from "@t3tools/client-runtime/connection"; import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; import { @@ -441,7 +437,7 @@ export interface ChatComposerProps { isPreparingWorktree: boolean; environmentUnavailable: { readonly label: string; - readonly connection: EnvironmentConnectionPresentation; + readonly connectionState: "connecting" | "disconnected" | "error"; } | null; // Pending approvals / inputs @@ -2354,9 +2350,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) : showPlanFollowUpPrompt && activeProposedPlan ? "Add feedback to refine the plan, or leave this blank to implement it" : environmentUnavailable - ? `${environmentUnavailable.label}: ${connectionStatusText( - environmentUnavailable.connection, - )}` + ? `${environmentUnavailable.label} is ${ + environmentUnavailable.connectionState === "connecting" + ? "connecting" + : "disconnected" + }` : phase === "disconnected" ? "Ask for follow-up changes or attach images" : "Ask anything, @tag files/folders, $use skills, or / for commands" diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 41b213f2b21..2110b53d604 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -10,7 +10,6 @@ import { memo } from "react"; import GitActionsControl from "../GitActionsControl"; import { type DraftId } from "~/composerDraftStore"; import { DiffIcon, Globe, TerminalSquareIcon } from "lucide-react"; -import { Badge } from "../ui/badge"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import ProjectScriptsControl, { type NewProjectScriptInput } from "../ProjectScriptsControl"; import { Toggle } from "../ui/toggle"; diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 1267e935bb2..6629e036866 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -470,9 +470,9 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" )} {elementContextState.contexts.length > 0 ? ( <div className="mb-2 flex flex-wrap gap-1.5"> - {elementContextState.contexts.map((context, index) => ( + {elementContextState.contexts.map((context) => ( <UserMessageElementContextChip - key={`${context.header}:${index}`} + key={`${context.header}:${context.body}`} context={context} /> ))} @@ -579,10 +579,16 @@ function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "mess <TooltipTrigger render={<p className="text-muted-foreground text-xs tabular-nums" />} > - {formatShortTimestamp(row.message.updatedAt, ctx.timestampFormat)} + {formatShortTimestamp( + row.message.completedAt ?? row.message.createdAt, + ctx.timestampFormat, + )} </TooltipTrigger> <TooltipPopup> - {formatChatTimestampTooltip(row.message.updatedAt, ctx.timestampFormat)} + {formatChatTimestampTooltip( + row.message.completedAt ?? row.message.createdAt, + ctx.timestampFormat, + )} </TooltipPopup> </Tooltip> )} diff --git a/apps/web/src/components/preview/useDiscoveredLocalServers.test.ts b/apps/web/src/components/preview/useDiscoveredLocalServers.test.ts index 43bd879d032..ada9153b4b6 100644 --- a/apps/web/src/components/preview/useDiscoveredLocalServers.test.ts +++ b/apps/web/src/components/preview/useDiscoveredLocalServers.test.ts @@ -1,5 +1,5 @@ import type { DiscoveredLocalServer } from "@t3tools/contracts"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from "vite-plus/test"; import { mergeServers, type PreviewableServer } from "./useDiscoveredLocalServers"; diff --git a/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts b/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts index 675a4868032..b1fe715f491 100644 --- a/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts +++ b/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts @@ -104,6 +104,16 @@ vi.mock("@t3tools/client-runtime", async (importOriginal) => { onEvent: vi.fn(() => () => undefined), onMetadata: vi.fn(() => () => undefined), }, + preview: { + open: vi.fn(), + navigate: vi.fn(), + refresh: vi.fn(), + close: vi.fn(), + list: vi.fn(), + reportStatus: vi.fn(), + onEvent: vi.fn(() => () => undefined), + subscribePorts: vi.fn(() => () => undefined), + }, projects: { searchEntries: vi.fn(), writeFile: vi.fn(), diff --git a/apps/web/src/lib/elementContext.test.ts b/apps/web/src/lib/elementContext.test.ts index d3fa3f2215d..a8c740741eb 100644 --- a/apps/web/src/lib/elementContext.test.ts +++ b/apps/web/src/lib/elementContext.test.ts @@ -1,5 +1,5 @@ import type { PickedElementPayload } from "@t3tools/contracts"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from "vite-plus/test"; import { appendElementContextsToPrompt, diff --git a/apps/web/src/lib/elementContext.ts b/apps/web/src/lib/elementContext.ts index b5f7d9342f7..8064db71079 100644 --- a/apps/web/src/lib/elementContext.ts +++ b/apps/web/src/lib/elementContext.ts @@ -198,12 +198,11 @@ export function appendElementContextsToPrompt( } const ELEMENT_CONTEXT_ID_PREFIX = "el_"; +let nextElementContextSequence = 0; export function newElementContextId(): string { - if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { - return `${ELEMENT_CONTEXT_ID_PREFIX}${crypto.randomUUID()}`; - } - return `${ELEMENT_CONTEXT_ID_PREFIX}${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`; + nextElementContextSequence += 1; + return `${ELEMENT_CONTEXT_ID_PREFIX}${nextElementContextSequence.toString(36)}`; } /** diff --git a/apps/web/src/previewStateStore.test.ts b/apps/web/src/previewStateStore.test.ts index 39dc7adee16..eb7c998109b 100644 --- a/apps/web/src/previewStateStore.test.ts +++ b/apps/web/src/previewStateStore.test.ts @@ -1,6 +1,6 @@ import { scopeThreadRef } from "@t3tools/client-runtime"; import { type EnvironmentId, type PreviewSessionSnapshot, ThreadId } from "@t3tools/contracts"; -import { beforeEach, describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it } from "vite-plus/test"; import { __testing, selectThreadPreviewState, usePreviewStateStore } from "./previewStateStore"; diff --git a/apps/web/src/rightPanelStore.test.ts b/apps/web/src/rightPanelStore.test.ts index 9074ff49d02..7865c57f8e7 100644 --- a/apps/web/src/rightPanelStore.test.ts +++ b/apps/web/src/rightPanelStore.test.ts @@ -1,6 +1,6 @@ import { scopeThreadRef } from "@t3tools/client-runtime"; import { type EnvironmentId, ThreadId } from "@t3tools/contracts"; -import { beforeEach, describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it } from "vite-plus/test"; import { selectActiveRightPanel, diff --git a/packages/contracts/src/preview.test.ts b/packages/contracts/src/preview.test.ts index d1f108ab4e9..c11af1c89e1 100644 --- a/packages/contracts/src/preview.test.ts +++ b/packages/contracts/src/preview.test.ts @@ -1,5 +1,5 @@ import { Schema } from "effect"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from "vite-plus/test"; import { DiscoveredLocalServer, diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index e8cba6ebe78..e3d462bb82a 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -480,38 +480,40 @@ export const WsTerminalCloseRpc = Rpc.make(WS_METHODS.terminalClose, { export const WsPreviewOpenRpc = Rpc.make(WS_METHODS.previewOpen, { payload: PreviewOpenInput, success: PreviewSessionSnapshot, - error: PreviewError, + error: Schema.Union([PreviewError, EnvironmentAuthorizationError]), }); export const WsPreviewNavigateRpc = Rpc.make(WS_METHODS.previewNavigate, { payload: PreviewNavigateInput, success: PreviewSessionSnapshot, - error: PreviewError, + error: Schema.Union([PreviewError, EnvironmentAuthorizationError]), }); export const WsPreviewRefreshRpc = Rpc.make(WS_METHODS.previewRefresh, { payload: PreviewRefreshInput, - error: PreviewError, + error: Schema.Union([PreviewError, EnvironmentAuthorizationError]), }); export const WsPreviewCloseRpc = Rpc.make(WS_METHODS.previewClose, { payload: PreviewCloseInput, - error: PreviewError, + error: Schema.Union([PreviewError, EnvironmentAuthorizationError]), }); export const WsPreviewListRpc = Rpc.make(WS_METHODS.previewList, { payload: PreviewListInput, success: PreviewListResult, + error: EnvironmentAuthorizationError, }); export const WsPreviewReportStatusRpc = Rpc.make(WS_METHODS.previewReportStatus, { payload: PreviewReportStatusInput, - error: PreviewError, + error: Schema.Union([PreviewError, EnvironmentAuthorizationError]), }); export const WsSubscribePreviewEventsRpc = Rpc.make(WS_METHODS.subscribePreviewEvents, { payload: Schema.Struct({}), success: PreviewEvent, + error: EnvironmentAuthorizationError, stream: true, }); @@ -520,6 +522,7 @@ export const WsSubscribeDiscoveredLocalServersRpc = Rpc.make( { payload: Schema.Struct({}), success: DiscoveredLocalServerList, + error: EnvironmentAuthorizationError, stream: true, }, ); diff --git a/packages/shared/src/preview.test.ts b/packages/shared/src/preview.test.ts index e9b72172477..6030686d3ed 100644 --- a/packages/shared/src/preview.test.ts +++ b/packages/shared/src/preview.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from "vite-plus/test"; import { isLoopbackHost, diff --git a/packages/shared/src/preview.ts b/packages/shared/src/preview.ts index 963023f0ee3..cc5a765ddcb 100644 --- a/packages/shared/src/preview.ts +++ b/packages/shared/src/preview.ts @@ -5,16 +5,15 @@ */ const TAB_ID_PREFIX = "tab_"; +let nextPreviewTabSequence = 0; /** * Generate a fresh preview tab id. Lives in shared (not contracts) because * the contracts package is schema-only — runtime helpers belong here. */ export function newPreviewTabId(): string { - if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { - return `${TAB_ID_PREFIX}${crypto.randomUUID()}`; - } - return `${TAB_ID_PREFIX}${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`; + nextPreviewTabSequence += 1; + return `${TAB_ID_PREFIX}${nextPreviewTabSequence.toString(36)}`; } const LOOPBACK_HOSTS: ReadonlySet<string> = new Set(["localhost", "127.0.0.1", "0.0.0.0", "::1"]); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 96d563a2fd5..d7644d97d0d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -128,6 +128,9 @@ importers: electron-updater: specifier: ^6.6.2 version: 6.8.3 + react-grab: + specifier: ^0.1.32 + version: 0.1.44(react@19.2.6) devDependencies: '@effect/vitest': specifier: 4.0.0-beta.78 @@ -580,6 +583,9 @@ importers: playwright: specifier: ^1.58.2 version: 1.60.0 + react-grab: + specifier: ^0.1.32 + version: 0.1.44(react@19.2.6) tailwindcss: specifier: ^4.0.0 version: 4.3.0 @@ -2395,6 +2401,9 @@ packages: peerDependencies: hono: ^4 + '@iarna/toml@2.2.5': + resolution: {integrity: sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==} + '@img/colour@1.1.0': resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} @@ -3432,6 +3441,10 @@ packages: '@types/react': optional: true + '@react-grab/cli@0.1.44': + resolution: {integrity: sha512-gMDYY2rw6OWajCcDlXSIgs2LC432YJXSb3Lm5yM187uhRgBYddoEVULi36h+IolX3r7jSb3ew7vn9FfI8NSo0A==} + hasBin: true + '@react-native-masked-view/masked-view@0.3.2': resolution: {integrity: sha512-XwuQoW7/GEgWRMovOQtX3A4PrXhyaZm0lVUiY8qJDvdngjLms9Cpdck6SmGAUNqQwcj2EadHC1HwL0bEyoa/SQ==} peerDependencies: @@ -4787,6 +4800,10 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} + agent-install@0.0.5: + resolution: {integrity: sha512-nHlms9BkP8ZiY79HrwCGiA2DcNaXrAaJrCM/BEqQ7MEsSKyCk+2A76xPGylIfASZSZE0SaU3T0bNSg4rBPIJAQ==} + hasBin: true + ajv-draft-04@1.0.0: resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==} peerDependencies: @@ -5054,6 +5071,11 @@ packages: resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==} engines: {node: '>=0.6'} + bippy@0.5.41: + resolution: {integrity: sha512-jCP2pXXLhXqPrAN+iSEFZmLI4uUM4fjSqajh0K+TmM062VehfDT3ZJNkrTGyN701Z5XMejs9qAudSqkMGhSMKg==} + peerDependencies: + react: '>=17.0.1' + body-parser@2.2.2: resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} engines: {node: '>=18'} @@ -5244,10 +5266,18 @@ packages: resolution: {integrity: sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + cli-cursor@5.0.0: + resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} + engines: {node: '>=18'} + cli-spinners@2.9.2: resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} engines: {node: '>=6'} + cli-spinners@3.4.0: + resolution: {integrity: sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==} + engines: {node: '>=18.20'} + cli-truncate@2.1.0: resolution: {integrity: sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==} engines: {node: '>=8'} @@ -5321,6 +5351,10 @@ packages: resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} engines: {node: '>=18'} + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} @@ -6604,6 +6638,10 @@ packages: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + image-size@1.2.1: resolution: {integrity: sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==} engines: {node: '>=16.x'} @@ -6725,6 +6763,10 @@ packages: engines: {node: '>=14.16'} hasBin: true + is-interactive@2.0.0: + resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} + engines: {node: '>=12'} + is-node-process@1.2.0: resolution: {integrity: sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==} @@ -6742,6 +6784,10 @@ packages: is-property@1.0.2: resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==} + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + is-wsl@2.2.0: resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} engines: {node: '>=8'} @@ -7154,6 +7200,10 @@ packages: resolution: {integrity: sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg==} engines: {node: '>=4'} + log-symbols@7.0.1: + resolution: {integrity: sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==} + engines: {node: '>=18'} + long@5.3.2: resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} @@ -7484,6 +7534,10 @@ packages: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + mimic-response@1.0.1: resolution: {integrity: sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==} engines: {node: '>=4'} @@ -7729,6 +7783,10 @@ packages: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} + onetime@7.0.0: + resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} + engines: {node: '>=18'} + oniguruma-parser@0.12.2: resolution: {integrity: sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==} @@ -7743,6 +7801,10 @@ packages: resolution: {integrity: sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg==} engines: {node: '>=6'} + ora@9.4.0: + resolution: {integrity: sha512-84cglkRILFxdtA8hAvLNdMrtBpPNBTrQ9/ulg0FA7xLMnD6mifv+enAIeRmvtv+WgdCE+LPGOfQmtJRrVaIVhQ==} + engines: {node: '>=20'} + os-paths@7.4.0: resolution: {integrity: sha512-Ux1J4NUqC6tZayBqLN1kUlDAEvLiQlli/53sSddU4IN+h+3xxnv2HmRSMpVSvr1hvJzotfMs3ERvETGK+f4OwA==} engines: {node: '>= 4.0'} @@ -8136,6 +8198,15 @@ packages: peerDependencies: react: '>=17.0.0' + react-grab@0.1.44: + resolution: {integrity: sha512-bDEwBdI90ljq2lhUtPqmWis/HwYB/CvfT0m5i+P9F83Pt0Ot8o9XL8v00s9jcWzdQUlsFDzmq2FO2CHUe8JY8A==} + hasBin: true + peerDependencies: + react: '>=17.0.0' + peerDependenciesMeta: + react: + optional: true + react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -8454,6 +8525,10 @@ packages: resolution: {integrity: sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + restore-cursor@5.1.0: + resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} + engines: {node: '>=18'} + retext-latin@4.0.0: resolution: {integrity: sha512-hv9woG7Fy0M9IlRQloq/N6atV82NxLGveq+3H2WOi79dtIYWN8OaxogDm77f8YnVXJL2VD3bbqowu5E3EMhBYA==} @@ -8757,6 +8832,10 @@ packages: std-env@4.1.0: resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + stdin-discarder@0.3.2: + resolution: {integrity: sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A==} + engines: {node: '>=18'} + stream-buffers@2.2.0: resolution: {integrity: sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg==} engines: {node: '>= 0.10.0'} @@ -9631,6 +9710,10 @@ packages: resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} engines: {node: '>=18'} + yoctocolors@2.1.2: + resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} + engines: {node: '>=18'} + yoga-layout@3.2.1: resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==} @@ -11621,6 +11704,8 @@ snapshots: dependencies: hono: 4.12.25 + '@iarna/toml@2.2.5': {} + '@img/colour@1.1.0': optional: true @@ -12577,6 +12662,17 @@ snapshots: optionalDependencies: '@types/react': 19.2.16 + '@react-grab/cli@0.1.44': + dependencies: + agent-install: 0.0.5 + commander: 14.0.3 + ignore: 7.0.5 + ora: 9.4.0 + package-manager-detector: 1.6.0 + picocolors: 1.1.1 + prompts: 2.4.2 + tinyexec: 1.2.4 + '@react-native-masked-view/masked-view@0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: react: 19.2.3 @@ -13796,6 +13892,15 @@ snapshots: agent-base@7.1.4: {} + agent-install@0.0.5: + dependencies: + '@iarna/toml': 2.2.5 + commander: 14.0.3 + jsonc-parser: 3.3.1 + picocolors: 1.1.1 + prompts: 2.4.2 + yaml: 2.9.0 + ajv-draft-04@1.0.0(ajv@8.20.0): optionalDependencies: ajv: 8.20.0 @@ -14229,6 +14334,10 @@ snapshots: big-integer@1.6.52: {} + bippy@0.5.41(react@19.2.6): + dependencies: + react: 19.2.6 + body-parser@2.2.2: dependencies: bytes: 3.1.2 @@ -14451,8 +14560,14 @@ snapshots: dependencies: restore-cursor: 4.0.0 + cli-cursor@5.0.0: + dependencies: + restore-cursor: 5.1.0 + cli-spinners@2.9.2: {} + cli-spinners@3.4.0: {} + cli-truncate@2.1.0: dependencies: slice-ansi: 3.0.0 @@ -14520,6 +14635,8 @@ snapshots: commander@12.1.0: {} + commander@14.0.3: {} + commander@2.20.3: {} commander@5.1.0: {} @@ -15980,6 +16097,8 @@ snapshots: ignore@5.3.2: {} + ignore@7.0.5: {} + image-size@1.2.1: dependencies: queue: 6.0.2 @@ -16099,6 +16218,8 @@ snapshots: dependencies: is-docker: 3.0.0 + is-interactive@2.0.0: {} + is-node-process@1.2.0: {} is-number@7.0.0: {} @@ -16109,6 +16230,8 @@ snapshots: is-property@1.0.2: {} + is-unicode-supported@2.1.0: {} + is-wsl@2.2.0: dependencies: is-docker: 2.2.1 @@ -16442,6 +16565,11 @@ snapshots: dependencies: chalk: 2.4.2 + log-symbols@7.0.1: + dependencies: + is-unicode-supported: 2.1.0 + yoctocolors: 2.1.2 + long@5.3.2: {} longest-streak@3.1.0: {} @@ -17071,6 +17199,8 @@ snapshots: mimic-fn@2.1.0: {} + mimic-function@5.0.1: {} + mimic-response@1.0.1: {} mimic-response@3.1.0: {} @@ -17308,6 +17438,10 @@ snapshots: dependencies: mimic-fn: 2.1.0 + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + oniguruma-parser@0.12.2: {} oniguruma-to-es@4.3.6: @@ -17330,6 +17464,17 @@ snapshots: strip-ansi: 5.2.0 wcwidth: 1.0.1 + ora@9.4.0: + dependencies: + chalk: 5.6.2 + cli-cursor: 5.0.0 + cli-spinners: 3.4.0 + is-interactive: 2.0.0 + is-unicode-supported: 2.1.0 + log-symbols: 7.0.1 + stdin-discarder: 0.3.2 + string-width: 8.2.1 + os-paths@7.4.0: optionalDependencies: fsevents: 2.3.3 @@ -17721,6 +17866,13 @@ snapshots: dependencies: react: 19.2.3 + react-grab@0.1.44(react@19.2.6): + dependencies: + '@react-grab/cli': 0.1.44 + bippy: 0.5.41(react@19.2.6) + optionalDependencies: + react: 19.2.6 + react-is@16.13.1: {} react-is@17.0.2: {} @@ -18122,6 +18274,11 @@ snapshots: onetime: 5.1.2 signal-exit: 3.0.7 + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + retext-latin@4.0.0: dependencies: '@types/nlcst': 2.0.3 @@ -18563,6 +18720,8 @@ snapshots: std-env@4.1.0: {} + stdin-discarder@0.3.2: {} + stream-buffers@2.2.0: {} strict-event-emitter@0.5.1: {} @@ -19377,6 +19536,8 @@ snapshots: yoctocolors-cjs@2.1.3: {} + yoctocolors@2.1.2: {} + yoga-layout@3.2.1: {} zod-to-json-schema@3.25.2(zod@4.4.3): From 96b28e6bb63a1cdb828710cbb6b8f5fbd5c28ba1 Mon Sep 17 00:00:00 2001 From: Julius Marminge <julius0216@outlook.com> Date: Thu, 11 Jun 2026 16:05:28 -0700 Subject: [PATCH 06/25] fix(preview): initialize and open browser reliably Co-authored-by: codex <codex@users.noreply.github.com> --- apps/desktop/src/ipc/methods/preview.test.ts | 28 +++++++++++++ apps/desktop/src/ipc/methods/preview.ts | 1 - apps/desktop/src/window/DesktopWindow.test.ts | 11 +++++ apps/desktop/src/window/DesktopWindow.ts | 1 + .../src/components/preview/PreviewView.tsx | 14 +++++-- .../preview/openPreviewSession.test.ts | 42 +++++++++++++++++++ .../components/preview/openPreviewSession.ts | 23 ++++++++++ apps/web/src/previewStateStore.ts | 2 +- 8 files changed, 116 insertions(+), 6 deletions(-) create mode 100644 apps/desktop/src/ipc/methods/preview.test.ts create mode 100644 apps/web/src/components/preview/openPreviewSession.test.ts create mode 100644 apps/web/src/components/preview/openPreviewSession.ts diff --git a/apps/desktop/src/ipc/methods/preview.test.ts b/apps/desktop/src/ipc/methods/preview.test.ts new file mode 100644 index 00000000000..8332d5b80ca --- /dev/null +++ b/apps/desktop/src/ipc/methods/preview.test.ts @@ -0,0 +1,28 @@ +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const fromPartition = vi.fn(() => { + throw new Error("Session can only be received when app is ready"); +}); + +vi.mock("electron", () => ({ + BrowserWindow: { + getAllWindows: vi.fn(() => []), + }, + session: { + fromPartition, + }, + webContents: { + fromId: vi.fn(() => null), + }, +})); + +describe("preview IPC methods", () => { + beforeEach(() => { + fromPartition.mockClear(); + }); + + it("does not access the Electron session while the module loads", async () => { + await expect(import("./preview.ts")).resolves.toBeDefined(); + expect(fromPartition).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index b4476fe6fc1..dca8d641108 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -9,7 +9,6 @@ import { PREVIEW_WEBVIEW_PREFERENCES } from "../../preview-webview-preferences.t import * as IpcChannels from "../channels.ts"; import type { DesktopIpcMethod } from "../DesktopIpc.ts"; -previewViewManager.getBrowserSession(); previewViewManager.onStateChange((tabId, state) => { for (const window of BrowserWindow.getAllWindows()) { if (!window.isDestroyed()) { diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 5e977de2dea..01dbf3af1e8 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -8,6 +8,17 @@ import * as Ref from "effect/Ref"; import type * as Electron from "electron"; import { vi } from "vite-plus/test"; +vi.mock("electron", async (importOriginal) => ({ + ...(await importOriginal<typeof import("electron")>()), + session: { + fromPartition: vi.fn(() => ({ + getUserAgent: vi.fn(() => "Mozilla/5.0 Electron/41.5.0 t3code/1.2.3"), + setPermissionRequestHandler: vi.fn(), + setUserAgent: vi.fn(), + })), + }, +})); + import * as DesktopAssets from "../app/DesktopAssets.ts"; import * as DesktopConfig from "../app/DesktopConfig.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index a19eb0ce263..0450b413c13 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -171,6 +171,7 @@ const make = Effect.gen(function* () { const createWindow = Effect.fn("desktop.window.createWindow")(function* ( backendHttpUrl: URL, ): Effect.fn.Return<Electron.BrowserWindow, DesktopWindowError> { + previewViewManager.getBrowserSession(); const applicationUrl = environment.isDevelopment ? yield* resolveDesktopDevServerUrl(environment) : backendHttpUrl.href; diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index ba456f4aba1..fb61fcb354c 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -12,6 +12,7 @@ import { selectThreadPreviewState, usePreviewStateStore } from "~/previewStateSt import { previewBridge } from "./previewBridge"; import { subscribePreviewAction } from "./previewActionBus"; +import { openPreviewSession } from "./openPreviewSession"; import { PreviewChromeRow } from "./PreviewChromeRow"; import { PreviewEmptyState } from "./PreviewEmptyState"; import { PreviewMoreMenu } from "./PreviewMoreMenu"; @@ -41,6 +42,7 @@ export function PreviewView({ threadRef, configuredUrls, visible }: Props) { const previewState = usePreviewStateStore((state) => selectThreadPreviewState(state.byThreadKey, threadRef), ); + const applyServerSnapshot = usePreviewStateStore((state) => state.applyServerSnapshot); const rememberUrl = usePreviewStateStore((state) => state.rememberUrl); const addElementContext = useComposerDraftStore((store) => store.addElementContext); @@ -74,15 +76,19 @@ export function PreviewView({ threadRef, configuredUrls, visible }: Props) { await previewBridge.navigate(tabId, next); rememberUrl(threadRef, next); } else { - const resolved = await api.preview.open({ threadId: threadRef.threadId, url: next }); - const resolvedUrl = resolved.navStatus._tag === "Idle" ? next : resolved.navStatus.url; - rememberUrl(threadRef, resolvedUrl); + await openPreviewSession({ + previewApi: api.preview, + threadRef, + url: next, + applyServerSnapshot, + rememberUrl, + }); } } catch { // Server-side `failed` event renders the unreachable view. } }, - [rememberUrl, tabId, threadRef], + [applyServerSnapshot, rememberUrl, tabId, threadRef], ); const handleRefresh = useCallback(() => { diff --git a/apps/web/src/components/preview/openPreviewSession.test.ts b/apps/web/src/components/preview/openPreviewSession.test.ts new file mode 100644 index 00000000000..98ad7be9a86 --- /dev/null +++ b/apps/web/src/components/preview/openPreviewSession.test.ts @@ -0,0 +1,42 @@ +import type { EnvironmentApi, PreviewSessionSnapshot, ScopedThreadRef } from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { openPreviewSession } from "./openPreviewSession"; + +const threadRef = { + environmentId: "local" as ScopedThreadRef["environmentId"], + threadId: "thread-1" as ScopedThreadRef["threadId"], +}; + +const snapshot: PreviewSessionSnapshot = { + threadId: threadRef.threadId, + tabId: "tab-1", + navStatus: { + _tag: "Loading", + url: "https://t3.chat/", + title: "", + }, + canGoBack: false, + canGoForward: false, + updatedAt: "2026-06-11T23:00:00.000Z", +}; + +describe("openPreviewSession", () => { + it("applies the RPC response without waiting for a preview event", async () => { + const open = vi.fn(async () => snapshot); + const applyServerSnapshot = vi.fn(); + const rememberUrl = vi.fn(); + + await openPreviewSession({ + previewApi: { open } as Pick<EnvironmentApi["preview"], "open">, + threadRef, + url: "t3.chat", + applyServerSnapshot, + rememberUrl, + }); + + expect(open).toHaveBeenCalledWith({ threadId: "thread-1", url: "t3.chat" }); + expect(applyServerSnapshot).toHaveBeenCalledWith(threadRef, snapshot); + expect(rememberUrl).toHaveBeenCalledWith(threadRef, "https://t3.chat/"); + }); +}); diff --git a/apps/web/src/components/preview/openPreviewSession.ts b/apps/web/src/components/preview/openPreviewSession.ts new file mode 100644 index 00000000000..37ef33069ce --- /dev/null +++ b/apps/web/src/components/preview/openPreviewSession.ts @@ -0,0 +1,23 @@ +import type { EnvironmentApi, ScopedThreadRef } from "@t3tools/contracts"; + +import type { PreviewStateStoreState } from "~/previewStateStore"; + +interface OpenPreviewSessionInput { + previewApi: Pick<EnvironmentApi["preview"], "open">; + threadRef: ScopedThreadRef; + url: string; + applyServerSnapshot: PreviewStateStoreState["applyServerSnapshot"]; + rememberUrl: PreviewStateStoreState["rememberUrl"]; +} + +export async function openPreviewSession(input: OpenPreviewSessionInput): Promise<void> { + const snapshot = await input.previewApi.open({ + threadId: input.threadRef.threadId, + url: input.url, + }); + input.applyServerSnapshot(input.threadRef, snapshot); + input.rememberUrl( + input.threadRef, + snapshot.navStatus._tag === "Idle" ? input.url : snapshot.navStatus.url, + ); +} diff --git a/apps/web/src/previewStateStore.ts b/apps/web/src/previewStateStore.ts index 90719efd4c3..10f5cf58504 100644 --- a/apps/web/src/previewStateStore.ts +++ b/apps/web/src/previewStateStore.ts @@ -42,7 +42,7 @@ const EMPTY_THREAD_PREVIEW_STATE: ThreadPreviewState = Object.freeze({ recentlySeenUrls: [] as string[], }); -interface PreviewStateStoreState { +export interface PreviewStateStoreState { byThreadKey: Record<string, ThreadPreviewState>; applyServerEvent: (ref: ScopedThreadRef, event: PreviewEvent) => void; applyServerSnapshot: (ref: ScopedThreadRef, snapshot: PreviewSessionSnapshot | null) => void; From 8f32d5dafb3ae25ec3f142be2af10cd192366cc6 Mon Sep 17 00:00:00 2001 From: Julius Marminge <julius0216@outlook.com> Date: Thu, 11 Jun 2026 16:10:41 -0700 Subject: [PATCH 07/25] fix(preview): declare RPC authorization scopes Co-authored-by: codex <codex@users.noreply.github.com> --- apps/server/src/ws.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 83bb1641680..afd7f569d59 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -183,6 +183,14 @@ const RPC_REQUIRED_SCOPE = new Map<string, AuthEnvironmentScope>([ [WS_METHODS.terminalClose, AuthTerminalOperateScope], [WS_METHODS.subscribeTerminalEvents, AuthTerminalOperateScope], [WS_METHODS.subscribeTerminalMetadata, AuthTerminalOperateScope], + [WS_METHODS.previewOpen, AuthOrchestrationOperateScope], + [WS_METHODS.previewNavigate, AuthOrchestrationOperateScope], + [WS_METHODS.previewRefresh, AuthOrchestrationOperateScope], + [WS_METHODS.previewClose, AuthOrchestrationOperateScope], + [WS_METHODS.previewList, AuthOrchestrationReadScope], + [WS_METHODS.previewReportStatus, AuthOrchestrationOperateScope], + [WS_METHODS.subscribePreviewEvents, AuthOrchestrationReadScope], + [WS_METHODS.subscribeDiscoveredLocalServers, AuthOrchestrationReadScope], [WS_METHODS.subscribeServerConfig, AuthOrchestrationReadScope], [WS_METHODS.subscribeServerLifecycle, AuthOrchestrationReadScope], [WS_METHODS.subscribeAuthAccess, AuthAccessReadScope], From 1643c3b2895d2f4c4e0fce049a182f8d00780099 Mon Sep 17 00:00:00 2001 From: Julius Marminge <julius0216@outlook.com> Date: Thu, 11 Jun 2026 19:56:27 -0700 Subject: [PATCH 08/25] Add preview annotation capture tooling - Add structured annotation payload validation and tests - Update preview preload to capture selected elements, regions, and strokes - Wire new preview annotation UI into the web app Co-authored-by: codex <codex@users.noreply.github.com> --- .../src/picked-element-payload.test.ts | 68 +- apps/desktop/src/picked-element-payload.ts | 99 +- apps/desktop/src/preview-pick-preload.ts | 1319 +++++++++++++---- apps/desktop/src/preview-view-manager.ts | 88 +- apps/web/package.json | 1 - .../components/preview/PreviewChromeRow.tsx | 10 +- .../src/components/preview/PreviewView.tsx | 34 +- apps/web/src/lib/previewAnnotation.test.ts | 60 + apps/web/src/lib/previewAnnotation.ts | 53 + apps/web/src/main.tsx | 4 - apps/web/src/reactGrabBoundary.test.ts | 15 + packages/contracts/src/ipc.ts | 68 +- pnpm-lock.yaml | 3 - 13 files changed, 1476 insertions(+), 346 deletions(-) create mode 100644 apps/web/src/lib/previewAnnotation.test.ts create mode 100644 apps/web/src/lib/previewAnnotation.ts create mode 100644 apps/web/src/reactGrabBoundary.test.ts diff --git a/apps/desktop/src/picked-element-payload.test.ts b/apps/desktop/src/picked-element-payload.test.ts index 8e5c3ad54ba..39537fa7c96 100644 --- a/apps/desktop/src/picked-element-payload.test.ts +++ b/apps/desktop/src/picked-element-payload.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { isPickedElementPayload } from "./picked-element-payload.ts"; +import { isPickedElementPayload, isPreviewAnnotationPayload } from "./picked-element-payload.ts"; function validPayload(overrides?: Record<string, unknown>): Record<string, unknown> { return { @@ -133,3 +133,69 @@ describe("isPickedElementPayload", () => { expect(isPickedElementPayload(validPayload({ stack: [{ bogus: true }] }))).toBe(false); }); }); + +function validAnnotation(overrides?: Record<string, unknown>): Record<string, unknown> { + return { + id: "annotation_1", + pageUrl: "https://example.com/", + pageTitle: "Example", + comment: "Make this clearer", + elements: [ + { + id: "element_1", + element: validPayload(), + rect: { x: 10, y: 20, width: 100, height: 40 }, + }, + ], + regions: [{ id: "region_1", rect: { x: 5, y: 6, width: 20, height: 30 } }], + strokes: [ + { + id: "stroke_1", + color: "#7c3aed", + width: 4, + points: [ + { x: 10, y: 10 }, + { x: 20, y: 20 }, + ], + bounds: { x: 6, y: 6, width: 18, height: 18 }, + }, + ], + styleChanges: [ + { + targetId: "element_1", + selector: "button.submit", + property: "opacity", + previousValue: "1", + value: "0.5", + }, + ], + screenshot: null, + createdAt: "2026-06-11T00:00:00.000Z", + ...overrides, + }; +} + +describe("isPreviewAnnotationPayload", () => { + it("accepts a structured annotation draft before screenshot capture", () => { + expect(isPreviewAnnotationPayload(validAnnotation())).toBe(true); + }); + + it("rejects screenshots supplied by the guest preload", () => { + expect(isPreviewAnnotationPayload(validAnnotation({ screenshot: { dataUrl: "bad" } }))).toBe( + false, + ); + }); + + it("rejects malformed geometry and nested element payloads", () => { + expect( + isPreviewAnnotationPayload( + validAnnotation({ regions: [{ id: "region_1", rect: { x: 0, y: 0, width: "wide" } }] }), + ), + ).toBe(false); + expect( + isPreviewAnnotationPayload( + validAnnotation({ elements: [{ id: "element_1", element: {}, rect: {} }] }), + ), + ).toBe(false); + }); +}); diff --git a/apps/desktop/src/picked-element-payload.ts b/apps/desktop/src/picked-element-payload.ts index 1b5d4df9f5b..a9fadf63c8f 100644 --- a/apps/desktop/src/picked-element-payload.ts +++ b/apps/desktop/src/picked-element-payload.ts @@ -10,7 +10,7 @@ * channel via prototype pollution) would otherwise throw deep in the * renderer and the chip silently never appears. */ -import type { PickedElementPayload } from "@t3tools/contracts"; +import type { PickedElementPayload, PreviewAnnotationPayload } from "@t3tools/contracts"; function isStringOrNull(value: unknown): value is string | null { return value === null || typeof value === "string"; @@ -47,3 +47,100 @@ export function isPickedElementPayload(value: unknown): value is PickedElementPa if (!c["stack"].every(isPickedStackFrame)) return false; return true; } + +function isRect(value: unknown): boolean { + if (typeof value !== "object" || value === null) return false; + const rect = value as Record<string, unknown>; + return ["x", "y", "width", "height"].every( + (key) => typeof rect[key] === "number" && Number.isFinite(rect[key]), + ); +} + +function isPoint(value: unknown): boolean { + if (typeof value !== "object" || value === null) return false; + const point = value as Record<string, unknown>; + return ( + typeof point["x"] === "number" && + Number.isFinite(point["x"]) && + typeof point["y"] === "number" && + Number.isFinite(point["y"]) + ); +} + +export function isPreviewAnnotationPayload(value: unknown): value is PreviewAnnotationPayload { + if (typeof value !== "object" || value === null) return false; + const annotation = value as Record<string, unknown>; + if (typeof annotation["id"] !== "string") return false; + if (typeof annotation["pageUrl"] !== "string") return false; + if (!isStringOrNull(annotation["pageTitle"])) return false; + if (typeof annotation["comment"] !== "string") return false; + if (typeof annotation["createdAt"] !== "string") return false; + if (annotation["screenshot"] !== null) return false; + + const elements = annotation["elements"]; + if (!Array.isArray(elements)) return false; + if ( + !elements.every((entry) => { + if (typeof entry !== "object" || entry === null) return false; + const target = entry as Record<string, unknown>; + return ( + typeof target["id"] === "string" && + isPickedElementPayload(target["element"]) && + isRect(target["rect"]) + ); + }) + ) { + return false; + } + + const regions = annotation["regions"]; + if (!Array.isArray(regions)) return false; + if ( + !regions.every((entry) => { + if (typeof entry !== "object" || entry === null) return false; + const target = entry as Record<string, unknown>; + return typeof target["id"] === "string" && isRect(target["rect"]); + }) + ) { + return false; + } + + const strokes = annotation["strokes"]; + if (!Array.isArray(strokes)) return false; + if ( + !strokes.every((entry) => { + if (typeof entry !== "object" || entry === null) return false; + const target = entry as Record<string, unknown>; + return ( + typeof target["id"] === "string" && + typeof target["color"] === "string" && + typeof target["width"] === "number" && + Number.isFinite(target["width"]) && + Array.isArray(target["points"]) && + target["points"].every(isPoint) && + isRect(target["bounds"]) + ); + }) + ) { + return false; + } + + const styleChanges = annotation["styleChanges"]; + if (!Array.isArray(styleChanges)) return false; + if ( + !styleChanges.every((entry) => { + if (typeof entry !== "object" || entry === null) return false; + const change = entry as Record<string, unknown>; + return ( + typeof change["targetId"] === "string" && + isStringOrNull(change["selector"]) && + typeof change["property"] === "string" && + typeof change["previousValue"] === "string" && + typeof change["value"] === "string" + ); + }) + ) { + return false; + } + return true; +} diff --git a/apps/desktop/src/preview-pick-preload.ts b/apps/desktop/src/preview-pick-preload.ts index f5dacf1edb9..be4df208ed8 100644 --- a/apps/desktop/src/preview-pick-preload.ts +++ b/apps/desktop/src/preview-pick-preload.ts @@ -1,175 +1,101 @@ // @effect-diagnostics globalDate:off -/** - * Preview pick preload — runs in the isolated world of the Chromium - * `<webview>` that hosts the in-app browser. Loaded via the - * `<webview preload="...">` attribute set by the renderer. - * - * Responsibilities: - * - * 1. Listen for `preview:start-pick` from main (sent through - * `WebContents.send`, received here via `ipcRenderer.on`). - * 2. Mount a minimal blue-highlight element picker on the page. - * 3. On click → call `react-grab/primitives.getElementContext` and bubble - * a `PickedElementPayload` to main via `ipcRenderer.send`. Main resolves - * the in-flight `pickElement(tabId)` promise via its per-WebContents - * `wc.ipc.on(...)` listener. - * 4. Tear down the picker on Escape, blur, navigation, or explicit cancel. - * - * Design notes: - * - * - We never modify the page's DOM tree — the highlight + crosshair cursor - * live on a single fixed-position overlay layer that we own. This keeps - * us safe against pages that reset DOM or do MutationObserver tracking. - * - The overlay uses `pointer-events: none` so clicks fall through to the - * real element behind it; we do hit-testing with `elementFromPoint`. - * - Only a single pick session is ever active per webview; re-activating - * silently replaces the previous session. - * - Cancellations triggered BY MAIN (CANCEL_PICK_CHANNEL, or a follow-up - * START_PICK_CHANNEL that supersedes the current session) tear down - * silently — they do NOT echo a `null` ELEMENT_PICKED back, because main - * already knows it cancelled and would otherwise resolve the freshly- - * registered next-pick listener with that stale `null`. Cancellations - * triggered by the USER (Escape, beforeunload, click on empty) DO send - * `null` back so main can resolve the in-flight pick promise. - */ import { ipcRenderer } from "electron"; import { getElementContext } from "react-grab/primitives"; -import type { PickedElementPayload, PickedElementStackFrame } from "@t3tools/contracts"; - -import { computeLabelPosition } from "./preview-pick-label-position.ts"; +import type { + PickedElementPayload, + PickedElementStackFrame, + PreviewAnnotationPayload, + PreviewAnnotationPoint, + PreviewAnnotationRect, + PreviewAnnotationRegionTarget, + PreviewAnnotationStrokeTarget, + PreviewAnnotationStyleChange, +} from "@t3tools/contracts"; const START_PICK_CHANNEL = "preview:start-pick"; const CANCEL_PICK_CHANNEL = "preview:cancel-pick"; const ELEMENT_PICKED_CHANNEL = "preview:element-picked"; - -const HIGHLIGHT_COLOR = "rgba(37, 99, 235, 0.9)"; // blue-600 -const HIGHLIGHT_FILL = "rgba(37, 99, 235, 0.16)"; -const LABEL_BG = "rgba(37, 99, 235, 0.96)"; +const ANNOTATION_CAPTURED_CHANNEL = "preview:annotation-captured"; +const OVERLAY_ATTRIBUTE = "data-t3code-annotation-ui"; const Z_INDEX_OVERLAY = 2147483646; +const ACCENT = "#7c3aed"; +const ACCENT_FILL = "rgba(124,58,237,0.12)"; +const BLUE = "#2563eb"; +const MAX_MARQUEE_ELEMENTS = 20; +const CONTENT_LAYER_Z_INDEX = 1; +const CHROME_LAYER_Z_INDEX = 10; -interface PickSession { - readonly overlay: HTMLDivElement; - readonly outline: HTMLDivElement; - readonly label: HTMLDivElement; - /** - * Tear down listeners + DOM WITHOUT notifying main. Used when main itself - * initiated the cancel (CANCEL_PICK_CHANNEL) or when a follow-up startPick - * supersedes us — main is already waiting on a fresh listener and would - * otherwise resolve it with the stale `null` we'd send. - */ - readonly teardownSilent: () => void; -} - -let activeSession: PickSession | null = null; +type AnnotationTool = "select" | "marquee" | "draw" | "erase"; -function endActiveSession(): void { - if (!activeSession) return; - const session = activeSession; - activeSession = null; - // Supersession from a new startPick is a main-initiated transition: tear - // down silently so the new pick's main-side listener doesn't receive a - // ghost `null` from the old session. - session.teardownSilent(); +interface SelectedElement { + id: string; + element: Element; + outline: HTMLDivElement; + label: HTMLDivElement; + baselineStyles: Map<string, string>; } -interface OverlayHandles { - readonly overlay: HTMLDivElement; - readonly outline: HTMLDivElement; - readonly label: HTMLDivElement; - readonly destroyDom: () => void; +interface AnnotationSession { + teardown: (notifyMain: boolean) => void; } -function createOverlay(): OverlayHandles { - const overlay = document.createElement("div"); - overlay.setAttribute("data-t3code-pick-overlay", ""); - overlay.style.cssText = [ - "position:fixed", - "inset:0", - "z-index:" + String(Z_INDEX_OVERLAY), - "pointer-events:none", - "cursor:crosshair", - // Some apps register `pointer-events: auto !important` on body — using a - // dedicated overlay element ensures we don't fight with that. - ].join(";"); +let activeSession: AnnotationSession | null = null; +let idSequence = 0; - const outline = document.createElement("div"); - outline.setAttribute("data-t3code-pick-outline", ""); - outline.style.cssText = [ - "position:absolute", - "left:0", - "top:0", - "width:0", - "height:0", - "border:2px solid " + HIGHLIGHT_COLOR, - "background:" + HIGHLIGHT_FILL, - "border-radius:2px", - "box-shadow:0 0 0 1px rgba(255,255,255,0.6) inset", - "transition:none", - "display:none", - "pointer-events:none", - ].join(";"); +const nextId = (prefix: string): string => { + idSequence += 1; + return `${prefix}_${idSequence.toString(36)}`; +}; - const label = document.createElement("div"); - label.setAttribute("data-t3code-pick-label", ""); - // `top:0; left:0` so transform translate() positions are absolute viewport - // coordinates (we re-anchor the label every paint via a single transform). - // `max-width: calc(100vw - 8px)` + ellipsis prevents an overly long - // tag#id.class string from overflowing the viewport horizontally before we - // even get to the clamp logic. - label.style.cssText = [ - "position:absolute", - "left:0", - "top:0", - "padding:2px 6px", - "background:" + LABEL_BG, - "color:white", - "font:600 11px/1.2 ui-sans-serif,system-ui,-apple-system,sans-serif", - "border-radius:3px", - "pointer-events:none", - "white-space:nowrap", - "max-width:calc(100vw - 8px)", - "overflow:hidden", - "text-overflow:ellipsis", - "box-shadow:0 1px 2px rgba(0,0,0,0.25)", - "display:none", - ].join(";"); +const rectFromDomRect = (rect: DOMRect): PreviewAnnotationRect => ({ + x: rect.left, + y: rect.top, + width: rect.width, + height: rect.height, +}); + +const normalizeRect = ( + startX: number, + startY: number, + endX: number, + endY: number, +): PreviewAnnotationRect => ({ + x: Math.min(startX, endX), + y: Math.min(startY, endY), + width: Math.abs(endX - startX), + height: Math.abs(endY - startY), +}); + +const isUsableRect = (rect: PreviewAnnotationRect): boolean => rect.width >= 3 && rect.height >= 3; - overlay.appendChild(outline); - overlay.appendChild(label); - document.documentElement.appendChild(overlay); - - // Force the crosshair cursor across the whole page even though we set it - // on the overlay (because pointer-events: none means it's never the - // cursor target). We add a stylesheet rule and revert on cleanup. - const styleEl = document.createElement("style"); - styleEl.textContent = `html[data-t3code-picking="1"], html[data-t3code-picking="1"] *, html[data-t3code-picking="1"] *::before, html[data-t3code-picking="1"] *::after { cursor: crosshair !important; }`; - document.documentElement.appendChild(styleEl); - document.documentElement.setAttribute("data-t3code-picking", "1"); - - const destroyDom = (): void => { - overlay.remove(); - styleEl.remove(); - document.documentElement.removeAttribute("data-t3code-picking"); +function unionRects(rects: ReadonlyArray<PreviewAnnotationRect>): PreviewAnnotationRect | null { + if (rects.length === 0) return null; + const left = Math.min(...rects.map((rect) => rect.x)); + const top = Math.min(...rects.map((rect) => rect.y)); + const right = Math.max(...rects.map((rect) => rect.x + rect.width)); + const bottom = Math.max(...rects.map((rect) => rect.y + rect.height)); + const padding = 20; + const x = Math.max(0, left - padding); + const y = Math.max(0, top - padding); + const maxWidth = Math.max(1, window.innerWidth - x); + const maxHeight = Math.max(1, window.innerHeight - y); + return { + x, + y, + width: Math.min(maxWidth, right - left + padding * 2), + height: Math.min(maxHeight, bottom - top + padding * 2), }; +} - return { overlay, outline, label, destroyDom }; +function isAnnotationNode(element: Element): boolean { + return element instanceof Element && element.closest(`[${OVERLAY_ATTRIBUTE}]`) !== null; } -/** - * Resolve the element under the cursor while ignoring our own overlay. - * We render at z-index 2147483646 with `pointer-events: none`, which means - * `elementFromPoint` already skips the overlay — but we double-guard against - * pages that mutate `pointer-events` via MutationObservers. - */ function pickFromPoint(clientX: number, clientY: number): Element | null { - const candidates = document.elementsFromPoint(clientX, clientY); - for (const candidate of candidates) { + for (const candidate of document.elementsFromPoint(clientX, clientY)) { if (!(candidate instanceof Element)) continue; - if (candidate.hasAttribute("data-t3code-pick-overlay")) continue; - if (candidate.hasAttribute("data-t3code-pick-outline")) continue; - if (candidate.hasAttribute("data-t3code-pick-label")) continue; - if (candidate === document.documentElement) continue; - if (candidate === document.body) continue; + if (isAnnotationNode(candidate)) continue; + if (candidate === document.documentElement || candidate === document.body) continue; return candidate; } return null; @@ -178,94 +104,74 @@ function pickFromPoint(clientX: number, clientY: number): Element | null { function describeRawElement(element: Element): string { const tag = element.tagName.toLowerCase(); const id = element.id ? `#${element.id}` : ""; - const className = + const classes = element instanceof HTMLElement && typeof element.className === "string" ? element.className .trim() .split(/\s+/) - .filter((token) => token.length > 0) + .filter(Boolean) .slice(0, 2) - .map((token) => `.${token}`) + .map((name) => `.${name}`) .join("") : ""; - return `${tag}${id}${className}`; + return `${tag}${id}${classes}`; } -/** - * Per-session cache of the resolved React component name for elements we've - * already inspected. Stores `null` when react-grab couldn't find a component - * (raw HTML / unmounted) so repeat hovers don't re-pay the async lookup. - */ -const componentNameCache = new WeakMap<Element, string | null>(); -const componentNameInFlight = new WeakSet<Element>(); - -function describeElement(element: Element): string { - const cached = componentNameCache.get(element); - if (cached) return `<${cached}> ${describeRawElement(element)}`; - return describeRawElement(element); +function createBox(color: string, fill: string): HTMLDivElement { + const node = document.createElement("div"); + node.setAttribute(OVERLAY_ATTRIBUTE, ""); + node.style.cssText = [ + "position:fixed", + "pointer-events:none", + `border:2px solid ${color}`, + `background:${fill}`, + "border-radius:3px", + "box-sizing:border-box", + "display:none", + `z-index:${CONTENT_LAYER_Z_INDEX}`, + ].join(";"); + return node; } -function paintOutline(handles: OverlayHandles, element: Element): void { - const rect = element.getBoundingClientRect(); - if (rect.width === 0 && rect.height === 0) { - handles.outline.style.display = "none"; - handles.label.style.display = "none"; - return; - } - handles.outline.style.display = "block"; - handles.outline.style.transform = `translate(${rect.left}px, ${rect.top}px)`; - handles.outline.style.width = `${rect.width}px`; - handles.outline.style.height = `${rect.height}px`; - - // Two-pass label paint: first apply the new text and force-block display - // so we can measure the rendered size, then clamp to the viewport and - // flip below the element when there isn't room above. - const text = describeElement(element); - if (handles.label.textContent !== text) { - handles.label.textContent = text; - } - handles.label.style.display = "block"; - - const labelRect = handles.label.getBoundingClientRect(); - const { x, y } = computeLabelPosition({ - targetLeft: rect.left, - targetTop: rect.top, - targetBottom: rect.bottom, - labelWidth: labelRect.width, - labelHeight: labelRect.height, - viewportWidth: document.documentElement.clientWidth || window.innerWidth || labelRect.width, - viewportHeight: document.documentElement.clientHeight || window.innerHeight || labelRect.height, - }); - handles.label.style.transform = `translate(${x}px, ${y}px)`; +function positionBox(node: HTMLElement, rect: PreviewAnnotationRect): void { + node.style.display = "block"; + node.style.transform = `translate(${rect.x}px, ${rect.y}px)`; + node.style.width = `${rect.width}px`; + node.style.height = `${rect.height}px`; } -/** - * Kick off (at most once per element) an async react-grab lookup for the - * component name. When the answer arrives, repaint the label iff the element - * is still under the cursor — otherwise the user has moved on and a stale - * paint would clobber the next element's label. - */ -function ensureComponentName(element: Element, onResolve: (resolvedFor: Element) => void): void { - if (componentNameCache.has(element)) return; - if (componentNameInFlight.has(element)) return; - componentNameInFlight.add(element); - void getElementContext(element) - .then((context) => { - const trimmed = context.componentName?.trim(); - componentNameCache.set(element, trimmed && trimmed.length > 0 ? trimmed : null); - }) - .catch(() => { - componentNameCache.set(element, null); - }) - .finally(() => { - componentNameInFlight.delete(element); - onResolve(element); - }); +function createLabel(): HTMLDivElement { + const label = document.createElement("div"); + label.setAttribute(OVERLAY_ATTRIBUTE, ""); + label.style.cssText = [ + "position:fixed", + "pointer-events:none", + `background:${ACCENT}`, + "color:white", + "font:600 11px/1.2 ui-sans-serif,system-ui,-apple-system,sans-serif", + "padding:2px 6px", + "border-radius:4px", + "white-space:nowrap", + "max-width:280px", + "overflow:hidden", + "text-overflow:ellipsis", + "box-shadow:0 1px 3px rgba(0,0,0,.3)", + `z-index:${CONTENT_LAYER_Z_INDEX}`, + ].join(";"); + return label; } -function clearOutline(handles: OverlayHandles): void { - handles.outline.style.display = "none"; - handles.label.style.display = "none"; +function updateSelectedVisual(target: SelectedElement): void { + if (!target.element.isConnected) { + target.outline.style.display = "none"; + target.label.style.display = "none"; + return; + } + const rect = target.element.getBoundingClientRect(); + positionBox(target.outline, rectFromDomRect(rect)); + target.label.textContent = describeRawElement(target.element); + target.label.style.display = "block"; + target.label.style.transform = `translate(${Math.max(4, rect.left)}px, ${Math.max(4, rect.top - 22)}px)`; } function toStackFrame(frame: { @@ -285,10 +191,10 @@ function toStackFrame(frame: { async function captureElement(element: Element): Promise<PickedElementPayload | null> { try { const context = await getElementContext(element); - const stack = (context.stack ?? []).map((frame) => toStackFrame(frame)); + const stack = (context.stack ?? []).map(toStackFrame); return { pageUrl: location.href, - pageTitle: document.title?.trim() ? document.title.trim() : null, + pageTitle: document.title?.trim() || null, tagName: element.tagName.toLowerCase(), selector: context.selector, htmlPreview: context.htmlPreview ?? "", @@ -303,118 +209,909 @@ async function captureElement(element: Element): Promise<PickedElementPayload | } } -function startPick(): void { - endActiveSession(); - if (typeof document === "undefined" || !document.body) { - ipcRenderer.send(ELEMENT_PICKED_CHANNEL, null); - return; +function createButton(label: string, title: string): HTMLButtonElement { + const button = document.createElement("button"); + button.type = "button"; + button.textContent = label; + button.title = title; + button.style.cssText = [ + "border:0", + "border-radius:7px", + "padding:6px 9px", + "font:600 12px/1 ui-sans-serif,system-ui,-apple-system,sans-serif", + "background:transparent", + "color:#27272a", + "cursor:pointer", + ].join(";"); + return button; +} + +function styleControl(input: HTMLInputElement | HTMLSelectElement): void { + input.setAttribute("aria-label", input.getAttribute("aria-label") ?? "Style value"); + input.style.cssText += + ";min-width:0;width:100%;height:26px;border:0;border-radius:7px;background:rgba(255,255,255,.78);color:#27272a;padding:3px 8px;box-sizing:border-box;font:500 11px/1 ui-monospace,SFMono-Regular,Menlo,monospace;outline:none;box-shadow:inset 0 0 0 1px rgba(0,0,0,.09);appearance:none"; +} + +function createUnitControl(input: HTMLInputElement): HTMLElement { + const wrapper = document.createElement("div"); + wrapper.style.cssText = "position:relative;min-width:0"; + const unit = document.createElement("span"); + unit.textContent = input.dataset.unit ?? ""; + unit.style.cssText = + "position:absolute;right:8px;top:50%;transform:translateY(-50%);pointer-events:none;color:#a1a1aa;font:500 10px/1 ui-monospace,SFMono-Regular,Menlo,monospace"; + wrapper.append(input, unit); + return wrapper; +} + +function createField( + labelText: string, + input: HTMLInputElement | HTMLSelectElement, +): HTMLLabelElement { + const label = document.createElement("label"); + label.style.cssText = + "display:grid;grid-template-columns:82px minmax(0,1fr);align-items:center;gap:8px;min-height:28px;font:500 11px/1.2 ui-sans-serif,system-ui;color:#52525b"; + const text = document.createElement("span"); + text.textContent = labelText; + styleControl(input); + label.append( + text, + input instanceof HTMLInputElement && input.dataset.unit ? createUnitControl(input) : input, + ); + return label; +} + +function createStyleSection(): HTMLElement { + const section = document.createElement("section"); + section.style.cssText = "display:grid;gap:3px;padding:7px 0;border-top:1px solid rgba(0,0,0,.07)"; + return section; +} + +function createUnitInput(unit: string, placeholder = "0"): HTMLInputElement { + const input = document.createElement("input"); + input.type = "number"; + input.placeholder = placeholder; + input.style.paddingRight = "30px"; + input.dataset.unit = unit; + return input; +} + +function pathFromPoints(points: ReadonlyArray<PreviewAnnotationPoint>): string { + if (points.length === 0) return ""; + if (points.length === 1) return `M ${points[0]!.x} ${points[0]!.y} l 0.01 0.01`; + let path = `M ${points[0]!.x} ${points[0]!.y}`; + for (let index = 1; index < points.length - 1; index += 1) { + const current = points[index]!; + const next = points[index + 1]!; + path += ` Q ${current.x} ${current.y} ${(current.x + next.x) / 2} ${(current.y + next.y) / 2}`; } + const last = points[points.length - 1]!; + path += ` L ${last.x} ${last.y}`; + return path; +} + +function strokeBounds( + points: ReadonlyArray<PreviewAnnotationPoint>, + width: number, +): PreviewAnnotationRect { + const xs = points.map((point) => point.x); + const ys = points.map((point) => point.y); + const padding = width + 3; + const left = Math.min(...xs) - padding; + const top = Math.min(...ys) - padding; + const right = Math.max(...xs) + padding; + const bottom = Math.max(...ys) + padding; + return { x: left, y: top, width: right - left, height: bottom - top }; +} + +function startAnnotation(): void { + activeSession?.teardown(false); + let finished = false; + const root = document.createElement("div"); + root.setAttribute(OVERLAY_ATTRIBUTE, ""); + root.style.cssText = `position:fixed;inset:0;z-index:${Z_INDEX_OVERLAY};pointer-events:none;font-family:ui-sans-serif,system-ui,-apple-system,sans-serif`; + const cursorStyle = document.createElement("style"); + cursorStyle.setAttribute(OVERLAY_ATTRIBUTE, ""); + cursorStyle.textContent = `html[data-t3code-annotation-tool] body, html[data-t3code-annotation-tool] body * { cursor: crosshair !important; } [${OVERLAY_ATTRIBUTE}], [${OVERLAY_ATTRIBUTE}] * { cursor: default !important; } [${OVERLAY_ATTRIBUTE}] input[type=number]::-webkit-inner-spin-button, [${OVERLAY_ATTRIBUTE}] input[type=number]::-webkit-outer-spin-button { appearance:none; margin:0; }`; + root.appendChild(cursorStyle); + + const hoverOutline = createBox(BLUE, "rgba(37,99,235,.10)"); + const marqueeBox = createBox(ACCENT, ACCENT_FILL); + root.append(hoverOutline, marqueeBox); + + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.setAttribute(OVERLAY_ATTRIBUTE, ""); + svg.setAttribute("width", "100%"); + svg.setAttribute("height", "100%"); + svg.setAttribute("viewBox", `0 0 ${window.innerWidth} ${window.innerHeight}`); + svg.style.cssText = "position:fixed;inset:0;overflow:visible;pointer-events:none"; + svg.style.zIndex = String(CONTENT_LAYER_Z_INDEX); + root.appendChild(svg); + + const toolbar = document.createElement("div"); + toolbar.setAttribute(OVERLAY_ATTRIBUTE, ""); + toolbar.style.cssText = `position:fixed;top:10px;left:50%;transform:translateX(-50%);z-index:${CHROME_LAYER_Z_INDEX};pointer-events:auto;display:flex;gap:2px;padding:3px;border:1px solid rgba(0,0,0,.10);border-radius:9px;background:rgba(255,255,255,.94);box-shadow:0 5px 16px rgba(0,0,0,.12);backdrop-filter:blur(12px)`; + root.appendChild(toolbar); + + const editor = document.createElement("div"); + editor.setAttribute(OVERLAY_ATTRIBUTE, ""); + editor.style.cssText = `position:fixed;right:12px;bottom:12px;z-index:${CHROME_LAYER_Z_INDEX};width:min(306px,calc(100vw - 24px));max-height:calc(100vh - 32px);overflow:hidden;pointer-events:auto;display:none;flex-direction:column;border:1px solid rgba(0,0,0,.09);border-radius:14px;background:rgba(255,255,255,.96);box-shadow:0 14px 36px rgba(0,0,0,.18);color:#18181b;box-sizing:border-box;backdrop-filter:blur(18px)`; + root.appendChild(editor); + + const editorHeader = document.createElement("div"); + editorHeader.style.cssText = + "display:flex;align-items:center;gap:7px;padding:7px 8px;border-bottom:1px solid rgba(0,0,0,.06);cursor:grab;user-select:none"; + const adjustIcon = document.createElement("div"); + adjustIcon.innerHTML = + '<svg viewBox="0 0 20 20" width="16" height="16" aria-hidden="true"><path d="M3 6h8M15 6h2M3 14h2M9 14h8M11 3v6M7 11v6" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/><circle cx="13" cy="6" r="2" fill="white" stroke="currentColor" stroke-width="1.5"/><circle cx="7" cy="14" r="2" fill="white" stroke="currentColor" stroke-width="1.5"/></svg>'; + adjustIcon.style.cssText = + "display:grid;place-items:center;width:24px;height:24px;border-radius:999px;background:#f4f4f5;color:#52525b;font:700 14px/1 ui-sans-serif"; + editorHeader.appendChild(adjustIcon); + + const status = document.createElement("div"); + status.style.cssText = + "min-width:0;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font:600 11px/1.2 ui-sans-serif,system-ui;color:#71717a"; + editorHeader.appendChild(status); + const dragHandle = document.createElement("div"); + dragHandle.textContent = "⠿"; + dragHandle.title = "Drag annotation editor"; + dragHandle.style.cssText = "color:#a1a1aa;font:700 18px/1 ui-sans-serif"; + editorHeader.appendChild(dragHandle); + editor.appendChild(editorHeader); + + const comment = document.createElement("textarea"); + comment.placeholder = "Describe the change…"; + comment.rows = 2; + comment.style.cssText = + "width:100%;resize:none;min-height:48px;border:0;padding:10px 12px;font:500 12px/1.35 ui-sans-serif,system-ui;color:#18181b;box-sizing:border-box;outline:none;background:transparent"; + editor.appendChild(comment); - const handles = createOverlay(); - let lastTarget: Element | null = null; - let resolved = false; - - // Tear down listeners + DOM. Does NOT notify main. `notifyMain` controls - // whether we additionally bubble a `null` ELEMENT_PICKED back so main can - // resolve the in-flight pick promise (only true for USER-initiated cancels; - // main already knows about its own cancellations). - const teardown = (notifyMain: boolean, payload: PickedElementPayload | null): void => { - if (resolved) return; - resolved = true; - window.removeEventListener("mousemove", onMove, { capture: true } as EventListenerOptions); - window.removeEventListener("click", onClick, { capture: true } as EventListenerOptions); - window.removeEventListener("keydown", onKey, { capture: true } as EventListenerOptions); - window.removeEventListener("scroll", onScrollOrResize, { - capture: true, - } as EventListenerOptions); - window.removeEventListener("resize", onScrollOrResize); - window.removeEventListener("beforeunload", onBeforeUnload); - ipcRenderer.off(CANCEL_PICK_CHANNEL, onMainCancel); - handles.destroyDom(); - if (notifyMain) { - // `ipcRenderer.send` (NOT `sendToHost`) reaches main, where the - // PreviewViewManager's per-WebContents `wc.ipc.on(...)` listener - // resolves the in-flight pick promise. - ipcRenderer.send(ELEMENT_PICKED_CHANNEL, payload); + const stylePanel = document.createElement("div"); + stylePanel.style.cssText = + "display:none;max-height:min(380px,calc(100vh - 180px));overflow:auto;padding:0 10px;background:rgba(250,250,250,.62);border-top:1px solid rgba(0,0,0,.06)"; + editor.appendChild(stylePanel); + + const footer = document.createElement("div"); + footer.style.cssText = + "display:flex;align-items:center;justify-content:space-between;gap:8px;padding:7px 8px;border-top:1px solid rgba(0,0,0,.06);background:rgba(255,255,255,.92)"; + const adjust = createButton("Adjust", "Show style controls for selected elements"); + adjust.style.cssText += + ";display:none;padding:5px 7px;color:#52525b;background:#f4f4f5;font-size:11px"; + const submit = createButton("Attach", "Attach annotation and screenshot"); + submit.style.cssText += `;background:${ACCENT};color:white;padding:6px 9px;font-size:11px`; + footer.append(adjust, submit); + editor.appendChild(footer); + + const selected = new Map<Element, SelectedElement>(); + const regions: PreviewAnnotationRegionTarget[] = []; + const strokes: PreviewAnnotationStrokeTarget[] = []; + const styleChanges = new Map<string, PreviewAnnotationStyleChange>(); + const toolButtons = new Map<AnnotationTool, HTMLButtonElement>(); + let tool: AnnotationTool = "select"; + let dragStart: PreviewAnnotationPoint | null = null; + let activeStroke: { target: PreviewAnnotationStrokeTarget; path: SVGPathElement } | null = null; + let pendingCapture = false; + let stylesExpanded = false; + let editorWasShown = false; + let editorPosition: { left: number; top: number } | null = null; + let editorDrag: { pointerId: number; offsetX: number; offsetY: number } | null = null; + + const updateStatus = (): void => { + const parts = [ + selected.size > 0 ? `${selected.size} element${selected.size === 1 ? "" : "s"}` : "", + regions.length > 0 ? `${regions.length} region${regions.length === 1 ? "" : "s"}` : "", + strokes.length > 0 ? `${strokes.length} drawing${strokes.length === 1 ? "" : "s"}` : "", + ].filter(Boolean); + const hasTargets = parts.length > 0; + status.textContent = parts.join(" · "); + editor.style.display = hasTargets ? "flex" : "none"; + submit.disabled = !hasTargets; + submit.style.opacity = hasTargets ? "1" : "0.45"; + adjust.style.display = selected.size > 0 ? "block" : "none"; + if (selected.size === 0 && stylesExpanded) { + stylesExpanded = false; + stylePanel.style.display = "none"; + adjust.textContent = "Adjust"; + } + if (hasTargets && !editorWasShown) { + editorWasShown = true; + window.setTimeout(() => comment.focus({ preventScroll: true }), 0); } }; - // Re-paint when an in-flight component-name lookup resolves, but only if - // the same element is still under the cursor — otherwise the user moved on - // and we'd clobber the next element's label. - const onComponentNameResolved = (resolvedFor: Element): void => { - if (lastTarget !== resolvedFor) return; - paintOutline(handles, resolvedFor); + const refreshToolButtons = (): void => { + for (const [candidate, button] of toolButtons) { + const active = candidate === tool; + button.style.background = active ? ACCENT_FILL : "transparent"; + button.style.color = active ? ACCENT : "#27272a"; + } + if (tool !== "select") hoverOutline.style.display = "none"; + if (tool !== "marquee") marqueeBox.style.display = "none"; + document.documentElement.setAttribute("data-t3code-annotation-tool", tool); }; - const onMove = (event: MouseEvent): void => { - const target = pickFromPoint(event.clientX, event.clientY); - if (target === lastTarget) return; - lastTarget = target; - if (target) { - paintOutline(handles, target); - ensureComponentName(target, onComponentNameResolved); - } else { - clearOutline(handles); + const removeSelected = (target: SelectedElement): void => { + if (target.element instanceof HTMLElement || target.element instanceof SVGElement) { + for (const [property, baseline] of target.baselineStyles) { + if (baseline) target.element.style.setProperty(property, baseline); + else target.element.style.removeProperty(property); + } + } + selected.delete(target.element); + target.outline.remove(); + target.label.remove(); + for (const [key, change] of styleChanges) { + if (change.targetId === target.id) styleChanges.delete(key); } + updateStatus(); }; - const onScrollOrResize = (): void => { - if (lastTarget) paintOutline(handles, lastTarget); + const addSelected = (element: Element): void => { + if (selected.has(element)) return; + const target: SelectedElement = { + id: nextId("element"), + element, + outline: createBox(ACCENT, ACCENT_FILL), + label: createLabel(), + baselineStyles: new Map(), + }; + selected.set(element, target); + root.append(target.outline, target.label); + updateSelectedVisual(target); + updateStatus(); + if (stylesExpanded) syncStyleControls(); }; - const onClick = (event: MouseEvent): void => { + const toggleSelected = (element: Element, additive: boolean): void => { + const existing = selected.get(element); + if (existing) { + removeSelected(existing); + return; + } + if (!additive) { + for (const target of Array.from(selected.values())) removeSelected(target); + } + addSelected(element); + }; + + const setStyleForSelected = (property: string, value: string): void => { + for (const target of selected.values()) { + if (!(target.element instanceof HTMLElement || target.element instanceof SVGElement)) + continue; + if (!target.baselineStyles.has(property)) { + target.baselineStyles.set(property, target.element.style.getPropertyValue(property)); + } + const key = `${target.id}:${property}`; + const previousValue = + styleChanges.get(key)?.previousValue ?? + getComputedStyle(target.element).getPropertyValue(property).trim(); + target.element.style.setProperty(property, value, "important"); + styleChanges.set(key, { + targetId: target.id, + selector: null, + property, + previousValue, + value, + }); + updateSelectedVisual(target); + } + }; + + const textSection = createStyleSection(); + const colorsSection = createStyleSection(); + const bordersSection = createStyleSection(); + const sizingSection = createStyleSection(); + stylePanel.append(textSection, colorsSection, bordersSection, sizingSection); + + const fontFamily = document.createElement("select"); + for (const value of ["inherit", "system-ui", "sans-serif", "serif", "monospace"]) { + const option = document.createElement("option"); + option.value = value; + option.textContent = value; + fontFamily.appendChild(option); + } + fontFamily.addEventListener("change", () => setStyleForSelected("font-family", fontFamily.value)); + textSection.appendChild(createField("Font", fontFamily)); + + const fontSize = createUnitInput("px", "16"); + fontSize.min = "1"; + fontSize.max = "300"; + fontSize.addEventListener("input", () => { + if (fontSize.value) setStyleForSelected("font-size", `${fontSize.value}px`); + }); + textSection.appendChild(createField("Font size", fontSize)); + + const fontWeight = document.createElement("select"); + for (const value of ["300", "400", "500", "600", "700", "800", "900"]) { + const option = document.createElement("option"); + option.value = value; + option.textContent = value; + fontWeight.appendChild(option); + } + fontWeight.addEventListener("change", () => setStyleForSelected("font-weight", fontWeight.value)); + textSection.appendChild(createField("Font weight", fontWeight)); + + const lineHeight = document.createElement("input"); + lineHeight.type = "text"; + lineHeight.placeholder = "normal / 1.4"; + lineHeight.addEventListener("change", () => { + if (lineHeight.value.trim()) setStyleForSelected("line-height", lineHeight.value.trim()); + }); + textSection.appendChild(createField("Line height", lineHeight)); + + const createColorRow = ( + labelText: string, + property: string, + section: HTMLElement, + ): { row: HTMLLabelElement; color: HTMLInputElement; text: HTMLInputElement } => { + const row = document.createElement("label"); + row.style.cssText = + "display:grid;grid-template-columns:82px minmax(0,1fr);align-items:center;gap:8px;min-height:28px;font:500 11px/1.2 ui-sans-serif,system-ui;color:#52525b"; + const label = document.createElement("span"); + label.textContent = labelText; + const control = document.createElement("div"); + control.style.cssText = + "display:grid;grid-template-columns:22px minmax(0,1fr);align-items:center;gap:5px;height:26px;padding:2px 5px;border-radius:7px;background:rgba(255,255,255,.78);box-shadow:inset 0 0 0 1px rgba(0,0,0,.09)"; + const color = document.createElement("input"); + color.type = "color"; + color.setAttribute("aria-label", labelText); + color.style.cssText = + "width:20px;height:20px;padding:0;border:0;border-radius:5px;overflow:hidden;background:transparent;cursor:pointer"; + const text = document.createElement("input"); + text.type = "text"; + text.setAttribute("aria-label", `${labelText} value`); + text.style.cssText = + "min-width:0;width:100%;border:0;background:transparent;color:#52525b;font:500 10px/1 ui-monospace,SFMono-Regular,Menlo,monospace;outline:none"; + color.addEventListener("input", () => { + text.value = color.value; + setStyleForSelected(property, color.value); + }); + text.addEventListener("change", () => { + const value = text.value.trim(); + if (!value) return; + setStyleForSelected(property, value); + if (/^#[0-9a-f]{6}$/i.test(value)) color.value = value; + }); + control.append(color, text); + row.append(label, control); + section.appendChild(row); + return { row, color, text }; + }; + + const textColor = createColorRow("Text color", "color", colorsSection); + const backgroundColor = createColorRow("Background", "background-color", colorsSection); + + const opacity = document.createElement("input"); + opacity.type = "range"; + opacity.min = "0"; + opacity.max = "1"; + opacity.step = "0.05"; + opacity.value = "1"; + opacity.style.accentColor = ACCENT; + opacity.addEventListener("input", () => setStyleForSelected("opacity", opacity.value)); + colorsSection.appendChild(createField("Opacity", opacity)); + + const radius = createUnitInput("px", "0"); + radius.min = "0"; + radius.max = "300"; + radius.addEventListener("input", () => { + if (radius.value) setStyleForSelected("border-radius", `${radius.value}px`); + }); + bordersSection.appendChild(createField("Radius", radius)); + + const borderColor = createColorRow("Border color", "border-color", bordersSection); + + const borderWidth = createUnitInput("px", "0"); + borderWidth.min = "0"; + borderWidth.max = "100"; + borderWidth.addEventListener("input", () => { + if (borderWidth.value) { + setStyleForSelected("border-style", "solid"); + setStyleForSelected("border-width", `${borderWidth.value}px`); + } + }); + bordersSection.appendChild(createField("Border width", borderWidth)); + + const dimensions = document.createElement("div"); + dimensions.style.cssText = + "display:grid;grid-template-columns:82px minmax(0,1fr);gap:8px;align-items:center"; + const dimensionLabel = document.createElement("div"); + dimensionLabel.style.cssText = + "display:grid;gap:9px;font:500 11px/1.2 ui-sans-serif,system-ui;color:#52525b"; + dimensionLabel.innerHTML = "<span>Width</span><span>Height</span>"; + const dimensionControls = document.createElement("div"); + dimensionControls.style.cssText = "position:relative;display:grid;gap:3px;padding-left:22px"; + const widthInput = createUnitInput("px", "auto"); + const heightInput = createUnitInput("px", "auto"); + styleControl(widthInput); + styleControl(heightInput); + const aspectLock = createButton("", "Lock aspect ratio"); + aspectLock.setAttribute("aria-pressed", "true"); + aspectLock.style.cssText += + ";position:absolute;left:0;top:50%;transform:translateY(-50%);width:18px;height:38px;padding:0;border-radius:6px;background:#ede9fe;color:#6d28d9"; + dimensionControls.append( + createUnitControl(widthInput), + createUnitControl(heightInput), + aspectLock, + ); + dimensions.append(dimensionLabel, dimensionControls); + sizingSection.appendChild(dimensions); + + let aspectLocked = true; + let aspectRatio = 1; + const refreshAspectButton = (): void => { + aspectLock.innerHTML = aspectLocked + ? '<svg viewBox="0 0 20 20" width="14" height="14" aria-hidden="true"><path d="M8 6.5 9.5 5A3.5 3.5 0 0 1 14.5 10l-1.5 1.5M12 13.5 10.5 15A3.5 3.5 0 0 1 5.5 10L7 8.5M7.5 12.5l5-5" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>' + : '<svg viewBox="0 0 20 20" width="14" height="14" aria-hidden="true"><path d="m6 6 8 8M8 6.5 9.5 5A3.5 3.5 0 0 1 14 9M12 13.5 10.5 15A3.5 3.5 0 0 1 6 11" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>'; + aspectLock.setAttribute("aria-pressed", String(aspectLocked)); + aspectLock.style.background = aspectLocked ? "#ede9fe" : "#f4f4f5"; + aspectLock.style.color = aspectLocked ? "#6d28d9" : "#71717a"; + }; + aspectLock.addEventListener("click", () => { + aspectLocked = !aspectLocked; + refreshAspectButton(); + }); + widthInput.addEventListener("input", () => { + const width = Number(widthInput.value); + if (!Number.isFinite(width) || width <= 0) return; + setStyleForSelected("width", `${width}px`); + if (aspectLocked && aspectRatio > 0) { + const height = Math.max(1, Math.round(width / aspectRatio)); + heightInput.value = String(height); + setStyleForSelected("height", `${height}px`); + } + }); + heightInput.addEventListener("input", () => { + const height = Number(heightInput.value); + if (!Number.isFinite(height) || height <= 0) return; + setStyleForSelected("height", `${height}px`); + if (aspectLocked && aspectRatio > 0) { + const width = Math.max(1, Math.round(height * aspectRatio)); + widthInput.value = String(width); + setStyleForSelected("width", `${width}px`); + } + }); + refreshAspectButton(); + + const addSpacingField = ( + label: string, + property: string, + placeholder: string, + ): HTMLInputElement => { + const input = document.createElement("input"); + input.type = "text"; + input.placeholder = placeholder; + input.addEventListener("change", () => { + if (input.value.trim()) setStyleForSelected(property, input.value.trim()); + }); + sizingSection.appendChild(createField(label, input)); + return input; + }; + const padding = addSpacingField("Padding", "padding", "0 0 0 0"); + const margin = addSpacingField("Margin", "margin", "0 0 0 0"); + const gap = addSpacingField("Gap", "gap", "0px"); + + const syncStyleControls = (): void => { + const first = selected.values().next().value as SelectedElement | undefined; + if (!first) return; + const computed = getComputedStyle(first.element); + const rect = first.element.getBoundingClientRect(); + aspectRatio = rect.height > 0 ? rect.width / rect.height : 1; + widthInput.value = String(Math.round(rect.width)); + heightInput.value = String(Math.round(rect.height)); + fontSize.value = String(Math.round(Number.parseFloat(computed.fontSize) || 16)); + fontWeight.value = computed.fontWeight.match(/^[0-9]+$/) ? computed.fontWeight : "400"; + lineHeight.value = computed.lineHeight; + fontFamily.value = Array.from(fontFamily.options).some( + (option) => option.value === computed.fontFamily, + ) + ? computed.fontFamily + : "inherit"; + textColor.text.value = computed.color; + backgroundColor.text.value = computed.backgroundColor; + borderColor.text.value = computed.borderColor; + opacity.value = computed.opacity; + radius.value = String(Math.round(Number.parseFloat(computed.borderRadius) || 0)); + borderWidth.value = String(Math.round(Number.parseFloat(computed.borderWidth) || 0)); + padding.value = computed.padding; + margin.value = computed.margin; + gap.value = computed.gap === "normal" ? "0px" : computed.gap; + }; + + const tools: ReadonlyArray<[AnnotationTool, string, string]> = [ + ["select", "Select", "Select elements (V)"], + ["marquee", "Region", "Draw a region or marquee-select elements (R)"], + ["draw", "Draw", "Draw freehand (D)"], + ["erase", "Erase", "Remove an annotation target (E)"], + ]; + for (const [candidate, label, title] of tools) { + const button = createButton(label, title); + button.style.cssText += ";padding:5px 7px;font-size:11px;border-radius:6px"; + button.addEventListener("click", () => { + tool = candidate; + refreshToolButtons(); + }); + toolButtons.set(candidate, button); + toolbar.appendChild(button); + } + + adjust.addEventListener("click", () => { + if (selected.size === 0) return; + stylesExpanded = !stylesExpanded; + stylePanel.style.display = stylesExpanded ? "grid" : "none"; + adjust.textContent = stylesExpanded ? "Hide styles" : "Adjust"; + if (stylesExpanded) syncStyleControls(); + }); + + const clampEditorPosition = (left: number, top: number): { left: number; top: number } => { + const margin = 8; + const rect = editor.getBoundingClientRect(); + return { + left: Math.min( + Math.max(margin, left), + Math.max(margin, window.innerWidth - rect.width - margin), + ), + top: Math.min( + Math.max(margin, top), + Math.max(margin, window.innerHeight - rect.height - margin), + ), + }; + }; + + const applyEditorPosition = (position: { left: number; top: number }): void => { + editorPosition = clampEditorPosition(position.left, position.top); + editor.style.left = `${editorPosition.left}px`; + editor.style.top = `${editorPosition.top}px`; + editor.style.right = "auto"; + editor.style.bottom = "auto"; + }; + + const onEditorPointerDown = (event: PointerEvent): void => { + if (event.button !== 0) return; + const rect = editor.getBoundingClientRect(); + editorDrag = { + pointerId: event.pointerId, + offsetX: event.clientX - rect.left, + offsetY: event.clientY - rect.top, + }; + editorHeader.setPointerCapture(event.pointerId); + editorHeader.style.cursor = "grabbing"; event.preventDefault(); event.stopPropagation(); - const target = pickFromPoint(event.clientX, event.clientY); - if (!target) { - teardown(true, null); + }; + + const onEditorPointerMove = (event: PointerEvent): void => { + if (!editorDrag || editorDrag.pointerId !== event.pointerId) return; + applyEditorPosition({ + left: event.clientX - editorDrag.offsetX, + top: event.clientY - editorDrag.offsetY, + }); + event.preventDefault(); + event.stopPropagation(); + }; + + const onEditorPointerUp = (event: PointerEvent): void => { + if (!editorDrag || editorDrag.pointerId !== event.pointerId) return; + editorDrag = null; + editorHeader.style.cursor = "grab"; + if (editorHeader.hasPointerCapture(event.pointerId)) + editorHeader.releasePointerCapture(event.pointerId); + event.preventDefault(); + event.stopPropagation(); + }; + editorHeader.addEventListener("pointerdown", onEditorPointerDown); + editorHeader.addEventListener("pointermove", onEditorPointerMove); + editorHeader.addEventListener("pointerup", onEditorPointerUp); + editorHeader.addEventListener("pointercancel", onEditorPointerUp); + + const repaint = (): void => { + for (const target of selected.values()) updateSelectedVisual(target); + if (editorPosition) applyEditorPosition(editorPosition); + }; + + const removeTargetAtPoint = (x: number, y: number): boolean => { + for (const target of Array.from(selected.values()).toReversed()) { + const rect = target.element.getBoundingClientRect(); + if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) { + removeSelected(target); + return true; + } + } + const regionIndex = regions.findIndex( + (region) => + x >= region.rect.x && + x <= region.rect.x + region.rect.width && + y >= region.rect.y && + y <= region.rect.y + region.rect.height, + ); + if (regionIndex >= 0) { + const [removed] = regions.splice(regionIndex, 1); + root.querySelector(`[data-region-id="${removed?.id}"]`)?.remove(); + updateStatus(); + return true; + } + const strokeIndex = strokes.findIndex( + (stroke) => + x >= stroke.bounds.x && + x <= stroke.bounds.x + stroke.bounds.width && + y >= stroke.bounds.y && + y <= stroke.bounds.y + stroke.bounds.height, + ); + if (strokeIndex >= 0) { + const [removed] = strokes.splice(strokeIndex, 1); + svg.querySelector(`[data-stroke-id="${removed?.id}"]`)?.remove(); + updateStatus(); + return true; + } + return false; + }; + + const selectElementsInRect = (rect: PreviewAnnotationRect): number => { + const candidates = Array.from(document.querySelectorAll("body *")) + .filter((element) => !isAnnotationNode(element)) + .map((element) => ({ element, rect: element.getBoundingClientRect() })) + .filter(({ rect: candidate }) => { + if (candidate.width < 2 || candidate.height < 2) return false; + return !( + candidate.right < rect.x || + candidate.left > rect.x + rect.width || + candidate.bottom < rect.y || + candidate.top > rect.y + rect.height + ); + }) + .filter(({ element, rect: candidate }) => { + const centerX = candidate.left + candidate.width / 2; + const centerY = candidate.top + candidate.height / 2; + return ( + centerX >= rect.x && + centerX <= rect.x + rect.width && + centerY >= rect.y && + centerY <= rect.y + rect.height && + (element.children.length === 0 || + element instanceof HTMLButtonElement || + element instanceof HTMLAnchorElement || + element.getAttribute("role") === "button") + ); + }) + .sort( + (left, right) => left.rect.width * left.rect.height - right.rect.width * right.rect.height, + ) + .slice(0, MAX_MARQUEE_ELEMENTS); + for (const candidate of candidates) addSelected(candidate.element); + return candidates.length; + }; + + const clearHoverOutline = (): void => { + hoverOutline.style.display = "none"; + }; + + const onPointerMove = (event: PointerEvent): void => { + if (isAnnotationNode(event.target as Element)) { + clearHoverOutline(); + return; + } + if (tool === "select" && dragStart === null) { + const target = pickFromPoint(event.clientX, event.clientY); + if (target) positionBox(hoverOutline, rectFromDomRect(target.getBoundingClientRect())); + else clearHoverOutline(); return; } - void captureElement(target).then((payload) => teardown(true, payload)); + clearHoverOutline(); + if (tool === "marquee" && dragStart) { + positionBox( + marqueeBox, + normalizeRect(dragStart.x, dragStart.y, event.clientX, event.clientY), + ); + return; + } + if (tool === "draw" && activeStroke) { + activeStroke.target.points = [ + ...activeStroke.target.points, + { x: event.clientX, y: event.clientY }, + ]; + activeStroke.target.bounds = strokeBounds( + activeStroke.target.points, + activeStroke.target.width, + ); + activeStroke.path.setAttribute("d", pathFromPoints(activeStroke.target.points)); + } }; - const onKey = (event: KeyboardEvent): void => { - if (event.key === "Escape") { - event.preventDefault(); - event.stopPropagation(); - teardown(true, null); + const onPointerDown = (event: PointerEvent): void => { + if (event.button !== 0 || isAnnotationNode(event.target as Element)) return; + event.preventDefault(); + event.stopPropagation(); + if (tool === "select") { + const target = pickFromPoint(event.clientX, event.clientY); + if (target) toggleSelected(target, event.shiftKey); + return; + } + if (tool === "erase") { + removeTargetAtPoint(event.clientX, event.clientY); + return; + } + dragStart = { x: event.clientX, y: event.clientY }; + if (tool === "draw") { + const stroke: PreviewAnnotationStrokeTarget = { + id: nextId("stroke"), + color: ACCENT, + width: 4, + points: [dragStart], + bounds: { x: dragStart.x, y: dragStart.y, width: 1, height: 1 }, + }; + const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); + path.setAttribute(OVERLAY_ATTRIBUTE, ""); + path.setAttribute("data-stroke-id", stroke.id); + path.setAttribute("fill", "none"); + path.setAttribute("stroke", stroke.color); + path.setAttribute("stroke-width", String(stroke.width)); + path.setAttribute("stroke-linecap", "round"); + path.setAttribute("stroke-linejoin", "round"); + svg.appendChild(path); + activeStroke = { target: stroke, path }; + } + }; + + const onPointerUp = (event: PointerEvent): void => { + if (!dragStart) return; + event.preventDefault(); + event.stopPropagation(); + if (tool === "marquee") { + const rect = normalizeRect(dragStart.x, dragStart.y, event.clientX, event.clientY); + marqueeBox.style.display = "none"; + if (isUsableRect(rect)) { + const found = selectElementsInRect(rect); + if (found === 0) { + const region: PreviewAnnotationRegionTarget = { id: nextId("region"), rect }; + regions.push(region); + const regionBox = createBox(ACCENT, "rgba(124,58,237,.06)"); + regionBox.setAttribute("data-region-id", region.id); + positionBox(regionBox, rect); + root.appendChild(regionBox); + } + } + } else if (tool === "draw" && activeStroke) { + if (activeStroke.target.points.length > 1) strokes.push(activeStroke.target); + else activeStroke.path.remove(); + activeStroke = null; } + dragStart = null; + updateStatus(); }; - const onBeforeUnload = (): void => { - teardown(true, null); + const onClick = (event: MouseEvent): void => { + if (isAnnotationNode(event.target as Element)) return; + event.preventDefault(); + event.stopPropagation(); }; - // Cancellation initiated by main (CANCEL_PICK_CHANNEL). Tear down silently - // — main already knows it cancelled and is either done waiting or about to - // register a fresh listener for a new pick. If we sent `null` here, that - // fresh listener would receive it and resolve the new pick instantly (the - // C1 race we previously hit). - const onMainCancel = (): void => { - teardown(false, null); + const onPointerOut = (event: PointerEvent): void => { + if (event.relatedTarget === null) clearHoverOutline(); }; - // Capture-phase listeners on `window` to outrun page handlers that - // `stopPropagation()` early. `passive: false` because we need preventDefault. - window.addEventListener("mousemove", onMove, { capture: true, passive: true }); - window.addEventListener("click", onClick, { capture: true }); - window.addEventListener("keydown", onKey, { capture: true }); - window.addEventListener("scroll", onScrollOrResize, { capture: true, passive: true }); - window.addEventListener("resize", onScrollOrResize, { passive: true }); - window.addEventListener("beforeunload", onBeforeUnload); - ipcRenderer.on(CANCEL_PICK_CHANNEL, onMainCancel); - - // Hand a "silent teardown" to `activeSession` so that a follow-up - // `startPick()` can supersede us without echoing `null` back to main. - activeSession = { - overlay: handles.overlay, - outline: handles.outline, - label: handles.label, - teardownSilent: () => teardown(false, null), + const onWindowBlur = (): void => { + clearHoverOutline(); }; + + const restoreStyles = (): void => { + for (const target of selected.values()) { + if (!(target.element instanceof HTMLElement || target.element instanceof SVGElement)) + continue; + for (const [property, baseline] of target.baselineStyles) { + if (baseline) target.element.style.setProperty(property, baseline); + else target.element.style.removeProperty(property); + } + } + }; + + const teardown = (notifyMain: boolean): void => { + if (finished) return; + finished = true; + restoreStyles(); + window.removeEventListener("pointermove", onPointerMove, true); + window.removeEventListener("pointerdown", onPointerDown, true); + window.removeEventListener("pointerup", onPointerUp, true); + window.removeEventListener("pointerout", onPointerOut, true); + window.removeEventListener("click", onClick, true); + window.removeEventListener("blur", onWindowBlur); + window.removeEventListener("keydown", onKeyDown, true); + window.removeEventListener("scroll", repaint, true); + window.removeEventListener("resize", repaint); + editorHeader.removeEventListener("pointerdown", onEditorPointerDown); + editorHeader.removeEventListener("pointermove", onEditorPointerMove); + editorHeader.removeEventListener("pointerup", onEditorPointerUp); + editorHeader.removeEventListener("pointercancel", onEditorPointerUp); + ipcRenderer.off(CANCEL_PICK_CHANNEL, onCancel); + ipcRenderer.off(ANNOTATION_CAPTURED_CHANNEL, onCaptured); + document.documentElement.removeAttribute("data-t3code-annotation-tool"); + root.remove(); + activeSession = null; + if (notifyMain) ipcRenderer.send(ELEMENT_PICKED_CHANNEL, null); + }; + + const onCancel = (): void => teardown(false); + const onCaptured = (): void => teardown(false); + const onKeyDown = (event: KeyboardEvent): void => { + if (isAnnotationNode(event.target as Element) && event.key !== "Escape") return; + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + teardown(true); + return; + } + if (event.key === "v") tool = "select"; + else if (event.key === "r") tool = "marquee"; + else if (event.key === "d") tool = "draw"; + else if (event.key === "e") tool = "erase"; + else return; + refreshToolButtons(); + }; + + submit.addEventListener("click", () => { + if (pendingCapture || (selected.size === 0 && regions.length === 0 && strokes.length === 0)) + return; + pendingCapture = true; + submit.disabled = true; + submit.textContent = "Capturing…"; + void Promise.all( + Array.from(selected.values()).map(async (target) => { + const element = await captureElement(target.element); + if (!element) return null; + for (const change of styleChanges.values()) { + if (change.targetId === target.id) change.selector = element.selector; + } + return { + id: target.id, + element, + rect: rectFromDomRect(target.element.getBoundingClientRect()), + }; + }), + ).then((captured) => { + const elements = captured.filter((target) => target !== null); + const annotation: PreviewAnnotationPayload = { + id: nextId("annotation"), + pageUrl: location.href, + pageTitle: document.title?.trim() || null, + comment: comment.value.trim(), + elements, + regions: [...regions], + strokes: [...strokes], + styleChanges: Array.from(styleChanges.values()), + screenshot: null, + createdAt: new Date().toISOString(), + }; + editor.style.display = "none"; + toolbar.style.display = "none"; + hoverOutline.style.display = "none"; + const screenshotRect = unionRects([ + ...elements.map((target) => target.rect), + ...regions.map((region) => region.rect), + ...strokes.map((stroke) => stroke.bounds), + ]); + ipcRenderer.send(ELEMENT_PICKED_CHANNEL, annotation, screenshotRect); + }); + }); + comment.addEventListener("keydown", (event) => { + if (event.key !== "Enter" || !(event.metaKey || event.ctrlKey)) return; + event.preventDefault(); + submit.click(); + }); + + window.addEventListener("pointermove", onPointerMove, { capture: true, passive: false }); + window.addEventListener("pointerdown", onPointerDown, { capture: true, passive: false }); + window.addEventListener("pointerup", onPointerUp, { capture: true, passive: false }); + window.addEventListener("pointerout", onPointerOut, { capture: true, passive: true }); + window.addEventListener("click", onClick, { capture: true, passive: false }); + window.addEventListener("blur", onWindowBlur); + window.addEventListener("keydown", onKeyDown, { capture: true }); + window.addEventListener("scroll", repaint, { capture: true, passive: true }); + window.addEventListener("resize", repaint, { passive: true }); + ipcRenderer.on(CANCEL_PICK_CHANNEL, onCancel); + ipcRenderer.on(ANNOTATION_CAPTURED_CHANNEL, onCaptured); + document.documentElement.appendChild(root); + refreshToolButtons(); + updateStatus(); + activeSession = { teardown }; } -ipcRenderer.on(START_PICK_CHANNEL, () => { - startPick(); -}); +ipcRenderer.on(START_PICK_CHANNEL, () => startAnnotation()); +ipcRenderer.on(CANCEL_PICK_CHANNEL, () => activeSession?.teardown(false)); diff --git a/apps/desktop/src/preview-view-manager.ts b/apps/desktop/src/preview-view-manager.ts index a49695430ca..1f378e90cea 100644 --- a/apps/desktop/src/preview-view-manager.ts +++ b/apps/desktop/src/preview-view-manager.ts @@ -6,16 +6,17 @@ * elements live in the renderer; we only attach listeners and forward state * here). Single layer-scoped browser session partition. */ -import type { PickedElementPayload } from "@t3tools/contracts"; +import type { PreviewAnnotationPayload, PreviewAnnotationRect } from "@t3tools/contracts"; import { normalizePreviewUrl } from "@t3tools/shared/preview"; import { type BrowserWindow, type Session, session, webContents } from "electron"; -import { isPickedElementPayload } from "./picked-element-payload.ts"; +import { isPreviewAnnotationPayload } from "./picked-element-payload.ts"; const PREVIEW_PARTITION = "persist:t3code-preview"; const START_PICK_CHANNEL = "preview:start-pick"; const CANCEL_PICK_CHANNEL = "preview:cancel-pick"; const ELEMENT_PICKED_CHANNEL = "preview:element-picked"; +const ANNOTATION_CAPTURED_CHANNEL = "preview:annotation-captured"; // Re-export the guest webview security posture from its dedicated module so // the constant is unit-testable in isolation. See @@ -53,6 +54,58 @@ const ZOOM_LEVELS: ReadonlyArray<number> = [ const DEFAULT_ZOOM_FACTOR = 1.0; const ZOOM_EPSILON = 0.001; +const normalizeCaptureRect = (value: unknown): PreviewAnnotationRect | null => { + if (typeof value !== "object" || value === null) return null; + const rect = value as Record<string, unknown>; + const x = rect["x"]; + const y = rect["y"]; + const width = rect["width"]; + const height = rect["height"]; + if ( + typeof x !== "number" || + !Number.isFinite(x) || + typeof y !== "number" || + !Number.isFinite(y) || + typeof width !== "number" || + !Number.isFinite(width) || + typeof height !== "number" || + !Number.isFinite(height) || + width <= 0 || + height <= 0 + ) { + return null; + } + return { + x: Math.max(0, Math.floor(x)), + y: Math.max(0, Math.floor(y)), + width: Math.max(1, Math.ceil(width)), + height: Math.max(1, Math.ceil(height)), + }; +}; + +const captureAnnotationScreenshot = async ( + wc: Electron.WebContents, + cropRect: PreviewAnnotationRect | null, +) => { + const image = await wc.capturePage( + cropRect + ? { + x: cropRect.x, + y: cropRect.y, + width: cropRect.width, + height: cropRect.height, + } + : undefined, + ); + const size = image.getSize(); + return { + dataUrl: image.toDataURL(), + width: size.width, + height: size.height, + cropRect: cropRect ?? { x: 0, y: 0, width: size.width, height: size.height }, + }; +}; + const findZoomStep = (current: number): number => { for (let index = 0; index < ZOOM_LEVELS.length; index += 1) { if (Math.abs(ZOOM_LEVELS[index]! - current) < ZOOM_EPSILON) return index; @@ -77,7 +130,7 @@ interface ManagedListeners { } interface PickSession { - readonly resolve: (payload: PickedElementPayload | null) => void; + readonly resolve: (payload: PreviewAnnotationPayload | null) => void; readonly cleanup: () => void; } @@ -103,7 +156,7 @@ export class PreviewViewManager { private readonly attached = new Map<number, ManagedListeners>(); private browserSession: Session | null = null; private readonly listeners = new Set<Listener>(); - /** In-flight element-pick sessions, keyed by tabId (one pick per tab). */ + /** In-flight preview annotation sessions, keyed by tabId. */ private readonly pickSessions = new Map<string, PickSession>(); setMainWindow(window: BrowserWindow): void { @@ -290,17 +343,17 @@ export class PreviewViewManager { } /** - * Activate the in-page element picker for `tabId`. Resolves with the - * `PickedElementPayload` the preload script bubbles back via `ipc-message`, - * or `null` when the user cancels (Escape, navigation, manual cancel). + * Activate annotation mode for `tabId`. Resolves after the guest submits a + * multi-target annotation and the desktop process captures its screenshot, + * or with `null` when the user cancels. * * Exactly one pick session may be active per tab — re-invoking while a * pick is in flight cleanly resolves the old session with `null` first. */ - async pickElement(tabId: string): Promise<PickedElementPayload | null> { + async pickElement(tabId: string): Promise<PreviewAnnotationPayload | null> { const wc = this.requireWebContents(tabId); this.cancelPickElement(tabId); - return new Promise<PickedElementPayload | null>((resolve) => { + return new Promise<PreviewAnnotationPayload | null>((resolve) => { // `wc.ipc` is the per-WebContents IpcMain that receives messages the // webview's preload sends with `ipcRenderer.send(...)`. We use that // (not the global `wc.on("ipc-message", ...)`, which is for @@ -313,7 +366,7 @@ export class PreviewViewManager { this.pickSessions.delete(tabId); }; const session: PickSession = { resolve, cleanup }; - const settle = (payload: PickedElementPayload | null) => { + const settle = (payload: PreviewAnnotationPayload | null) => { if (this.pickSessions.get(tabId) !== session) return; cleanup(); resolve(payload); @@ -324,11 +377,22 @@ export class PreviewViewManager { settle(null); return; } - if (!isPickedElementPayload(payload)) { + if (!isPreviewAnnotationPayload(payload)) { settle(null); return; } - settle(payload); + const cropRect = normalizeCaptureRect(args[1]); + void captureAnnotationScreenshot(wc, cropRect) + .then((screenshot) => settle({ ...payload, screenshot })) + .catch(() => settle(payload)) + .finally(() => { + if (wc.isDestroyed()) return; + try { + wc.send(ANNOTATION_CAPTURED_CHANNEL); + } catch { + // The guest may have navigated while capture was in flight. + } + }); }; const onDestroyed = () => settle(null); const onNavigated = () => settle(null); diff --git a/apps/web/package.json b/apps/web/package.json index b0910fe608f..cbf554a6679 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -64,7 +64,6 @@ "babel-plugin-react-compiler": "1.0.0", "msw": "2.12.11", "playwright": "^1.58.2", - "react-grab": "^0.1.32", "tailwindcss": "^4.0.0", "vite": "catalog:", "vite-plus": "catalog:", diff --git a/apps/web/src/components/preview/PreviewChromeRow.tsx b/apps/web/src/components/preview/PreviewChromeRow.tsx index cdb46501de0..c84d27345a8 100644 --- a/apps/web/src/components/preview/PreviewChromeRow.tsx +++ b/apps/web/src/components/preview/PreviewChromeRow.tsx @@ -37,8 +37,8 @@ interface Props { /** When provided, renders an "Open in browser" affordance to the right. */ onOpenInBrowser?: (() => void) | undefined; /** - * When provided, renders a "Select element" toggle button to the right of - * the URL input. Pressed while a pick is active (button shows in `pressed` + * When provided, renders an annotation-mode toggle button to the right of + * the URL input. Pressed while annotation mode is active (button shows in `pressed` * state). Disabled in `pickDisabled` mode. */ onPickElement?: (() => void) | undefined; @@ -192,7 +192,7 @@ export function PreviewChromeRow({ size="icon-xs" onClick={onPickElement} disabled={pickDisabled} - aria-label={pickActive ? "Cancel element pick" : "Select element from page"} + aria-label={pickActive ? "Cancel annotation" : "Annotate preview"} aria-pressed={pickActive ? "true" : "false"} type="button" /> @@ -204,8 +204,8 @@ export function PreviewChromeRow({ {pickDisabled && pickDisabledReason ? pickDisabledReason : pickActive - ? "Cancel pick (Esc)" - : "Select element to attach"} + ? "Cancel annotation (Esc)" + : "Annotate elements, regions, and drawings"} </TooltipPopup> </Tooltip> ) : null} diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index fb61fcb354c..0f4e6651458 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -7,6 +7,10 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useComposerDraftStore } from "~/composerDraftStore"; import { ensureEnvironmentApi } from "~/environmentApi"; import { normalizeElementContextSelection } from "~/lib/elementContext"; +import { + appendPreviewAnnotationPrompt, + previewAnnotationScreenshotFile, +} from "~/lib/previewAnnotation"; import { ensureLocalApi } from "~/localApi"; import { selectThreadPreviewState, usePreviewStateStore } from "~/previewStateStore"; @@ -45,6 +49,9 @@ export function PreviewView({ threadRef, configuredUrls, visible }: Props) { const applyServerSnapshot = usePreviewStateStore((state) => state.applyServerSnapshot); const rememberUrl = usePreviewStateStore((state) => state.rememberUrl); const addElementContext = useComposerDraftStore((store) => store.addElementContext); + const addImage = useComposerDraftStore((store) => store.addImage); + const getComposerDraft = useComposerDraftStore((store) => store.getComposerDraft); + const setPrompt = useComposerDraftStore((store) => store.setPrompt); usePreviewSession(threadRef); @@ -137,11 +144,26 @@ export function PreviewView({ threadRef, configuredUrls, visible }: Props) { setPickActive(true); void (async () => { try { - const payload = await previewBridge.pickElement(tabId); - if (!payload) return; - const selection = normalizeElementContextSelection(payload); - if (!selection) return; - addElementContext(threadRef, selection); + const annotation = await previewBridge.pickElement(tabId); + if (!annotation) return; + for (const target of annotation.elements) { + const selection = normalizeElementContextSelection(target.element); + if (selection) addElementContext(threadRef, selection); + } + const currentPrompt = getComposerDraft(threadRef)?.prompt ?? ""; + setPrompt(threadRef, appendPreviewAnnotationPrompt(currentPrompt, annotation)); + const screenshotFile = await previewAnnotationScreenshotFile(annotation); + if (screenshotFile && annotation.screenshot) { + addImage(threadRef, { + type: "image", + id: annotation.id, + name: screenshotFile.name, + mimeType: screenshotFile.type, + sizeBytes: screenshotFile.size, + previewUrl: annotation.screenshot.dataUrl, + file: screenshotFile, + }); + } } catch { // Picker failed (e.g. webview navigated). Treat as silent cancel. } finally { @@ -165,7 +187,7 @@ export function PreviewView({ threadRef, configuredUrls, visible }: Props) { } } })(); - }, [addElementContext, tabId, threadRef]); + }, [addElementContext, addImage, getComposerDraft, setPrompt, tabId, threadRef]); // If the active tab changes mid-pick (close, thread switch, hot restart), // tell main to tear down the in-flight session AND reset our local toggle diff --git a/apps/web/src/lib/previewAnnotation.test.ts b/apps/web/src/lib/previewAnnotation.test.ts new file mode 100644 index 00000000000..5dc5c0b6601 --- /dev/null +++ b/apps/web/src/lib/previewAnnotation.test.ts @@ -0,0 +1,60 @@ +import type { PreviewAnnotationPayload } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { appendPreviewAnnotationPrompt, buildPreviewAnnotationPrompt } from "./previewAnnotation"; + +const annotation: PreviewAnnotationPayload = { + id: "annotation_1", + pageUrl: "http://localhost:3000", + pageTitle: "Example", + comment: "Make these cards feel related.", + elements: [], + regions: [{ id: "region_1", rect: { x: 10, y: 20, width: 100, height: 80 } }], + strokes: [ + { + id: "stroke_1", + color: "#7c3aed", + width: 4, + points: [ + { x: 10, y: 10 }, + { x: 20, y: 20 }, + ], + bounds: { x: 6, y: 6, width: 18, height: 18 }, + }, + ], + styleChanges: [ + { + targetId: "element_1", + selector: ".card", + property: "border-radius", + previousValue: "4px", + value: "16px", + }, + ], + screenshot: { + dataUrl: "data:image/png;base64,AA==", + width: 100, + height: 80, + cropRect: { x: 10, y: 20, width: 100, height: 80 }, + }, + createdAt: "2026-06-11T00:00:00.000Z", +}; + +describe("preview annotations", () => { + it("describes regions, drawings, styles, and screenshot context", () => { + const result = buildPreviewAnnotationPrompt(annotation); + expect(result).toContain("Make these cards feel related."); + expect(result).toContain("1 marked region"); + expect(result).toContain("1 drawing"); + expect(result).toContain("border-radius: 4px → 16px"); + expect(result).toContain("attached screenshot"); + }); + + it("appends to an existing composer prompt", () => { + expect( + appendPreviewAnnotationPrompt("Fix this", annotation).startsWith( + "Fix this\n\nPreview annotation:", + ), + ).toBe(true); + }); +}); diff --git a/apps/web/src/lib/previewAnnotation.ts b/apps/web/src/lib/previewAnnotation.ts new file mode 100644 index 00000000000..c5d3747967d --- /dev/null +++ b/apps/web/src/lib/previewAnnotation.ts @@ -0,0 +1,53 @@ +import type { PreviewAnnotationPayload } from "@t3tools/contracts"; + +export function buildPreviewAnnotationPrompt(annotation: PreviewAnnotationPayload): string { + const lines = ["Preview annotation:"]; + if (annotation.comment.trim()) lines.push(annotation.comment.trim()); + const targets: string[] = []; + if (annotation.elements.length > 0) { + targets.push( + `${annotation.elements.length} selected element${annotation.elements.length === 1 ? "" : "s"}`, + ); + } + if (annotation.regions.length > 0) { + targets.push( + `${annotation.regions.length} marked region${annotation.regions.length === 1 ? "" : "s"}`, + ); + } + if (annotation.strokes.length > 0) { + targets.push( + `${annotation.strokes.length} drawing${annotation.strokes.length === 1 ? "" : "s"}`, + ); + } + if (targets.length > 0) lines.push(`Targets: ${targets.join(", ")}.`); + if (annotation.styleChanges.length > 0) { + lines.push("Requested visual changes:"); + for (const change of annotation.styleChanges) { + lines.push(`- ${change.property}: ${change.previousValue || "(unset)"} → ${change.value}`); + } + } + if (annotation.screenshot) { + lines.push("The attached screenshot is the annotated preview crop."); + } + return lines.join("\n"); +} + +export function appendPreviewAnnotationPrompt( + prompt: string, + annotation: PreviewAnnotationPayload, +): string { + const annotationText = buildPreviewAnnotationPrompt(annotation); + const trimmed = prompt.trim(); + return trimmed ? `${trimmed}\n\n${annotationText}` : annotationText; +} + +export async function previewAnnotationScreenshotFile( + annotation: PreviewAnnotationPayload, +): Promise<File | null> { + if (!annotation.screenshot) return null; + const response = await fetch(annotation.screenshot.dataUrl); + const blob = await response.blob(); + return new File([blob], `preview-annotation-${annotation.id}.png`, { + type: blob.type || "image/png", + }); +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index b4aff4f2cac..e92cad9aa3d 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -18,10 +18,6 @@ import { getRouter } from "./router"; import { APP_DISPLAY_NAME } from "./branding"; import { syncDocumentWindowControlsOverlayClass } from "./lib/windowControlsOverlay"; -if (import.meta.env.DEV) { - void import("react-grab"); -} - // Electron loads the app from a file-backed shell, so hash history avoids path resolution issues. const history = isElectron ? createHashHistory() : createBrowserHistory(); diff --git a/apps/web/src/reactGrabBoundary.test.ts b/apps/web/src/reactGrabBoundary.test.ts new file mode 100644 index 00000000000..672bd9f3f9b --- /dev/null +++ b/apps/web/src/reactGrabBoundary.test.ts @@ -0,0 +1,15 @@ +// @effect-diagnostics nodeBuiltinImport:off +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vite-plus/test"; + +import packageJson from "../package.json" with { type: "json" }; + +describe("React Grab runtime boundary", () => { + it("keeps the host renderer free of the React Grab overlay", () => { + const mainSource = readFileSync(new URL("./main.tsx", import.meta.url), "utf8"); + + expect(mainSource).not.toMatch(/import\(["']react-grab["']\)/); + expect(packageJson.dependencies).not.toHaveProperty("react-grab"); + expect(packageJson.devDependencies).not.toHaveProperty("react-grab"); + }); +}); diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 5bb68c6575a..161aaf69a6b 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -505,6 +505,70 @@ export interface PickedElementPayload { pickedAt: string; } +export interface PreviewAnnotationRect { + x: number; + y: number; + width: number; + height: number; +} + +export interface PreviewAnnotationPoint { + x: number; + y: number; +} + +export interface PreviewAnnotationElementTarget { + id: string; + element: PickedElementPayload; + rect: PreviewAnnotationRect; +} + +export interface PreviewAnnotationRegionTarget { + id: string; + rect: PreviewAnnotationRect; +} + +export interface PreviewAnnotationStrokeTarget { + id: string; + color: string; + width: number; + points: ReadonlyArray<PreviewAnnotationPoint>; + bounds: PreviewAnnotationRect; +} + +export interface PreviewAnnotationStyleChange { + targetId: string; + selector: string | null; + property: string; + previousValue: string; + value: string; +} + +export interface PreviewAnnotationScreenshot { + dataUrl: string; + width: number; + height: number; + cropRect: PreviewAnnotationRect; +} + +/** + * A submitted preview annotation. One annotation may reference multiple DOM + * elements, freeform regions, and ink strokes. The desktop main process adds + * the screenshot after the guest preload submits the structured draft. + */ +export interface PreviewAnnotationPayload { + id: string; + pageUrl: string; + pageTitle: string | null; + comment: string; + elements: ReadonlyArray<PreviewAnnotationElementTarget>; + regions: ReadonlyArray<PreviewAnnotationRegionTarget>; + strokes: ReadonlyArray<PreviewAnnotationStrokeTarget>; + styleChanges: ReadonlyArray<PreviewAnnotationStyleChange>; + screenshot: PreviewAnnotationScreenshot | null; + createdAt: string; +} + export interface DesktopBridge { getAppBranding: () => DesktopAppBranding | null; getLocalEnvironmentBootstrap: () => DesktopEnvironmentBootstrap | null; @@ -601,8 +665,8 @@ export interface DesktopPreviewBridge { * the picked payload, or `null` when the user cancels (Escape / nav). The * promise rejects if the picker can't be activated (no webview, etc.). */ - pickElement: (tabId: string) => Promise<PickedElementPayload | null>; - /** Cancel an in-flight `pickElement(tabId)` (renderer-side teardown). */ + pickElement: (tabId: string) => Promise<PreviewAnnotationPayload | null>; + /** Cancel an in-flight preview annotation session. */ cancelPickElement: (tabId: string) => Promise<void>; onStateChange: (listener: (tabId: string, state: DesktopPreviewTabState) => void) => () => void; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d7644d97d0d..9b012818b61 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -583,9 +583,6 @@ importers: playwright: specifier: ^1.58.2 version: 1.60.0 - react-grab: - specifier: ^0.1.32 - version: 0.1.44(react@19.2.6) tailwindcss: specifier: ^4.0.0 version: 4.3.0 From adb6897ec2ff604dfb6dfdb6fbbd9f6c33c877c4 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Thu, 11 Jun 2026 22:15:03 -0700 Subject: [PATCH 09/25] Add shared MCP preview automation --- ...-server-with-preview-automation-revised.md | 520 ++++++++++++++ ...-preview-browser-automation-via-cdp-mcp.md | 670 ++++++++++++++++++ apps/desktop/scripts/dev-electron.mjs | 9 +- apps/desktop/scripts/electron-launcher.mjs | 33 + apps/desktop/scripts/smoke-test.mjs | 5 +- apps/desktop/scripts/start-electron.mjs | 5 +- apps/desktop/src/ipc/channels.ts | 8 + apps/desktop/src/ipc/methods/preview.ts | 49 ++ apps/desktop/src/preload.ts | 18 + apps/desktop/src/preview-view-manager.test.ts | 44 ++ apps/desktop/src/preview-view-manager.ts | 485 ++++++++++++- .../src/mcp/Layers/McpHttpServer.test.ts | 133 ++++ apps/server/src/mcp/Layers/McpHttpServer.ts | 162 +++++ .../src/mcp/Layers/McpSessionRegistry.test.ts | 66 ++ .../src/mcp/Layers/McpSessionRegistry.ts | 173 +++++ .../Layers/PreviewAutomationBroker.test.ts | 65 ++ .../src/mcp/Layers/PreviewAutomationBroker.ts | 268 +++++++ .../src/mcp/Services/McpInvocationContext.ts | 33 + .../src/mcp/Services/McpProviderSession.ts | 34 + .../src/mcp/Services/McpSessionRegistry.ts | 29 + .../mcp/Services/PreviewAutomationBroker.ts | 40 ++ .../src/mcp/toolkits/preview/handlers.ts | 47 ++ apps/server/src/mcp/toolkits/preview/tools.ts | 138 ++++ .../src/provider/Layers/ClaudeAdapter.ts | 15 + .../src/provider/Layers/CodexAdapter.ts | 16 + .../provider/Layers/CodexSessionRuntime.ts | 3 +- .../src/provider/Layers/CursorAdapter.ts | 19 + .../server/src/provider/Layers/GrokAdapter.ts | 19 + .../src/provider/Layers/OpenCodeAdapter.ts | 17 + .../src/provider/Layers/ProviderService.ts | 61 +- .../src/provider/acp/AcpSessionRuntime.ts | 7 +- apps/server/src/server.ts | 27 +- apps/server/src/ws.ts | 29 + apps/web/src/components/ChatView.browser.tsx | 6 + .../web/src/components/ChatView.logic.test.ts | 46 ++ apps/web/src/components/ChatView.logic.ts | 29 +- apps/web/src/components/ChatView.tsx | 79 ++- .../preview/PreviewAutomationOwner.tsx | 287 ++++++++ apps/web/src/environmentApi.ts | 7 + .../service.threadSubscriptions.test.ts | 6 + packages/client-runtime/src/wsRpcClient.ts | 20 + packages/contracts/src/index.ts | 1 + packages/contracts/src/ipc.ts | 33 + packages/contracts/src/previewAutomation.ts | 219 ++++++ packages/contracts/src/rpc.ts | 36 + vite.config.ts | 6 + 46 files changed, 3973 insertions(+), 49 deletions(-) create mode 100644 .plans/shared-http-mcp-server-with-preview-automation-revised.md create mode 100644 .plans/visible-preview-browser-automation-via-cdp-mcp.md create mode 100644 apps/desktop/src/preview-view-manager.test.ts create mode 100644 apps/server/src/mcp/Layers/McpHttpServer.test.ts create mode 100644 apps/server/src/mcp/Layers/McpHttpServer.ts create mode 100644 apps/server/src/mcp/Layers/McpSessionRegistry.test.ts create mode 100644 apps/server/src/mcp/Layers/McpSessionRegistry.ts create mode 100644 apps/server/src/mcp/Layers/PreviewAutomationBroker.test.ts create mode 100644 apps/server/src/mcp/Layers/PreviewAutomationBroker.ts create mode 100644 apps/server/src/mcp/Services/McpInvocationContext.ts create mode 100644 apps/server/src/mcp/Services/McpProviderSession.ts create mode 100644 apps/server/src/mcp/Services/McpSessionRegistry.ts create mode 100644 apps/server/src/mcp/Services/PreviewAutomationBroker.ts create mode 100644 apps/server/src/mcp/toolkits/preview/handlers.ts create mode 100644 apps/server/src/mcp/toolkits/preview/tools.ts create mode 100644 apps/web/src/components/preview/PreviewAutomationOwner.tsx create mode 100644 packages/contracts/src/previewAutomation.ts diff --git a/.plans/shared-http-mcp-server-with-preview-automation-revised.md b/.plans/shared-http-mcp-server-with-preview-automation-revised.md new file mode 100644 index 00000000000..12b2be5f78b --- /dev/null +++ b/.plans/shared-http-mcp-server-with-preview-automation-revised.md @@ -0,0 +1,520 @@ +# Shared HTTP MCP Server with Preview Automation + +## Summary + +Embed one reusable HTTP MCP server in each T3 environment server. Every agent session connects to that shared MCP endpoint using a session-scoped bearer token. + +Preview browser automation is the first MCP toolkit, not the purpose or boundary of the MCP server. Future T3 toolkits register with the same server. + +Architecture: + +`agent session` -> `shared T3 HTTP MCP server` -> `tool dispatcher` -> `preview broker` -> `focused desktop client` -> `Electron webview via CDP` + +No per-thread MCP process, stdio transport, headless browser, or automatic remote port forwarding. + +## Process Model + +Each `apps/server` process owns exactly one MCP server instance. + +- MCP transport: HTTP. +- MCP endpoint: `/mcp`. +- MCP lifetime: environment server lifetime. +- Agent sessions create MCP protocol sessions/connections, not OS processes. +- Toolkit registration happens once during server startup. +- Provider session termination revokes only its scoped credential. + +Implement the endpoint with: + +```ts +McpServer.layerHttp({ + name: "T3 Code", + version, + path: "/mcp", +}); +``` + +Use the API from: + +`effect/unstable/ai/McpServer` + +Reference source: + +`.repos/effect-smol/packages/effect/src/unstable/ai/McpServer.ts` + +Do not implement MCP framing, initialization, session management, or JSON-RPC manually. + +## Invocation Identity + +MCP `tools/call` does not include T3 thread identity. Bind identity to the MCP connection through authentication. + +When starting or resuming a provider session, issue an opaque bearer token associated internally with: + +```ts +interface McpInvocationScope { + environmentId: EnvironmentId; + threadId: ThreadId; + providerSessionId: string; + providerInstanceId: ProviderInstanceId; + allowedCapabilities: ReadonlySet<string>; + issuedAt: string; + expiresAt: string; +} +``` + +Provider MCP configuration: + +```ts +{ + type: "http", + name: "t3", + url: `${environmentHttpBaseUrl}/mcp`, + headers: [ + { + name: "Authorization", + value: `Bearer ${token}`, + }, + ], +} +``` + +Requirements: + +- Agents never receive or pass `threadId` as a tool argument. +- Tool handlers obtain `McpInvocationScope` from request authentication middleware. +- Tokens are cryptographically random opaque values. +- Store only a hash of each token server-side. +- Revoke tokens when the provider session stops. +- Expire tokens after inactivity and at a fixed maximum lifetime. +- Server restart invalidates all tokens. +- Resuming a provider session issues a fresh token. + +## General MCP Architecture + +Add server modules such as: + +```text +apps/server/src/mcp/ + Services/ + McpSessionRegistry.ts + McpInvocationContext.ts + Layers/ + McpSessionRegistry.ts + McpHttpServer.ts + toolkits/ + preview/ + tools.ts + handlers.ts + layer.ts +``` + +### Toolkit Registration + +Define capabilities with Effect AI: + +```ts +import { McpServer, Tool, Toolkit } from "effect/unstable/ai"; +``` + +Each capability family owns: + +- `Tool.make` definitions +- a `Toolkit.make` collection +- handler services/layers +- an MCP registration layer + +Server startup merges all registration layers: + +```ts +const T3McpToolkits = Layer.mergeAll( + PreviewToolkitRegistration, + // Future toolkit registrations +); +``` + +Future filesystem, terminal, source-control, or environment tools must not require changes to MCP transport or authentication. + +### Naming + +Use stable capability-prefixed names: + +- `preview_status` +- `preview_open` +- `preview_navigate` +- `preview_snapshot` +- `preview_click` +- `preview_type` +- `preview_press` +- `preview_scroll` +- `preview_evaluate` +- `preview_wait_for` + +## MCP Authentication + +Integrate bearer authentication into the HTTP MCP route before MCP request handling. + +Authentication flow: + +1. Read `Authorization: Bearer <token>`. +2. Hash token and resolve it through `McpSessionRegistry`. +3. Verify expiration and provider-session liveness. +4. Provide `McpInvocationContext` to MCP toolkit handlers. +5. Reject invalid credentials without invoking MCP tools. +6. Update token activity timestamp after authenticated requests. + +Capability authorization occurs inside handlers or common middleware: + +```ts +yield* McpInvocationContext.requireCapability("preview"); +``` + +## Preview Automation Contracts + +Add `packages/contracts/src/previewAutomation.ts`. + +Define schemas for: + +- preview status +- opening/showing preview +- navigation +- page snapshot +- selector or coordinate click +- text entry +- key press +- scrolling +- JavaScript evaluation +- waiting for selector, text, or URL + +Define tagged errors: + +- `PreviewAutomationUnavailableError` +- `PreviewAutomationNoFocusedOwnerError` +- `PreviewAutomationUnsupportedClientError` +- `PreviewAutomationTabNotFoundError` +- `PreviewAutomationTimeoutError` +- `PreviewAutomationExecutionError` +- `PreviewAutomationInvalidSelectorError` +- `PreviewAutomationResultTooLargeError` + +## Preview Toolkit + +Implement preview tools as an independent Effect AI toolkit. + +Apply annotations: + +- `preview_status` and `preview_snapshot`: read-only +- `preview_status`: idempotent +- navigation and page interaction tools: open-world +- browser operations: non-destructive +- all tools: human-readable title and precise description + +### `preview_open` + +Input: + +```ts +{ + url?: string; + show?: boolean; + reuseExistingTab?: boolean; +} +``` + +Defaults: + +- `show: true` +- `reuseExistingTab: true` + +Behavior: + +- Show the preview panel for the scoped thread. +- Reuse its active preview tab when available. +- Create and mount a tab otherwise. +- Navigate when `url` is supplied. +- Wait until the webview has registered before returning. + +### `preview_snapshot` + +Return: + +- current URL, title, loading state +- bounded visible text +- up to 200 interactive elements +- accessibility tree +- PNG screenshot scaled to a maximum width of 1280 pixels + +Expose the screenshot as MCP image content and metadata as structured content. + +### Browser Controls + +- `preview_click`: selector or viewport coordinates +- `preview_type`: optionally focus selector and clear existing value +- `preview_press`: common keys and modifiers +- `preview_scroll`: viewport or selector target +- `preview_evaluate`: execute bounded JavaScript +- `preview_wait_for`: selector, visible text, or URL substring +- `preview_navigate`: navigate and wait for selected readiness condition + +Default operation timeout: 15 seconds. + +Maximum serialized evaluation result: 64 KB. + +Maximum visible text: 20 KB. + +## Preview Broker + +Add `PreviewAutomationBroker` to `apps/server`. + +Responsibilities: + +- Track automation-capable desktop clients. +- Track preview ownership by environment and thread. +- Route operations to the correct desktop client. +- Correlate requests and responses. +- Enforce timeouts. +- Fail pending calls when clients disconnect. + +Owner state: + +```ts +interface PreviewAutomationOwner { + clientId: string; + environmentId: EnvironmentId; + threadId: ThreadId; + tabId: PreviewTabId | null; + visible: boolean; + supportsAutomation: boolean; + focusedAt: string; +} +``` + +Routing policy: + +- Use the most recently focused Electron window displaying the scoped thread. +- Never accept environment or thread overrides from tool arguments. +- Return `PreviewAutomationNoFocusedOwnerError` when no valid owner exists. +- Do not switch the UI to a different thread automatically. + +## Server-to-Desktop Protocol + +Add WS RPCs: + +- `previewAutomation.connect` +- `previewAutomation.respond` +- `previewAutomation.reportOwner` +- `previewAutomation.clearOwner` + +`connect` is a long-lived stream from the environment server to the desktop client. + +Request: + +```ts +{ + requestId: string; + threadId: ThreadId; + tabId?: PreviewTabId; + operation: PreviewAutomationOperation; + input: unknown; + timeoutMs: number; +} +``` + +Response: + +```ts +{ + requestId: string; + ok: boolean; + result?: unknown; + error?: { + _tag: string; + message: string; + detail?: unknown; + }; +} +``` + +## Desktop Automation + +Extend `PreviewViewManager` using Electron `webContents.debugger`. + +Use CDP domains: + +- `Runtime` +- `DOM` +- `Page` +- `Accessibility` +- `Input` + +Use `webContents.capturePage()` for screenshots. + +Create a scoped CDP helper that: + +1. Resolves the tab’s webContents. +2. Attaches lazily. +3. Enables required domains. +4. Executes one bounded operation. +5. Detaches in finalization. +6. Maps protocol failures to typed errors. + +If DevTools or another debugger owns the target, return a typed automation error rather than disrupting it. + +Place pure logic in separate modules: + +- DOM summary extraction +- selector generation +- result clamping +- key mapping +- CDP response parsing + +## Desktop IPC + +Extend `DesktopPreviewBridge` with: + +```ts +automation: { + status(...): Promise<...>; + snapshot(...): Promise<...>; + click(...): Promise<...>; + type(...): Promise<...>; + press(...): Promise<...>; + scroll(...): Promise<...>; + evaluate(...): Promise<...>; + waitFor(...): Promise<...>; +} +``` + +Add schema-validated IPC channels and handlers. + +## Web Client + +Add a preview ownership hook mounted with `PreviewView`. + +Report changes to: + +- active environment/thread +- tab id +- panel visibility +- window focus +- Electron automation availability + +Handle broker requests: + +- `preview_open` opens the right panel for the active scoped thread. +- Create a preview session and tab if needed. +- Wait for webview registration. +- Other operations invoke desktop automation IPC. +- Always send a correlated success or failure response. + +Clear ownership on unmount, thread change, panel close, or desktop disconnect. + +## Provider Integration + +Add `McpSessionRegistry` integration to provider lifecycle. + +For each provider session: + +1. Issue a scoped MCP bearer token. +2. Add the shared HTTP MCP configuration to session startup. +3. Start or resume the agent session. +4. Revoke the token during provider-session finalization. + +ACP providers use their existing HTTP MCP configuration fields. + +Codex uses its supported MCP/config override mechanism to register the same shared HTTP endpoint. + +Assume every supported provider can use HTTP MCP. Do not implement stdio fallback. + +## Remote Environment Behavior + +For a Mac mini environment viewed from a MacBook: + +1. Mac mini runs the T3 environment server and shared MCP endpoint. +2. Agent session connects to that endpoint locally/remotely using its scoped token. +3. Preview tool calls enter the Mac mini preview broker. +4. Broker routes them over the existing T3 connection to the focused MacBook desktop. +5. MacBook controls its visible Electron webview. + +URLs must already be reachable from the MacBook, such as: + +- `http://mac-mini.local:5173` +- `http://192.168.1.42:5173` + +Warn when a remote environment opens `localhost`, `127.0.0.1`, or `::1`, because loopback resolves on the preview client. + +Preserve URL-resolution metadata: + +```ts +{ + requestedUrl: string; + resolvedUrl: string; + resolutionKind: "direct"; + environmentId: EnvironmentId; +} +``` + +This leaves room for future SSH, relay, or Tailscale resolution. + +## Tests + +### MCP Server + +- one MCP server layer starts per environment server +- multiple authenticated MCP clients share the same server instance +- each client receives its own invocation scope +- toolkit registration is independent of transport +- a mock future toolkit can register without changing server runtime +- malformed parameters are rejected by Effect schemas +- tool annotations appear in `tools/list` + +### Authentication + +- valid token resolves correct thread and provider session +- concurrent tokens remain isolated +- revoked and expired tokens fail +- token cannot call unauthorized capability family +- thread identity cannot be overridden in arguments +- server restart invalidates tokens + +### Preview Broker + +- focused owner receives operation +- most recently focused client wins +- wrong-thread client is never selected +- no owner returns typed error +- disconnect fails pending calls +- stale responses are ignored + +### Desktop and Web + +- agent can show and open preview +- webview registration is awaited +- CDP click, typing, key press, scroll, evaluation, and wait work +- snapshot bounds screenshot, text, and interactive elements +- ownership updates on focus and visibility changes +- background threads are not automatically activated + +### End-to-End Integration + +Run two mocked agent sessions against one HTTP MCP server: + +1. Bind each bearer token to a different thread. +2. Call `preview_status` concurrently. +3. Verify each request routes to its own focused preview owner. +4. Verify no MCP child process is spawned. +5. Revoke one provider session and confirm only its MCP access fails. + +## Validation + +- `vp check` +- `vp run typecheck` +- `vp test` + +## Assumptions + +- HTTP MCP is supported by every target provider. +- One MCP server is embedded in each T3 environment server. +- MCP connection authentication supplies invocation identity. +- Agents never know or pass T3 thread IDs. +- Preview automation is the first of multiple future MCP toolkits. +- Only Electron desktop preview clients support browser automation in v1. +- No headless browser, stdio fallback, or automatic tunnel management is included. diff --git a/.plans/visible-preview-browser-automation-via-cdp-mcp.md b/.plans/visible-preview-browser-automation-via-cdp-mcp.md new file mode 100644 index 00000000000..0b040d7a79c --- /dev/null +++ b/.plans/visible-preview-browser-automation-via-cdp-mcp.md @@ -0,0 +1,670 @@ +# Visible Preview Browser Automation via CDP + MCP + +## Summary + +Implement agent control of the user-visible T3 preview browser only. Do not add headless browser support. Do not add SSH/relay/private forwarding in v1; preview URLs must already be reachable from the desktop client, such as a Mac mini private IP URL opened on a MacBook. + +Architecture: + +`agent` -> `stdio MCP server in environment` -> `private T3 server bridge` -> `focused desktop client/window` -> `Electron preview webview via CDP` + +The stdio MCP server is the agent-facing integration because all target agents can speak MCP. The MCP server is intentionally thin: it does not automate Chromium directly. It calls the T3 environment server, which routes commands to the focused desktop client that owns the visible preview. + +## Explicit Non-Goals + +- No headless browser runner. +- No Playwright-managed browser. +- No arbitrary SSH dev-port forwarding in v1. +- No Cloudflare/Tailscale/relay URL rewriting in v1. +- No automation of browser/web clients that do not have Electron preview support. +- No per-action user approval prompts in v1. + +## Key Decisions + +- **Transport to agents:** stdio MCP. +- **Browser being controlled:** the actual integrated Electron preview webview. +- **Remote URL handling:** manual reachable URLs only. Agents may open `http://mac-mini.local:5173` or `http://192.168.x.y:5173`; T3 will not tunnel `127.0.0.1:5173` from remote to local yet. +- **Client routing:** route tool requests to the most recently focused desktop window/client for the agent’s thread. Return a typed error if no focused desktop preview owner is available. +- **Scope:** full control of preview browser, including opening/showing the preview panel. +- **Primary automation engine:** Chrome DevTools Protocol through Electron `webContents.debugger`, with `webContents.capturePage()` where it is simpler and more reliable. + +## New Contracts + +Add `packages/contracts/src/previewAutomation.ts`. + +### Branded IDs + +- `PreviewAutomationRequestId` +- `PreviewAutomationClientId` +- `PreviewAutomationOwnerId` + +### Tool Input/Output Schemas + +Add Effect schemas for these operations: + +- `PreviewAutomationOpenInput` + - `url?: string` + - `show?: boolean` default `true` + - `reuseExistingTab?: boolean` default `true` +- `PreviewAutomationNavigateInput` + - `url: string` + - `waitUntil?: "load" | "domcontentloaded" | "network-idle" | "none"` default `"load"` + - `timeoutMs?: number` default `15000` +- `PreviewAutomationSnapshotInput` + - `includeScreenshot?: boolean` default `true` + - `includeDomSummary?: boolean` default `true` + - `includeAccessibilityTree?: boolean` default `true` + - `screenshotMaxWidth?: number` default `1280` +- `PreviewAutomationClickInput` + - one of: + - `{ selector: string }` + - `{ x: number; y: number }` + - `button?: "left" | "middle" | "right"` default `"left"` + - `clickCount?: number` default `1` +- `PreviewAutomationTypeInput` + - `text: string` + - `selector?: string` + - `clearFirst?: boolean` default `false` +- `PreviewAutomationPressInput` + - `key: string` + - `modifiers?: readonly ("alt" | "control" | "meta" | "shift")[]` +- `PreviewAutomationScrollInput` + - `deltaX?: number` + - `deltaY?: number` + - optional target `{ selector: string }` +- `PreviewAutomationEvaluateInput` + - `expression: string` + - `awaitPromise?: boolean` default `true` + - `returnByValue?: boolean` default `true` +- `PreviewAutomationWaitForInput` + - one of: + - `{ selector: string }` + - `{ text: string }` + - `{ urlIncludes: string }` + - `timeoutMs?: number` default `10000` +- `PreviewAutomationStatusResult` + - `available: boolean` + - `visible: boolean` + - `threadId` + - `tabId: string | null` + - `url: string | null` + - `title: string | null` + - `loading: boolean` + - `ownerClientId: string | null` + +### Result Shape + +Every mutating or stateful operation returns: + +```ts +{ + ok: boolean; + status: PreviewAutomationStatusResult; + message?: string; +} +``` + +`snapshot` additionally returns: + +```ts +{ + status: PreviewAutomationStatusResult; + screenshot?: { + mimeType: "image/png"; + dataBase64: string; + width: number; + height: number; + }; + domSummary?: { + url: string; + title: string; + activeElement: string | null; + text: string; + interactiveElements: readonly { + index: number; + tag: string; + role: string | null; + name: string; + text: string; + selector: string | null; + rect: { x: number; y: number; width: number; height: number } | null; + }[]; + }; + accessibilityTree?: unknown; +} +``` + +### Error Types + +Add tagged errors: + +- `PreviewAutomationUnavailableError` +- `PreviewAutomationNoFocusedOwnerError` +- `PreviewAutomationUnsupportedClientError` +- `PreviewAutomationTabNotFoundError` +- `PreviewAutomationTimeoutError` +- `PreviewAutomationExecutionError` +- `PreviewAutomationInvalidSelectorError` + +## Server-Side Broker + +Add `apps/server/src/previewAutomation/Services/PreviewAutomationBroker.ts`. + +Responsibilities: + +- Track connected desktop automation clients. +- Track focus ownership by `(environmentId, threadId)`. +- Accept tool calls from MCP/stdin proxy. +- Route each call to the focused owner for the thread. +- Enforce timeouts and cleanup pending requests on disconnect. +- Return typed failures when no client/window is available. + +### Owner State + +Store: + +```ts +{ + clientId: PreviewAutomationClientId; + environmentId: EnvironmentId; + threadId: ThreadId; + tabId: PreviewTabId | null; + focusedAt: string; + visible: boolean; + supportsAutomation: boolean; +} +``` + +Ownership updates come from the web/desktop client when: + +- route/thread changes, +- preview panel opens/closes, +- window focus changes, +- tab id changes, +- desktop bridge availability changes. + +## WS Bridge: Server to Desktop Client + +The current preview RPCs are client-to-server plus server event streams. Add a request/response bridge using stream-style RPCs to avoid introducing bidirectional RPC infrastructure. + +### New WS Methods + +Add to `packages/contracts/src/rpc.ts`: + +- `previewAutomation.connect` + - client calls this as a long-lived stream + - input: + - `clientId` + - `capabilities` + - stream output: + - `PreviewAutomationClientRequest` +- `previewAutomation.respond` + - client sends response for a request id +- `previewAutomation.reportOwner` + - client reports focus/visibility/thread ownership +- `previewAutomation.clearOwner` + - client clears stale ownership on unmount/disconnect + +### Request Shape + +```ts +{ + requestId: string; + threadId: string; + tabId?: string; + operation: + | "open" + | "navigate" + | "snapshot" + | "click" + | "type" + | "press" + | "scroll" + | "evaluate" + | "waitFor" + | "status"; + input: unknown; + timeoutMs: number; +} +``` + +### Response Shape + +```ts +{ + requestId: string; + ok: boolean; + result?: unknown; + error?: { + _tag: string; + message: string; + detail?: unknown; + }; +} +``` + +## Desktop Preview Automation + +Extend `apps/desktop/src/preview-view-manager.ts`. + +### Add Methods + +- `getAutomationStatus(tabId)` +- `captureSnapshot(tabId, options)` +- `click(tabId, input)` +- `type(tabId, input)` +- `press(tabId, input)` +- `scroll(tabId, input)` +- `evaluate(tabId, input)` +- `waitFor(tabId, input)` + +### CDP Session Handling + +Add a small helper inside desktop preview code: + +- Attach `webContents.debugger` lazily per operation. +- Do not keep debugger attached forever unless needed. +- If already attached by DevTools or another debugger, return `PreviewAutomationExecutionError`. +- Use CDP domains: + - `Runtime.evaluate` + - `DOM.getDocument` + - `DOM.querySelector` + - `DOM.getBoxModel` + - `Accessibility.getFullAXTree` + - `Input.dispatchMouseEvent` + - `Input.dispatchKeyEvent` + - optionally `Page.captureScreenshot` if `webContents.capturePage()` is insufficient + +For screenshots, prefer `webContents.capturePage()` first because it is already used safely for annotations. + +### DOM Summary Script + +Use `Runtime.evaluate` with a bounded page script that returns: + +- `document.URL` +- `document.title` +- active element summary +- visible text truncated to a fixed limit, e.g. 20k chars +- up to 200 interactive elements: + - buttons + - links + - inputs + - selects + - textareas + - elements with roles + - elements with click handlers where detectable +- stable-ish CSS selectors generated in page context +- bounding rects + +Do not return full HTML by default. + +### Input Behavior + +- `click(selector)`: + - resolve selector in page + - scroll into view + - compute center of bounding box + - dispatch mouse move/down/up through CDP +- `click(x, y)`: + - dispatch at viewport coordinates +- `type(selector, text)`: + - focus selector if provided + - optionally clear existing value with platform shortcut + - dispatch text via CDP keyboard events or `Input.insertText` +- `press(key)`: + - map common key names: Enter, Escape, Tab, Backspace, Arrow keys + - support modifiers +- `scroll`: + - use CDP mouse wheel or page `scrollBy` fallback + +## Web Client Changes + +Update `apps/web/src/components/preview`. + +### Ownership Reporting + +Add a hook, likely `usePreviewAutomationOwner`, mounted near `PreviewView`. + +It reports owner state when: + +- the current route thread ref changes, +- preview panel visibility changes, +- browser window focus/blur changes, +- tab id changes, +- desktop preview bridge exists. + +Policy: + +- Only Electron desktop clients with `window.desktopBridge.preview` can report `supportsAutomation: true`. +- A visible preview panel gets ownership. +- The most recently focused window wins. +- On unmount or preview close, clear ownership. + +### Handling Server Requests + +Add a client-side subscriber using the new `previewAutomation.connect` stream. + +When a request arrives: + +- if operation is `open`, ensure preview panel is visible for the thread, call `api.preview.open` if needed, mount/create desktop tab, then navigate if URL is provided. +- for browser operations, call new `desktopBridge.preview.automation.*` methods. +- send `previewAutomation.respond`. + +Opening behavior: + +- If the right panel is closed, open it to preview. +- If the thread is not currently active in the UI, do not navigate the whole app in v1. Return `PreviewAutomationNoFocusedOwnerError`. +- If preview is supported but no tab exists and the agent calls `open`, create one. +- If no tab exists and the agent calls `snapshot/click/type/...`, return a clear “preview not open” error suggesting `preview_open`. + +## Desktop IPC / Preload + +Extend `packages/contracts/src/ipc.ts` `DesktopPreviewBridge`. + +Add: + +```ts +automation: { + status(tabId: string): Promise<PreviewAutomationStatusResult>; + snapshot(tabId: string, input: PreviewAutomationSnapshotInput): Promise<PreviewAutomationSnapshotResult>; + click(tabId: string, input: PreviewAutomationClickInput): Promise<PreviewAutomationActionResult>; + type(tabId: string, input: PreviewAutomationTypeInput): Promise<PreviewAutomationActionResult>; + press(tabId: string, input: PreviewAutomationPressInput): Promise<PreviewAutomationActionResult>; + scroll(tabId: string, input: PreviewAutomationScrollInput): Promise<PreviewAutomationActionResult>; + evaluate(tabId: string, input: PreviewAutomationEvaluateInput): Promise<PreviewAutomationEvaluateResult>; + waitFor(tabId: string, input: PreviewAutomationWaitForInput): Promise<PreviewAutomationActionResult>; +} +``` + +Add IPC channels in `apps/desktop/src/ipc/channels.ts` and handlers in `apps/desktop/src/ipc/methods/preview.ts`. + +## Stdio MCP Server + +Add package: `packages/preview-mcp`. + +Purpose: + +- Implements the MCP stdio protocol. +- Exposes T3 preview tools. +- Calls a private loopback endpoint or local JSON-RPC bridge on the environment server. +- Does not know Electron/CDP details. + +### Binary + +Expose bin: + +```json +{ + "bin": { + "t3-preview-mcp": "./dist/index.js" + } +} +``` + +During local dev, provider config can call the source runner via workspace package script; production package uses built JS. + +### MCP Environment Variables + +Provider sessions launch the MCP server with: + +- `T3_PREVIEW_MCP_SERVER_URL` + - environment server loopback URL +- `T3_PREVIEW_MCP_TOKEN` + - short-lived token scoped to the provider session/thread +- `T3_PREVIEW_ENVIRONMENT_ID` +- `T3_PREVIEW_THREAD_ID` + +The token must be generated by the environment server and expire when the provider session ends. + +### MCP Tools + +Expose these tools: + +- `preview_status` +- `preview_open` +- `preview_navigate` +- `preview_snapshot` +- `preview_click` +- `preview_type` +- `preview_press` +- `preview_scroll` +- `preview_evaluate` +- `preview_wait_for` + +Tool descriptions must explicitly say they operate the visible T3 preview browser for the current thread. + +### MCP Output + +- Text results include concise status and URL/title. +- `preview_snapshot` returns: + - text summary + - MCP image content for screenshot when available +- Errors are MCP tool errors with the tagged T3 error message included. + +## Private MCP Bridge Endpoint + +Add an internal server route under `apps/server`, not public app UI: + +- `POST /internal/preview-automation/tool` +- Auth: bearer `T3_PREVIEW_MCP_TOKEN` +- Body: + - `{ tool: string, input: unknown }` +- Response: + - `{ ok: true, result } | { ok: false, error }` + +This endpoint is only for the stdio MCP proxy. It calls `PreviewAutomationBroker`. + +Bind it to the same host/port as the environment server, but require the short-lived token. The endpoint must reject requests without a token or with an expired/stale thread/session. + +## Provider Integration + +### Codex + +When starting a Codex provider session, add the T3 preview MCP server to Codex configuration if the app-server config path supports per-thread MCP injection. + +Implementation path: + +1. Add `PreviewMcpSessionService` in `apps/server`. +2. On provider session start: + - create scoped MCP token for `(environmentId, threadId, providerSessionId)`. + - build MCP server config: + - name: `t3-preview` + - command: `t3-preview-mcp` + - env vars listed above +3. Thread/session start passes the MCP server config through the provider’s supported config field. +4. If Codex app-server cannot accept injected MCP servers through typed params, use its `config` override field with Codex-compatible MCP config. + +### ACP Providers + +ACP session creation already passes `mcpServers: []`. Replace that with the same `t3-preview` MCP server config for providers that support MCP. + +### Provider Fallback + +If a provider does not support MCP injection yet, do not add provider-specific native tools in v1. The shared MCP server remains available for later provider wiring. + +## Remote Environment Behavior + +### Mac mini dev server viewed from MacBook + +Expected v1 workflow: + +1. Agent runs dev server on Mac mini. +2. Agent or user opens a reachable URL, e.g.: + - `http://mac-mini.local:5173` + - `http://192.168.1.42:5173` +3. T3 desktop on MacBook opens that URL in the local Electron preview. +4. Agent uses MCP tools. +5. T3 routes tool calls from Mac mini environment server to the focused MacBook preview client. + +### `localhost` Caveat + +In v1, if an agent opens `http://localhost:5173` from a remote environment, the MacBook preview will interpret that as MacBook localhost. The tool result should include a warning when: + +- environment is not the primary/local environment, and +- URL hostname is `localhost`, `127.0.0.1`, or `::1`. + +Warning text: + +`This URL is loopback on the preview client, not necessarily the remote environment. Use a client-reachable host/IP for remote dev servers.` + +### Future-Proofing + +Do not bake manual URL assumptions into the automation layer. Represent opened URLs as: + +```ts +{ + displayUrl: string; + requestedUrl: string; + resolutionKind: "direct"; + environmentId: string; +} +``` + +Later `resolutionKind` can add: + +- `ssh-forward` +- `relay` +- `tailscale` +- `cloudflare-tunnel` + +## Security and Safety + +- MCP tokens are scoped to one provider session/thread. +- MCP tokens expire on provider session stop and server restart. +- Browser automation only routes to focused desktop owner for that same thread. +- Do not allow MCP input to specify arbitrary `environmentId` or `threadId`; infer both from token/session. +- `preview_evaluate` is powerful. Keep it enabled in v1 because the user requested full control, but: + - limit result serialization size, e.g. 64 KB + - timeout evaluation + - return by value by default + - document that it executes in the preview page context +- Screenshot output should be bounded: + - max dimensions + - max base64 size + - return error if too large after scaling attempts +- Clear pending broker requests when desktop disconnects. +- CDP operations must timeout and detach debugger in `finally`. + +## Failure Modes + +Return typed errors for: + +- no desktop client connected +- desktop client connected but preview unsupported +- no focused owner for thread +- preview panel not open and operation is not `preview_open` +- webview not initialized yet +- navigation timeout +- selector not found +- CDP debugger unavailable +- page execution error +- screenshot too large +- stale request id or response after timeout + +## Tests + +### Contracts + +Add tests for: + +- schema decoding for every preview automation input/result +- error schema decoding +- invalid selector/click union inputs rejected +- snapshot options defaulting + +### Desktop Unit Tests + +Add tests around `PreviewViewManager` helpers: + +- owner-independent status when tab exists/does not exist +- selector summary script clamps output +- selector generation handles ids/classes/nth-child fallback +- error mapping for missing webContents +- screenshot result shape + +Where Electron/CDP is hard to unit test, isolate pure helpers and cover IPC handler validation. + +### Server Broker Tests + +Add tests for: + +- registering clients +- ownership updates +- focused owner wins +- request routed to focused owner +- request timeout +- client disconnect fails pending requests +- response with unknown request id ignored/rejected +- token scoped to thread/session +- remote loopback URL warning generated + +### Web Client Tests + +Add tests for: + +- ownership report sent only in Electron preview-supported runtime +- opening preview panel on `preview_open` +- no route switch for background thread +- clear ownership on unmount +- response sent for successful and failed desktop bridge calls + +### MCP Tests + +Add tests for: + +- tool list includes all expected preview tools +- each tool maps to internal bridge request +- token missing/invalid returns MCP error +- snapshot maps screenshot to MCP image content +- tool errors preserve tagged T3 error message + +### Integration Tests + +Add a focused integration test using mocked desktop client: + +1. Start server broker. +2. Register fake desktop automation client. +3. Mark it focused for thread. +4. Invoke MCP `preview_open`. +5. Assert fake client received open request. +6. Respond success. +7. Assert MCP response is successful. + +Add a second integration test: + +1. No focused owner. +2. Invoke `preview_snapshot`. +3. Assert `PreviewAutomationNoFocusedOwnerError`. + +## Validation Commands + +Before completion: + +- `vp check` +- `vp run typecheck` +- `vp test` + +No `vp run lint:mobile` required unless mobile code is changed. + +## Implementation Order + +1. Add contracts for preview automation schemas and WS methods. +2. Add `PreviewAutomationBroker` server service. +3. Add WS stream bridge for desktop clients. +4. Add desktop IPC and `PreviewViewManager` CDP automation methods. +5. Add web ownership reporting and request handling. +6. Add private `/internal/preview-automation/tool` endpoint with scoped token auth. +7. Add `packages/preview-mcp` stdio MCP server. +8. Wire provider sessions to launch/register `t3-preview` MCP server where provider protocols support MCP config. +9. Add remote loopback URL warning. +10. Add tests. +11. Run validation commands. + +## Assumptions + +- v1 only supports Electron desktop preview clients. +- The active/focused thread preview is the right target for agent control. +- Manual client-reachable URLs are acceptable for remote dev servers. +- Full browser control includes `evaluate`. +- Stdio MCP is the agent-facing transport. +- A private server bridge behind the stdio MCP server is acceptable and necessary because the browser lives on the desktop client, not beside the remote agent. diff --git a/apps/desktop/scripts/dev-electron.mjs b/apps/desktop/scripts/dev-electron.mjs index 2a2e52449be..6c8b94188a2 100644 --- a/apps/desktop/scripts/dev-electron.mjs +++ b/apps/desktop/scripts/dev-electron.mjs @@ -2,7 +2,11 @@ import { spawn, spawnSync } from "node:child_process"; import { watch } from "node:fs"; import { join } from "node:path"; -import { desktopDir, resolveDevProtocolClient, resolveElectronPath } from "./electron-launcher.mjs"; +import { + desktopDir, + resolveDevProtocolClient, + resolveElectronLaunchCommand, +} from "./electron-launcher.mjs"; import { waitForResources } from "./wait-for-resources.mjs"; const devServerUrl = process.env.VITE_DEV_SERVER_URL?.trim(); @@ -79,7 +83,8 @@ function startApp() { const launchArgs = devProtocolClient ? electronArgs : [...electronArgs, `--t3code-dev-root=${desktopDir}`, "dist-electron/main.cjs"]; - const app = spawn(resolveElectronPath(), launchArgs, { + const electronCommand = resolveElectronLaunchCommand(launchArgs); + const app = spawn(electronCommand.electronPath, electronCommand.args, { cwd: desktopDir, env: childEnv, stdio: "inherit", diff --git a/apps/desktop/scripts/electron-launcher.mjs b/apps/desktop/scripts/electron-launcher.mjs index 8f20001bbb0..1fc956b39dc 100644 --- a/apps/desktop/scripts/electron-launcher.mjs +++ b/apps/desktop/scripts/electron-launcher.mjs @@ -307,6 +307,31 @@ function buildMacLauncher(electronBinaryPath) { return targetBinaryPath; } +function isLinuxSetuidSandboxConfigured(electronBinaryPath) { + if (process.platform !== "linux") { + return true; + } + + const sandboxPath = join(dirname(electronBinaryPath), "chrome-sandbox"); + try { + const sandboxStat = statSync(sandboxPath); + return sandboxStat.uid === 0 && (sandboxStat.mode & 0o4777) === 0o4755; + } catch { + return false; + } +} + +function resolveLinuxSandboxArgs(electronBinaryPath) { + if (isLinuxSetuidSandboxConfigured(electronBinaryPath)) { + return []; + } + + console.warn( + "[desktop-launcher] Electron chrome-sandbox is not root-owned with mode 4755; launching local Electron with --no-sandbox.", + ); + return ["--no-sandbox"]; +} + export function resolveElectronPath() { ensureElectronRuntime(); @@ -320,6 +345,14 @@ export function resolveElectronPath() { return buildMacLauncher(electronBinaryPath); } +export function resolveElectronLaunchCommand(args = []) { + const electronPath = resolveElectronPath(); + return { + electronPath, + args: [...resolveLinuxSandboxArgs(electronPath), ...args], + }; +} + export function resolveDevProtocolClient() { if (process.platform !== "darwin" || !isDevelopment) { return null; diff --git a/apps/desktop/scripts/smoke-test.mjs b/apps/desktop/scripts/smoke-test.mjs index fdbe69b7780..48a2e168a2b 100644 --- a/apps/desktop/scripts/smoke-test.mjs +++ b/apps/desktop/scripts/smoke-test.mjs @@ -1,15 +1,16 @@ import { spawn } from "node:child_process"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { resolveElectronLaunchCommand } from "./electron-launcher.mjs"; const __dirname = dirname(fileURLToPath(import.meta.url)); const desktopDir = resolve(__dirname, ".."); -const electronBin = resolve(desktopDir, "node_modules/.bin/electron"); const mainJs = resolve(desktopDir, "dist-electron/main.cjs"); console.log("\nLaunching Electron smoke test..."); -const child = spawn(electronBin, [mainJs], { +const electronCommand = resolveElectronLaunchCommand([mainJs]); +const child = spawn(electronCommand.electronPath, electronCommand.args, { stdio: ["pipe", "pipe", "pipe"], env: { ...process.env, diff --git a/apps/desktop/scripts/start-electron.mjs b/apps/desktop/scripts/start-electron.mjs index 375dbfe575f..d959b4ab1f0 100644 --- a/apps/desktop/scripts/start-electron.mjs +++ b/apps/desktop/scripts/start-electron.mjs @@ -1,11 +1,12 @@ import { spawn } from "node:child_process"; -import { desktopDir, resolveElectronPath } from "./electron-launcher.mjs"; +import { desktopDir, resolveElectronLaunchCommand } from "./electron-launcher.mjs"; const childEnv = { ...process.env }; delete childEnv.ELECTRON_RUN_AS_NODE; -const child = spawn(resolveElectronPath(), ["dist-electron/main.cjs"], { +const electronCommand = resolveElectronLaunchCommand(["dist-electron/main.cjs"]); +const child = spawn(electronCommand.electronPath, electronCommand.args, { stdio: "inherit", cwd: desktopDir, env: childEnv, diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 9c11859f236..11832664d88 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -56,4 +56,12 @@ export const PREVIEW_CLEAR_CACHE_CHANNEL = "desktop:preview-clear-cache"; export const PREVIEW_GET_CONFIG_CHANNEL = "desktop:preview-get-config"; export const PREVIEW_PICK_ELEMENT_CHANNEL = "desktop:preview-pick-element"; export const PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL = "desktop:preview-cancel-pick-element"; +export const PREVIEW_AUTOMATION_STATUS_CHANNEL = "desktop:preview-automation-status"; +export const PREVIEW_AUTOMATION_SNAPSHOT_CHANNEL = "desktop:preview-automation-snapshot"; +export const PREVIEW_AUTOMATION_CLICK_CHANNEL = "desktop:preview-automation-click"; +export const PREVIEW_AUTOMATION_TYPE_CHANNEL = "desktop:preview-automation-type"; +export const PREVIEW_AUTOMATION_PRESS_CHANNEL = "desktop:preview-automation-press"; +export const PREVIEW_AUTOMATION_SCROLL_CHANNEL = "desktop:preview-automation-scroll"; +export const PREVIEW_AUTOMATION_EVALUATE_CHANNEL = "desktop:preview-automation-evaluate"; +export const PREVIEW_AUTOMATION_WAIT_FOR_CHANNEL = "desktop:preview-automation-wait-for"; export const PREVIEW_STATE_CHANGE_CHANNEL = "desktop:preview-state-change"; diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index dca8d641108..904cef30b41 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -28,6 +28,13 @@ const tabIdFrom = (raw: unknown): string => { return tabId; }; +const inputFrom = (raw: unknown): unknown => { + if (typeof raw !== "object" || raw === null || !("input" in raw)) { + throw new Error("preview automation input is required"); + } + return raw.input; +}; + class PreviewIpcError extends Data.TaggedError("PreviewIpcError")<{ readonly cause: unknown; }> {} @@ -102,4 +109,46 @@ export const previewMethods = [ method(IpcChannels.PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL, (raw) => previewViewManager.cancelPickElement(tabIdFrom(raw)), ), + method(IpcChannels.PREVIEW_AUTOMATION_STATUS_CHANNEL, (raw) => + previewViewManager.automationStatus(tabIdFrom(raw)), + ), + method(IpcChannels.PREVIEW_AUTOMATION_SNAPSHOT_CHANNEL, (raw) => + previewViewManager.automationSnapshot(tabIdFrom(raw)), + ), + method(IpcChannels.PREVIEW_AUTOMATION_CLICK_CHANNEL, (raw) => + previewViewManager.automationClick( + tabIdFrom(raw), + inputFrom(raw) as Parameters<typeof previewViewManager.automationClick>[1], + ), + ), + method(IpcChannels.PREVIEW_AUTOMATION_TYPE_CHANNEL, (raw) => + previewViewManager.automationType( + tabIdFrom(raw), + inputFrom(raw) as Parameters<typeof previewViewManager.automationType>[1], + ), + ), + method(IpcChannels.PREVIEW_AUTOMATION_PRESS_CHANNEL, (raw) => + previewViewManager.automationPress( + tabIdFrom(raw), + inputFrom(raw) as Parameters<typeof previewViewManager.automationPress>[1], + ), + ), + method(IpcChannels.PREVIEW_AUTOMATION_SCROLL_CHANNEL, (raw) => + previewViewManager.automationScroll( + tabIdFrom(raw), + inputFrom(raw) as Parameters<typeof previewViewManager.automationScroll>[1], + ), + ), + method(IpcChannels.PREVIEW_AUTOMATION_EVALUATE_CHANNEL, (raw) => + previewViewManager.automationEvaluate( + tabIdFrom(raw), + inputFrom(raw) as Parameters<typeof previewViewManager.automationEvaluate>[1], + ), + ), + method(IpcChannels.PREVIEW_AUTOMATION_WAIT_FOR_CHANNEL, (raw) => + previewViewManager.automationWaitFor( + tabIdFrom(raw), + inputFrom(raw) as Parameters<typeof previewViewManager.automationWaitFor>[1], + ), + ), ] as const; diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index a66a1680401..8f890c27e78 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -163,6 +163,24 @@ contextBridge.exposeInMainWorld("desktopBridge", { pickElement: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL, { tabId }), cancelPickElement: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL, { tabId }), + automation: { + status: (tabId) => + ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_STATUS_CHANNEL, { tabId }), + snapshot: (tabId) => + ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_SNAPSHOT_CHANNEL, { tabId }), + click: (tabId, input) => + ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_CLICK_CHANNEL, { tabId, input }), + type: (tabId, input) => + ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_TYPE_CHANNEL, { tabId, input }), + press: (tabId, input) => + ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_PRESS_CHANNEL, { tabId, input }), + scroll: (tabId, input) => + ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_SCROLL_CHANNEL, { tabId, input }), + evaluate: (tabId, input) => + ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_EVALUATE_CHANNEL, { tabId, input }), + waitFor: (tabId, input) => + ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_WAIT_FOR_CHANNEL, { tabId, input }), + }, onStateChange: (listener) => { const wrappedListener = ( _event: Electron.IpcRendererEvent, diff --git a/apps/desktop/src/preview-view-manager.test.ts b/apps/desktop/src/preview-view-manager.test.ts new file mode 100644 index 00000000000..54d33a85699 --- /dev/null +++ b/apps/desktop/src/preview-view-manager.test.ts @@ -0,0 +1,44 @@ +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const fromId = vi.fn(() => null); + +vi.mock("electron", () => ({ + session: { + fromPartition: vi.fn(), + }, + webContents: { + fromId, + }, +})); + +describe("PreviewViewManager automation status", () => { + beforeEach(() => { + fromId.mockClear(); + }); + + it("reports an unregistered webview as temporarily unavailable", async () => { + const { PreviewViewManager } = await import("./preview-view-manager.ts"); + const manager = new PreviewViewManager(); + + expect(manager.automationStatus("tab_1")).toEqual({ + available: false, + visible: true, + tabId: "tab_1", + url: null, + title: null, + loading: false, + }); + + manager.createTab("tab_1"); + + expect(manager.automationStatus("tab_1")).toEqual({ + available: false, + visible: true, + tabId: "tab_1", + url: null, + title: null, + loading: false, + }); + expect(fromId).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/preview-view-manager.ts b/apps/desktop/src/preview-view-manager.ts index 1f378e90cea..ce8f9964529 100644 --- a/apps/desktop/src/preview-view-manager.ts +++ b/apps/desktop/src/preview-view-manager.ts @@ -6,9 +6,21 @@ * elements live in the renderer; we only attach listeners and forward state * here). Single layer-scoped browser session partition. */ -import type { PreviewAnnotationPayload, PreviewAnnotationRect } from "@t3tools/contracts"; +import type { + PreviewAnnotationPayload, + PreviewAnnotationRect, + PreviewAutomationClickInput, + PreviewAutomationEvaluateInput, + PreviewAutomationPressInput, + PreviewAutomationScrollInput, + PreviewAutomationSnapshot, + PreviewAutomationStatus, + PreviewAutomationTypeInput, + PreviewAutomationWaitForInput, +} from "@t3tools/contracts"; import { normalizePreviewUrl } from "@t3tools/shared/preview"; import { type BrowserWindow, type Session, session, webContents } from "electron"; +import { setTimeout as sleep } from "node:timers/promises"; import { isPreviewAnnotationPayload } from "./picked-element-payload.ts"; @@ -53,6 +65,36 @@ const ZOOM_LEVELS: ReadonlyArray<number> = [ const DEFAULT_ZOOM_FACTOR = 1.0; const ZOOM_EPSILON = 0.001; +const MAX_EVALUATION_BYTES = 64_000; +const MAX_VISIBLE_TEXT_LENGTH = 20_000; +const MAX_INTERACTIVE_ELEMENTS = 200; +const MAX_SCREENSHOT_WIDTH = 1280; + +interface CdpEvaluationResult { + readonly result?: { + readonly value?: unknown; + readonly description?: string; + }; + readonly exceptionDetails?: { + readonly text?: string; + readonly exception?: { readonly description?: string }; + }; +} + +const automationError = ( + tag: + | "PreviewAutomationExecutionError" + | "PreviewAutomationInvalidSelectorError" + | "PreviewAutomationResultTooLargeError" + | "PreviewAutomationTimeoutError", + message: string, + detail?: unknown, +): Error & { detail?: unknown } => { + const error = new Error(message) as Error & { detail?: unknown }; + error.name = tag; + if (detail !== undefined) error.detail = detail; + return error; +}; const normalizeCaptureRect = (value: unknown): PreviewAnnotationRect | null => { if (typeof value !== "object" || value === null) return null; @@ -455,6 +497,391 @@ export class PreviewViewManager { this.applyZoom(tabId, () => DEFAULT_ZOOM_FACTOR); } + automationStatus(tabId: string): PreviewAutomationStatus { + const tab = this.tabs.get(tabId); + if (!tab || tab.webContentsId == null) { + const navStatus = tab?.navStatus; + return { + available: false, + visible: true, + tabId, + url: !navStatus || navStatus.kind === "Idle" ? null : navStatus.url, + title: !navStatus || navStatus.kind === "Idle" ? null : navStatus.title, + loading: navStatus?.kind === "Loading", + }; + } + const wc = webContents.fromId(tab.webContentsId); + if (!wc || wc.isDestroyed()) { + return { + available: false, + visible: true, + tabId, + url: null, + title: null, + loading: false, + }; + } + return { + available: true, + visible: true, + tabId, + url: wc.getURL() || null, + title: wc.getTitle() || null, + loading: wc.isLoading(), + }; + } + + async automationSnapshot(tabId: string): Promise<PreviewAutomationSnapshot> { + const wc = this.requireWebContents(tabId); + return this.withDebugger(wc, async (send) => { + await Promise.all([send("Runtime.enable"), send("Accessibility.enable")]); + const page = await this.evaluateWithDebugger<{ + url: string; + title: string; + loading: boolean; + visibleText: string; + interactiveElements: PreviewAutomationSnapshot["interactiveElements"]; + }>( + send, + `(() => { + const selectorFor = (element) => { + if (element.id) return "#" + CSS.escape(element.id); + for (const attribute of ["data-testid", "name"]) { + const value = element.getAttribute(attribute); + if (value) return element.tagName.toLowerCase() + "[" + attribute + "=" + JSON.stringify(value) + "]"; + } + const parts = []; + let current = element; + while (current && current.nodeType === Node.ELEMENT_NODE && parts.length < 8) { + let part = current.tagName.toLowerCase(); + const parent = current.parentElement; + if (parent) { + const siblings = Array.from(parent.children).filter((child) => child.tagName === current.tagName); + if (siblings.length > 1) part += ":nth-of-type(" + (siblings.indexOf(current) + 1) + ")"; + } + parts.unshift(part); + current = parent; + } + return parts.join(" > "); + }; + const visible = (element) => { + const style = getComputedStyle(element); + const rect = element.getBoundingClientRect(); + return style.visibility !== "hidden" && style.display !== "none" && rect.width > 0 && rect.height > 0; + }; + const elements = Array.from(document.querySelectorAll( + "a[href],button,input,textarea,select,[role],[tabindex]" + )).filter(visible).slice(0, ${MAX_INTERACTIVE_ELEMENTS}).map((element) => { + const rect = element.getBoundingClientRect(); + return { + tag: element.tagName.toLowerCase(), + role: element.getAttribute("role"), + name: element.getAttribute("aria-label") || element.innerText || element.getAttribute("name") || "", + selector: selectorFor(element), + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height + }; + }); + return { + url: location.href, + title: document.title, + loading: document.readyState !== "complete", + visibleText: (document.body?.innerText || "").slice(0, ${MAX_VISIBLE_TEXT_LENGTH}), + interactiveElements: elements + }; + })()`, + true, + ); + const accessibility = await send("Accessibility.getFullAXTree"); + let image = await wc.capturePage(); + let size = image.getSize(); + if (size.width > MAX_SCREENSHOT_WIDTH) { + image = image.resize({ width: MAX_SCREENSHOT_WIDTH }); + size = image.getSize(); + } + return { + ...page, + accessibilityTree: accessibility, + screenshot: { + mimeType: "image/png", + data: image.toPNG().toString("base64"), + width: size.width, + height: size.height, + }, + }; + }); + } + + async automationClick(tabId: string, input: PreviewAutomationClickInput): Promise<void> { + const wc = this.requireWebContents(tabId); + await this.withDebugger(wc, async (send) => { + await Promise.all([ + send("Runtime.enable"), + send("Input.setIgnoreInputEvents", { ignore: false }), + ]); + let x: number; + let y: number; + if ("selector" in input) { + const point = await this.evaluateWithDebugger< + { x: number; y: number } | { invalidSelector: true; message: string } | { notFound: true } + >( + send, + `(() => { + try { + const element = document.querySelector(${JSON.stringify(input.selector)}); + if (!element) return { notFound: true }; + element.scrollIntoView({ block: "center", inline: "center" }); + const rect = element.getBoundingClientRect(); + return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }; + } catch (error) { + return { invalidSelector: true, message: String(error) }; + } + })()`, + true, + ); + if ("invalidSelector" in point) { + throw automationError("PreviewAutomationInvalidSelectorError", point.message, { + selector: input.selector, + }); + } + if ("notFound" in point) { + throw automationError( + "PreviewAutomationExecutionError", + `No element matches selector ${input.selector}.`, + ); + } + x = point.x; + y = point.y; + } else { + x = input.x; + y = input.y; + } + const viewport = await this.evaluateWithDebugger<{ width: number; height: number }>( + send, + "({ width: window.innerWidth, height: window.innerHeight })", + true, + ); + if (x < 0 || y < 0 || x > viewport.width || y > viewport.height) { + throw automationError( + "PreviewAutomationExecutionError", + `Click coordinates (${x}, ${y}) are outside the preview viewport.`, + ); + } + await send("Input.dispatchMouseEvent", { + type: "mousePressed", + x, + y, + button: "left", + clickCount: 1, + }); + await send("Input.dispatchMouseEvent", { + type: "mouseReleased", + x, + y, + button: "left", + clickCount: 1, + }); + }); + } + + async automationType(tabId: string, input: PreviewAutomationTypeInput): Promise<void> { + const wc = this.requireWebContents(tabId); + await this.withDebugger(wc, async (send) => { + await send("Runtime.enable"); + const focusResult = await this.evaluateWithDebugger< + { ok: true } | { invalidSelector: true; message: string } | { notFound: true } + >( + send, + `(() => { + try { + const element = ${ + input.selector + ? `document.querySelector(${JSON.stringify(input.selector)})` + : "document.activeElement" + }; + if (!element) return { notFound: true }; + element.focus(); + if (${input.clear ?? false}) { + if ("value" in element) element.value = ""; + else if (element.isContentEditable) element.textContent = ""; + element.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "deleteContentBackward" })); + } + return { ok: true }; + } catch (error) { + return { invalidSelector: true, message: String(error) }; + } + })()`, + true, + ); + if ("invalidSelector" in focusResult) { + throw automationError("PreviewAutomationInvalidSelectorError", focusResult.message, { + selector: input.selector ?? "", + }); + } + if ("notFound" in focusResult) { + throw automationError( + "PreviewAutomationExecutionError", + input.selector + ? `No element matches selector ${input.selector}.` + : "No element is focused in the preview.", + ); + } + await send("Input.insertText", { text: input.text }); + await this.evaluateWithDebugger( + send, + `(() => { + const element = document.activeElement; + element?.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: ${JSON.stringify(input.text)} })); + element?.dispatchEvent(new Event("change", { bubbles: true })); + })()`, + false, + ); + }); + } + + async automationPress(tabId: string, input: PreviewAutomationPressInput): Promise<void> { + const wc = this.requireWebContents(tabId); + await this.withDebugger(wc, async (send) => { + const modifiers = (input.modifiers ?? []).reduce((value, modifier) => { + switch (modifier) { + case "Alt": + return value | 1; + case "Control": + return value | 2; + case "Meta": + return value | 4; + case "Shift": + return value | 8; + } + }, 0); + const key = input.key; + const text = key.length === 1 ? key : undefined; + const params = { + key, + code: key.length === 1 ? `Key${key.toUpperCase()}` : key, + modifiers, + ...(text ? { text, unmodifiedText: text } : {}), + }; + await send("Input.dispatchKeyEvent", { type: "keyDown", ...params }); + await send("Input.dispatchKeyEvent", { type: "keyUp", ...params }); + }); + } + + async automationScroll(tabId: string, input: PreviewAutomationScrollInput): Promise<void> { + const wc = this.requireWebContents(tabId); + await this.withDebugger(wc, async (send) => { + await send("Runtime.enable"); + const result = await this.evaluateWithDebugger< + { ok: true } | { invalidSelector: true; message: string } | { notFound: true } + >( + send, + `(() => { + try { + const target = ${ + input.selector + ? `document.querySelector(${JSON.stringify(input.selector)})` + : "window" + }; + if (!target) return { notFound: true }; + target.scrollBy({ left: ${input.deltaX ?? 0}, top: ${input.deltaY ?? 0}, behavior: "instant" }); + return { ok: true }; + } catch (error) { + return { invalidSelector: true, message: String(error) }; + } + })()`, + true, + ); + if ("invalidSelector" in result) { + throw automationError("PreviewAutomationInvalidSelectorError", result.message, { + selector: input.selector ?? "", + }); + } + if ("notFound" in result) { + throw automationError( + "PreviewAutomationExecutionError", + `No element matches selector ${input.selector}.`, + ); + } + }); + } + + async automationEvaluate(tabId: string, input: PreviewAutomationEvaluateInput): Promise<unknown> { + const wc = this.requireWebContents(tabId); + return this.withDebugger(wc, async (send) => { + await send("Runtime.enable"); + const value = await this.evaluateWithDebugger( + send, + input.expression, + input.returnByValue ?? true, + input.awaitPromise ?? true, + ); + const serialized = JSON.stringify(value); + if ( + serialized !== undefined && + Buffer.byteLength(serialized, "utf8") > MAX_EVALUATION_BYTES + ) { + throw automationError( + "PreviewAutomationResultTooLargeError", + `Evaluation result exceeds ${MAX_EVALUATION_BYTES} bytes.`, + { maximumBytes: MAX_EVALUATION_BYTES }, + ); + } + return value; + }); + } + + async automationWaitFor(tabId: string, input: PreviewAutomationWaitForInput): Promise<void> { + const wc = this.requireWebContents(tabId); + const timeoutMs = input.timeoutMs ?? 15_000; + await this.withDebugger(wc, async (send) => { + await send("Runtime.enable"); + const deadline = Date.now() + timeoutMs; + while (Date.now() <= deadline) { + const result = await this.evaluateWithDebugger< + { matched: boolean } | { invalidSelector: true; message: string } + >( + send, + `(() => { + try { + const selectorMatched = ${ + input.selector + ? `document.querySelector(${JSON.stringify(input.selector)}) !== null` + : "true" + }; + const textMatched = ${ + input.text + ? `(document.body?.innerText || "").includes(${JSON.stringify(input.text)})` + : "true" + }; + const urlMatched = ${ + input.urlIncludes + ? `location.href.includes(${JSON.stringify(input.urlIncludes)})` + : "true" + }; + return { matched: selectorMatched && textMatched && urlMatched }; + } catch (error) { + return { invalidSelector: true, message: String(error) }; + } + })()`, + true, + ); + if ("invalidSelector" in result) { + throw automationError("PreviewAutomationInvalidSelectorError", result.message, { + selector: input.selector ?? "", + }); + } + if (result.matched) return; + await sleep(100); + } + throw automationError( + "PreviewAutomationTimeoutError", + `Preview condition did not match within ${timeoutMs}ms.`, + ); + }); + } + private applyZoom(tabId: string, transform: (current: number) => number): void { const tab = this.tabs.get(tabId); if (!tab) return; @@ -469,6 +896,62 @@ export class PreviewViewManager { this.update(tabId, { zoomFactor: next }); } + private async withDebugger<A>( + wc: Electron.WebContents, + use: ( + send: (method: string, commandParams?: Record<string, unknown>) => Promise<unknown>, + ) => Promise<A>, + ): Promise<A> { + if (wc.debugger.isAttached()) { + throw automationError( + "PreviewAutomationExecutionError", + "Preview automation is unavailable while another debugger is attached.", + ); + } + wc.debugger.attach("1.3"); + try { + return await use((method, commandParams) => wc.debugger.sendCommand(method, commandParams)); + } catch (cause) { + if (cause instanceof Error && cause.name.startsWith("PreviewAutomation")) throw cause; + throw automationError( + "PreviewAutomationExecutionError", + cause instanceof Error ? cause.message : String(cause), + cause, + ); + } finally { + if (wc.debugger.isAttached()) { + try { + wc.debugger.detach(); + } catch { + // The target can disappear while an operation is completing. + } + } + } + } + + private async evaluateWithDebugger<A = unknown>( + send: (method: string, commandParams?: Record<string, unknown>) => Promise<unknown>, + expression: string, + returnByValue: boolean, + awaitPromise = true, + ): Promise<A> { + const response = (await send("Runtime.evaluate", { + expression, + awaitPromise, + returnByValue, + userGesture: true, + })) as CdpEvaluationResult; + if (response.exceptionDetails) { + throw automationError( + "PreviewAutomationExecutionError", + response.exceptionDetails.exception?.description ?? + response.exceptionDetails.text ?? + "JavaScript evaluation failed.", + ); + } + return response.result?.value as A; + } + onStateChange(listener: Listener): () => void { this.listeners.add(listener); return () => { diff --git a/apps/server/src/mcp/Layers/McpHttpServer.test.ts b/apps/server/src/mcp/Layers/McpHttpServer.test.ts new file mode 100644 index 00000000000..8d01277ce3e --- /dev/null +++ b/apps/server/src/mcp/Layers/McpHttpServer.test.ts @@ -0,0 +1,133 @@ +import { expect, it } from "@effect/vitest"; +import { EnvironmentId, PreviewTabId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; +import { McpSchema, McpServer } from "effect/unstable/ai"; +import { HttpServerResponse } from "effect/unstable/http"; + +import { McpInvocationContext } from "../Services/McpInvocationContext.ts"; +import { normalizeMcpHttpResponse, PreviewToolkitRegistrationLive } from "./McpHttpServer.ts"; +import { previewAutomationBroker } from "./PreviewAutomationBroker.ts"; + +const environmentId = EnvironmentId.make("environment-mcp-test"); +const threadId = ThreadId.make("thread-mcp-test"); +const tabId = PreviewTabId.make("tab-mcp-test"); +const invocation = { + environmentId, + threadId, + providerSessionId: "provider-session-mcp-test", + providerInstanceId: ProviderInstanceId.make("codex"), + capabilities: new Set(["preview"] as const), + issuedAt: 1, + expiresAt: Number.MAX_SAFE_INTEGER, +}; +const client = McpSchema.McpServerClient.of({ + clientId: 1, + initializePayload: { + protocolVersion: "2025-03-26", + capabilities: {}, + clientInfo: { name: "mcp-test", version: "1.0.0" }, + }, + getClient: Effect.die("unused"), +}); +const TestLayer = PreviewToolkitRegistrationLive.pipe( + Layer.provideMerge(McpServer.McpServer.layer), +); + +it("normalizes empty successful notification responses to accepted", () => { + const notificationResponse = normalizeMcpHttpResponse( + HttpServerResponse.text("", { status: 200, contentType: "application/json" }), + ); + expect(notificationResponse.status).toBe(202); + + const resultResponse = normalizeMcpHttpResponse( + HttpServerResponse.jsonUnsafe({ jsonrpc: "2.0", id: 1, result: {} }), + ); + expect(resultResponse.status).toBe(200); +}); + +it.effect("registers annotated tools and preserves authenticated request context", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* McpServer.McpServer; + const requests = yield* previewAutomationBroker.connect("mcp-test-client"); + yield* Stream.runForEach(requests, (request) => + previewAutomationBroker.respond({ + requestId: request.requestId, + ok: true, + result: + request.operation === "snapshot" + ? { + url: "http://example.test/", + title: "Example", + loading: false, + visibleText: "Example", + interactiveElements: [], + accessibilityTree: {}, + screenshot: { + mimeType: "image/png", + data: Buffer.from("png").toString("base64"), + width: 10, + height: 5, + }, + } + : { + available: true, + visible: true, + tabId, + url: "http://example.test/", + title: "Example", + loading: false, + }, + }), + ).pipe(Effect.forkScoped); + yield* Effect.yieldNow; + yield* previewAutomationBroker.reportOwner({ + clientId: "mcp-test-client", + environmentId, + threadId, + tabId, + visible: true, + supportsAutomation: true, + focusedAt: "2026-06-11T00:00:00.000Z", + }); + + const statusTool = server.tools.find(({ tool }) => tool.name === "preview_status"); + expect(statusTool?.tool.annotations?.readOnlyHint).toBe(true); + expect(statusTool?.tool.annotations?.idempotentHint).toBe(true); + + const status = yield* server + .callTool({ name: "preview_status", arguments: {} }) + .pipe( + Effect.provideService(McpInvocationContext, invocation), + Effect.provideService(McpSchema.McpServerClient, client), + ); + expect(status.isError).toBe(false); + expect(status.structuredContent).toMatchObject({ + available: true, + tabId, + }); + + const malformed = yield* server + .callTool({ name: "preview_click", arguments: { selector: "" } }) + .pipe( + Effect.provideService(McpInvocationContext, invocation), + Effect.provideService(McpSchema.McpServerClient, client), + ); + expect(malformed.isError).toBe(true); + + const snapshot = yield* server + .callTool({ name: "preview_snapshot", arguments: {} }) + .pipe( + Effect.provideService(McpInvocationContext, invocation), + Effect.provideService(McpSchema.McpServerClient, client), + ); + expect(snapshot.isError).toBe(false); + expect(snapshot.content.some((content) => content.type === "image")).toBe(true); + expect(snapshot.structuredContent).toMatchObject({ + screenshot: { mimeType: "image/png", width: 10, height: 5 }, + }); + }), + ).pipe(Effect.provide(TestLayer)), +); diff --git a/apps/server/src/mcp/Layers/McpHttpServer.ts b/apps/server/src/mcp/Layers/McpHttpServer.ts new file mode 100644 index 00000000000..645a8fdc3c8 --- /dev/null +++ b/apps/server/src/mcp/Layers/McpHttpServer.ts @@ -0,0 +1,162 @@ +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import { McpSchema, McpServer, Tool } from "effect/unstable/ai"; +import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; + +import packageJson from "../../../package.json" with { type: "json" }; +import { McpInvocationContext } from "../Services/McpInvocationContext.ts"; +import { McpSessionRegistry } from "../Services/McpSessionRegistry.ts"; +import { PreviewToolkitHandlersLive } from "../toolkits/preview/handlers.ts"; +import { PreviewToolkit } from "../toolkits/preview/tools.ts"; + +const unauthorized = HttpServerResponse.jsonUnsafe( + { + error: "invalid_mcp_credential", + message: "A valid provider-scoped MCP bearer credential is required.", + }, + { + status: 401, + headers: { + "cache-control": "no-store", + "www-authenticate": "Bearer", + }, + }, +); + +export const normalizeMcpHttpResponse = ( + response: HttpServerResponse.HttpServerResponse, +): HttpServerResponse.HttpServerResponse => { + const bodyIsEmpty = + response.body._tag === "Empty" || + (response.body._tag === "Uint8Array" && response.body.contentLength === 0) || + (response.body._tag === "Raw" && response.body.contentLength === 0); + return response.status === 200 && bodyIsEmpty + ? HttpServerResponse.setStatus(response, 202) + : response; +}; + +const McpAuthMiddlewareLive = HttpRouter.middleware<{ + provides: McpInvocationContext; +}>()( + Effect.gen(function* () { + const registry = yield* McpSessionRegistry; + return (httpEffect) => + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const authorization = request.headers.authorization; + const token = + authorization?.startsWith("Bearer ") === true + ? authorization.slice("Bearer ".length).trim() + : ""; + const invocation = yield* registry.resolve(token); + if (!invocation) return unauthorized; + return yield* httpEffect.pipe( + Effect.provideService(McpInvocationContext, invocation), + Effect.map(normalizeMcpHttpResponse), + ); + }); + }), +).layer; + +const McpTransportLive = McpServer.layerHttp({ + name: "T3 Code", + version: packageJson.version, + path: "/mcp", +}).pipe(Layer.provide(McpAuthMiddlewareLive)); + +export const PreviewToolkitRegistrationLive = Layer.effectDiscard( + Effect.gen(function* () { + const server = yield* McpServer.McpServer; + const built = yield* PreviewToolkit; + const handleTool = built.handle as unknown as ( + name: keyof typeof built.tools, + payload: unknown, + ) => Effect.Effect< + Stream.Stream<{ readonly encodedResult: unknown }, Error>, + Error, + McpInvocationContext + >; + for (const tool of Object.values(built.tools)) { + yield* server.addTool({ + tool: new McpSchema.Tool({ + name: tool.name, + description: Tool.getDescription(tool), + inputSchema: Tool.getJsonSchema(tool), + annotations: { + ...Context.getOption(tool.annotations, Tool.Title).pipe( + Option.map((title) => ({ title })), + Option.getOrUndefined, + ), + readOnlyHint: Context.get(tool.annotations, Tool.Readonly), + destructiveHint: Context.get(tool.annotations, Tool.Destructive), + idempotentHint: Context.get(tool.annotations, Tool.Idempotent), + openWorldHint: Context.get(tool.annotations, Tool.OpenWorld), + }, + }), + annotations: tool.annotations, + handle: (payload) => + handleTool(tool.name as keyof typeof built.tools, payload).pipe( + Stream.unwrap, + Stream.run(Sink.last()), + Effect.flatMap(Effect.fromOption), + Effect.matchCause({ + onFailure: (cause) => + new McpSchema.CallToolResult({ + isError: true, + content: [{ type: "text", text: Cause.pretty(cause) }], + }), + onSuccess: (result) => { + if (tool.name === "preview_snapshot") { + const snapshot = result.encodedResult as { + readonly screenshot: { + readonly mimeType: "image/png"; + readonly data: string; + readonly width: number; + readonly height: number; + }; + readonly [key: string]: unknown; + }; + const { screenshot, ...page } = snapshot; + const metadata = { + ...page, + screenshot: { + mimeType: screenshot.mimeType, + width: screenshot.width, + height: screenshot.height, + }, + }; + return new McpSchema.CallToolResult({ + isError: false, + structuredContent: metadata, + content: [ + { type: "text", text: JSON.stringify(metadata) }, + { + type: "image", + data: new Uint8Array(Buffer.from(screenshot.data, "base64")), + mimeType: screenshot.mimeType, + }, + ], + }); + } + return new McpSchema.CallToolResult({ + isError: false, + structuredContent: + typeof result.encodedResult === "object" ? result.encodedResult : undefined, + content: [{ type: "text", text: JSON.stringify(result.encodedResult) }], + }); + }, + }), + ) as unknown as Effect.Effect<McpSchema.CallToolResult, never, McpSchema.McpServerClient>, + }); + } + }), +).pipe(Layer.provide(PreviewToolkitHandlersLive)); + +export const McpHttpServerLive = Layer.mergeAll(PreviewToolkitRegistrationLive).pipe( + Layer.provideMerge(McpTransportLive), +); diff --git a/apps/server/src/mcp/Layers/McpSessionRegistry.test.ts b/apps/server/src/mcp/Layers/McpSessionRegistry.test.ts new file mode 100644 index 00000000000..bbd27c63cac --- /dev/null +++ b/apps/server/src/mcp/Layers/McpSessionRegistry.test.ts @@ -0,0 +1,66 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import { HttpServer } from "effect/unstable/http"; + +import { ServerEnvironment } from "../../environment/Services/ServerEnvironment.ts"; +import { makeMcpSessionRegistry } from "./McpSessionRegistry.ts"; + +const environmentId = EnvironmentId.make("environment-1"); +const fakeHttpServer = HttpServer.HttpServer.of({ + address: { _tag: "TcpAddress", hostname: "127.0.0.1", port: 43123 }, + serve: (() => Effect.void) as HttpServer.HttpServer["Service"]["serve"], +}); +const fakeEnvironment = ServerEnvironment.of({ + getEnvironmentId: Effect.succeed(environmentId), + getDescriptor: Effect.die("unused"), +}); + +const makeRegistry = (now: () => number) => + makeMcpSessionRegistry({ + now, + idleTimeoutMs: 100, + maximumLifetimeMs: 1_000, + }).pipe( + Effect.provideService(HttpServer.HttpServer, fakeHttpServer), + Effect.provideService(ServerEnvironment, fakeEnvironment), + Effect.provide(NodeServices.layer), + ); + +it.effect("stores only a token hash, resolves the bearer token, and revokes by thread", () => + Effect.gen(function* () { + let timestamp = 1_000; + const registry = yield* makeRegistry(() => timestamp); + const threadId = ThreadId.make("thread-1"); + const issued = yield* registry.issue({ + threadId, + providerInstanceId: ProviderInstanceId.make("codex"), + }); + expect(issued.config.endpoint).toBe("http://127.0.0.1:43123/mcp"); + const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, ""); + expect(token.length).toBeGreaterThan(20); + + const resolved = yield* registry.resolve(token); + expect(resolved?.threadId).toBe(threadId); + + yield* registry.revokeThread(threadId); + expect(yield* registry.resolve(token)).toBeUndefined(); + + timestamp += 2_000; + }), +); + +it.effect("expires credentials after inactivity", () => + Effect.gen(function* () { + let timestamp = 1_000; + const registry = yield* makeRegistry(() => timestamp); + const issued = yield* registry.issue({ + threadId: ThreadId.make("thread-2"), + providerInstanceId: ProviderInstanceId.make("claude"), + }); + const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, ""); + timestamp += 101; + expect(yield* registry.resolve(token)).toBeUndefined(); + }), +); diff --git a/apps/server/src/mcp/Layers/McpSessionRegistry.ts b/apps/server/src/mcp/Layers/McpSessionRegistry.ts new file mode 100644 index 00000000000..a6aa95072a6 --- /dev/null +++ b/apps/server/src/mcp/Layers/McpSessionRegistry.ts @@ -0,0 +1,173 @@ +import { ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import { HttpServer } from "effect/unstable/http"; + +import { ServerEnvironment } from "../../environment/Services/ServerEnvironment.ts"; +import type { McpInvocationScope } from "../Services/McpInvocationContext.ts"; +import { + McpSessionRegistry, + type McpCredentialRequest, + type McpIssuedCredential, + type McpSessionRegistryShape, +} from "../Services/McpSessionRegistry.ts"; + +interface CredentialRecord { + readonly tokenHash: string; + readonly scope: McpInvocationScope; + readonly lastUsedAt: number; +} + +interface RegistryState { + readonly records: ReadonlyMap<string, CredentialRecord>; +} + +export interface McpSessionRegistryOptions { + readonly idleTimeoutMs?: number; + readonly maximumLifetimeMs?: number; + readonly now?: () => number; +} + +const DEFAULT_IDLE_TIMEOUT_MS = 30 * 60 * 1_000; +const DEFAULT_MAXIMUM_LIFETIME_MS = 8 * 60 * 60 * 1_000; + +const bytesToHex = (bytes: Uint8Array): string => + Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); + +const tokenFromBytes = (bytes: Uint8Array): string => Buffer.from(bytes).toString("base64url"); + +export const makeMcpSessionRegistry = (options: McpSessionRegistryOptions = {}) => + Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const environment = yield* ServerEnvironment; + const environmentId = yield* environment.getEnvironmentId; + const httpServer = yield* HttpServer.HttpServer; + const state = yield* Ref.make<RegistryState>({ records: new Map() }); + const now = options.now ?? Date.now; + const idleTimeoutMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS; + const maximumLifetimeMs = options.maximumLifetimeMs ?? DEFAULT_MAXIMUM_LIFETIME_MS; + const endpoint = + httpServer.address._tag === "TcpAddress" + ? `http://127.0.0.1:${httpServer.address.port}/mcp` + : "http://127.0.0.1/mcp"; + + const hashToken = (token: string) => + crypto + .digest("SHA-256", new TextEncoder().encode(token)) + .pipe(Effect.map(bytesToHex), Effect.orDie); + + const pruneExpired = (records: ReadonlyMap<string, CredentialRecord>, timestamp: number) => { + let changed = false; + const next = new Map<string, CredentialRecord>(); + for (const [hash, record] of records) { + if (timestamp <= record.scope.expiresAt && timestamp - record.lastUsedAt <= idleTimeoutMs) { + next.set(hash, record); + } else { + changed = true; + } + } + return changed ? next : records; + }; + + const issue: McpSessionRegistryShape["issue"] = (request) => + Effect.gen(function* () { + const issuedAt = now(); + const providerSessionId = yield* crypto.randomUUIDv4.pipe(Effect.orDie); + const rawToken = yield* crypto + .randomBytes(32) + .pipe(Effect.map(tokenFromBytes), Effect.orDie); + const tokenHash = yield* hashToken(rawToken); + const expiresAt = issuedAt + maximumLifetimeMs; + const scope: McpInvocationScope = { + environmentId, + threadId: ThreadId.make(request.threadId), + providerSessionId, + providerInstanceId: ProviderInstanceId.make(request.providerInstanceId), + capabilities: new Set(["preview"]), + issuedAt, + expiresAt, + }; + yield* Ref.update(state, ({ records }) => { + const next = new Map(pruneExpired(records, issuedAt)); + next.set(tokenHash, { tokenHash, scope, lastUsedAt: issuedAt }); + return { records: next }; + }); + return { + config: { + environmentId, + threadId: scope.threadId, + providerSessionId, + providerInstanceId: scope.providerInstanceId, + endpoint, + authorizationHeader: `Bearer ${rawToken}`, + }, + expiresAt, + }; + }); + + const resolve: McpSessionRegistryShape["resolve"] = (rawToken) => + Effect.gen(function* () { + if (rawToken.length === 0) return undefined; + const tokenHash = yield* hashToken(rawToken); + const timestamp = now(); + let resolved: McpInvocationScope | undefined; + yield* Ref.update(state, ({ records }) => { + const current = pruneExpired(records, timestamp); + const record = current.get(tokenHash); + if (!record) return { records: current }; + resolved = record.scope; + const next = new Map(current); + next.set(tokenHash, { ...record, lastUsedAt: timestamp }); + return { records: next }; + }); + return resolved; + }); + + const revokeWhere = (predicate: (record: CredentialRecord) => boolean) => + Ref.update(state, ({ records }) => ({ + records: new Map(Array.from(records).filter(([, record]) => !predicate(record))), + })); + + return McpSessionRegistry.of({ + issue, + resolve, + revokeProviderSession: (providerSessionId) => + revokeWhere((record) => record.scope.providerSessionId === providerSessionId), + revokeThread: (threadId) => revokeWhere((record) => record.scope.threadId === threadId), + revokeAll: Ref.set(state, { records: new Map() }), + }); + }); + +let activeMcpSessionRegistry: McpSessionRegistryShape | undefined; + +export const McpSessionRegistryLive: Layer.Layer< + McpSessionRegistry, + never, + Crypto.Crypto | ServerEnvironment | HttpServer.HttpServer +> = Layer.effect( + McpSessionRegistry, + makeMcpSessionRegistry().pipe( + Effect.tap((registry) => + Effect.sync(() => { + activeMcpSessionRegistry = registry; + }), + ), + ), +); + +export const issueActiveMcpCredential = ( + request: McpCredentialRequest, +): Effect.Effect<McpIssuedCredential | undefined> => + activeMcpSessionRegistry + ? activeMcpSessionRegistry + .revokeThread(request.threadId) + .pipe(Effect.andThen(activeMcpSessionRegistry.issue(request))) + : Effect.sync((): McpIssuedCredential | undefined => undefined); + +export const revokeActiveMcpThread = (threadId: ThreadId): Effect.Effect<void> => + activeMcpSessionRegistry ? activeMcpSessionRegistry.revokeThread(threadId) : Effect.void; + +export const revokeAllActiveMcpCredentials = (): Effect.Effect<void> => + activeMcpSessionRegistry ? activeMcpSessionRegistry.revokeAll : Effect.void; diff --git a/apps/server/src/mcp/Layers/PreviewAutomationBroker.test.ts b/apps/server/src/mcp/Layers/PreviewAutomationBroker.test.ts new file mode 100644 index 00000000000..d3e43e4c53c --- /dev/null +++ b/apps/server/src/mcp/Layers/PreviewAutomationBroker.test.ts @@ -0,0 +1,65 @@ +import { expect, it } from "@effect/vitest"; +import { + EnvironmentId, + PreviewAutomationNoFocusedOwnerError, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Stream from "effect/Stream"; + +import { makePreviewAutomationBroker } from "./PreviewAutomationBroker.ts"; + +const scope = { + environmentId: EnvironmentId.make("environment-1"), + threadId: ThreadId.make("thread-1"), + providerSessionId: "provider-session-1", + providerInstanceId: ProviderInstanceId.make("codex"), + capabilities: new Set(["preview"] as const), + issuedAt: 1, + expiresAt: 2, +}; + +it.effect("routes a request to the focused owner and correlates its response", () => + Effect.scoped( + Effect.gen(function* () { + const broker = makePreviewAutomationBroker(); + const requests = yield* broker.connect("client-1"); + yield* Stream.runForEach(requests, (request) => + broker.respond({ + requestId: request.requestId, + ok: true, + result: { available: true }, + }), + ).pipe(Effect.forkScoped); + yield* Effect.yieldNow; + yield* broker.reportOwner({ + clientId: "client-1", + environmentId: scope.environmentId, + threadId: scope.threadId, + tabId: null, + visible: false, + supportsAutomation: true, + focusedAt: "2026-06-11T00:00:00.000Z", + }); + + const result = yield* broker.invoke<{ available: boolean }>({ + scope, + operation: "open", + input: {}, + }); + + expect(result).toEqual({ available: true }); + }), + ), +); + +it.effect("rejects calls when no focused owner exists", () => + Effect.gen(function* () { + const broker = makePreviewAutomationBroker(); + const error = yield* broker + .invoke<void>({ scope, operation: "status", input: {} }) + .pipe(Effect.flip); + expect(error).toBeInstanceOf(PreviewAutomationNoFocusedOwnerError); + }), +); diff --git a/apps/server/src/mcp/Layers/PreviewAutomationBroker.ts b/apps/server/src/mcp/Layers/PreviewAutomationBroker.ts new file mode 100644 index 00000000000..67d3a6ed575 --- /dev/null +++ b/apps/server/src/mcp/Layers/PreviewAutomationBroker.ts @@ -0,0 +1,268 @@ +import { + PreviewAutomationExecutionError, + PreviewAutomationInvalidSelectorError, + PreviewAutomationNoFocusedOwnerError, + PreviewAutomationResultTooLargeError, + PreviewAutomationTabNotFoundError, + PreviewAutomationTimeoutError, + PreviewAutomationUnavailableError, + PreviewAutomationUnsupportedClientError, + type PreviewAutomationError, + type PreviewAutomationOwner, + type PreviewAutomationResponse, +} from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; + +import { + PreviewAutomationBroker, + type PreviewAutomationBrokerShape, +} from "../Services/PreviewAutomationBroker.ts"; + +interface ClientConnection { + readonly clientId: string; + readonly queue: Queue.Queue< + Parameters<PreviewAutomationBrokerShape["respond"]>[0] extends never + ? never + : import("@t3tools/contracts").PreviewAutomationRequest + >; +} + +interface PendingRequest { + readonly clientId: string; + readonly deferred: Deferred.Deferred<unknown, PreviewAutomationError>; +} + +interface BrokerState { + readonly clients: ReadonlyMap<string, ClientConnection>; + readonly owners: ReadonlyMap<string, PreviewAutomationOwner>; + readonly pending: ReadonlyMap<string, PendingRequest>; +} + +const makeResponseError = ( + error: NonNullable<PreviewAutomationResponse["error"]>, +): PreviewAutomationError => { + switch (error._tag) { + case "PreviewAutomationNoFocusedOwnerError": + return new PreviewAutomationNoFocusedOwnerError({ message: error.message }); + case "PreviewAutomationUnsupportedClientError": + return new PreviewAutomationUnsupportedClientError({ message: error.message }); + case "PreviewAutomationTabNotFoundError": + return new PreviewAutomationTabNotFoundError({ message: error.message }); + case "PreviewAutomationTimeoutError": + return new PreviewAutomationTimeoutError({ message: error.message }); + case "PreviewAutomationInvalidSelectorError": { + const detail = + typeof error.detail === "object" && error.detail !== null ? error.detail : undefined; + return new PreviewAutomationInvalidSelectorError({ + message: error.message, + selector: + detail && "selector" in detail && typeof detail.selector === "string" + ? detail.selector + : "", + }); + } + case "PreviewAutomationResultTooLargeError": { + const detail = + typeof error.detail === "object" && error.detail !== null ? error.detail : undefined; + return new PreviewAutomationResultTooLargeError({ + message: error.message, + maximumBytes: + detail && "maximumBytes" in detail && typeof detail.maximumBytes === "number" + ? detail.maximumBytes + : 64_000, + }); + } + case "PreviewAutomationUnavailableError": + return new PreviewAutomationUnavailableError({ message: error.message }); + default: + return new PreviewAutomationExecutionError({ + message: error.message, + detail: error.detail, + }); + } +}; + +export const makePreviewAutomationBroker = (): PreviewAutomationBrokerShape => { + const state = Effect.runSync( + Ref.make<BrokerState>({ + clients: new Map(), + owners: new Map(), + pending: new Map(), + }), + ); + let requestSequence = 0; + + const disconnect = (clientId: string, queue: ClientConnection["queue"]) => + Effect.gen(function* () { + const toFail: PendingRequest[] = []; + yield* Ref.update(state, (current) => { + if (current.clients.get(clientId)?.queue !== queue) return current; + const clients = new Map(current.clients); + const owners = new Map(current.owners); + const pending = new Map(current.pending); + clients.delete(clientId); + owners.delete(clientId); + for (const [requestId, entry] of pending) { + if (entry.clientId === clientId) { + pending.delete(requestId); + toFail.push(entry); + } + } + return { clients, owners, pending }; + }); + yield* Effect.forEach( + toFail, + ({ deferred }) => + Deferred.fail( + deferred, + new PreviewAutomationUnavailableError({ + message: "The preview automation client disconnected.", + }), + ), + { discard: true }, + ); + yield* Queue.shutdown(queue); + }); + + const connect: PreviewAutomationBrokerShape["connect"] = (clientId) => + Effect.gen(function* () { + const queue = yield* Queue.unbounded<import("@t3tools/contracts").PreviewAutomationRequest>(); + let previous: ClientConnection | undefined; + yield* Ref.update(state, (current) => { + previous = current.clients.get(clientId); + const clients = new Map(current.clients); + clients.set(clientId, { clientId, queue }); + return { ...current, clients }; + }); + if (previous) yield* disconnect(clientId, previous.queue); + return Stream.fromQueue(queue).pipe(Stream.ensuring(disconnect(clientId, queue))); + }); + + const reportOwner: PreviewAutomationBrokerShape["reportOwner"] = (owner) => + Ref.update(state, (current) => { + const owners = new Map(current.owners); + owners.set(owner.clientId, owner); + return { ...current, owners }; + }); + + const clearOwner: PreviewAutomationBrokerShape["clearOwner"] = (clientId) => + Ref.update(state, (current) => { + const owners = new Map(current.owners); + owners.delete(clientId); + return { ...current, owners }; + }); + + const respond: PreviewAutomationBrokerShape["respond"] = (response) => + Effect.gen(function* () { + let pending: PendingRequest | undefined; + yield* Ref.update(state, (current) => { + pending = current.pending.get(response.requestId); + if (!pending) return current; + const next = new Map(current.pending); + next.delete(response.requestId); + return { ...current, pending: next }; + }); + if (!pending) return; + if (response.ok) { + yield* Deferred.succeed(pending.deferred, response.result); + } else { + yield* Deferred.fail( + pending.deferred, + response.error + ? makeResponseError(response.error) + : new PreviewAutomationExecutionError({ + message: "Preview automation failed without an error payload.", + }), + ); + } + }); + + const invoke = <A = unknown>( + input: Parameters<PreviewAutomationBrokerShape["invoke"]>[0], + ): Effect.Effect<A, PreviewAutomationError> => + Effect.gen(function* () { + const current = yield* Ref.get(state); + const candidates = Array.from(current.owners.values()) + .filter( + (owner) => + owner.environmentId === input.scope.environmentId && + owner.threadId === input.scope.threadId && + owner.supportsAutomation && + (input.operation === "open" || input.operation === "status" || owner.visible), + ) + .sort((left, right) => right.focusedAt.localeCompare(left.focusedAt)); + const owner = candidates[0]; + if (!owner) { + return yield* new PreviewAutomationNoFocusedOwnerError({ + message: "No focused desktop preview owner is available for this thread.", + }); + } + const connection = current.clients.get(owner.clientId); + if (!connection) { + return yield* new PreviewAutomationUnavailableError({ + message: "The focused preview owner is not connected.", + }); + } + if ( + input.operation !== "open" && + input.operation !== "status" && + !owner.tabId && + !input.tabId + ) { + return yield* new PreviewAutomationTabNotFoundError({ + message: "The focused preview owner does not have an active tab.", + }); + } + const requestId = `preview-${requestSequence++}`; + const timeoutMs = input.timeoutMs ?? 15_000; + const deferred = yield* Deferred.make<unknown, PreviewAutomationError>(); + yield* Ref.update(state, (next) => { + const pending = new Map(next.pending); + pending.set(requestId, { clientId: owner.clientId, deferred }); + return { ...next, pending }; + }); + const offered = yield* Queue.offer(connection.queue, { + requestId, + threadId: input.scope.threadId, + tabId: input.tabId ?? owner.tabId ?? undefined, + operation: input.operation, + input: input.input, + timeoutMs, + }); + if (!offered) { + return yield* new PreviewAutomationUnavailableError({ + message: "The preview automation client is no longer accepting requests.", + }); + } + const result = yield* Deferred.await(deferred).pipe(Effect.timeoutOption(timeoutMs)); + yield* Ref.update(state, (next) => { + const pending = new Map(next.pending); + pending.delete(requestId); + return { ...next, pending }; + }); + return yield* Option.match(result, { + onNone: () => + Effect.fail( + new PreviewAutomationTimeoutError({ + message: `Preview automation timed out after ${timeoutMs}ms.`, + }), + ), + onSome: (value) => Effect.succeed(value as A), + }); + }); + + return PreviewAutomationBroker.of({ connect, reportOwner, clearOwner, respond, invoke }); +}; + +export const previewAutomationBroker = makePreviewAutomationBroker(); + +export const PreviewAutomationBrokerLive: Layer.Layer<PreviewAutomationBroker> = Layer.succeed( + PreviewAutomationBroker, + previewAutomationBroker, +); diff --git a/apps/server/src/mcp/Services/McpInvocationContext.ts b/apps/server/src/mcp/Services/McpInvocationContext.ts new file mode 100644 index 00000000000..89a3820479b --- /dev/null +++ b/apps/server/src/mcp/Services/McpInvocationContext.ts @@ -0,0 +1,33 @@ +import type { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import { PreviewAutomationUnavailableError } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; + +export type McpCapability = "preview"; + +export interface McpInvocationScope { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly providerSessionId: string; + readonly providerInstanceId: ProviderInstanceId; + readonly capabilities: ReadonlySet<McpCapability>; + readonly issuedAt: number; + readonly expiresAt: number; +} + +export class McpInvocationContext extends Context.Service< + McpInvocationContext, + McpInvocationScope +>()("t3/mcp/Services/McpInvocationContext") {} + +export const requireMcpCapability = Effect.fn("mcp.requireCapability")(function* ( + capability: McpCapability, +) { + const invocation = yield* McpInvocationContext; + if (!invocation.capabilities.has(capability)) { + return yield* new PreviewAutomationUnavailableError({ + message: `MCP credential does not grant the ${capability} capability.`, + }); + } + return invocation; +}); diff --git a/apps/server/src/mcp/Services/McpProviderSession.ts b/apps/server/src/mcp/Services/McpProviderSession.ts new file mode 100644 index 00000000000..b97cbb1e1c0 --- /dev/null +++ b/apps/server/src/mcp/Services/McpProviderSession.ts @@ -0,0 +1,34 @@ +import type { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; + +export interface McpProviderSessionConfig { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly providerSessionId: string; + readonly providerInstanceId: ProviderInstanceId; + readonly endpoint: string; + readonly authorizationHeader: string; +} + +export class McpProviderSession extends Context.Service< + McpProviderSession, + McpProviderSessionConfig +>()("t3/mcp/Services/McpProviderSession") {} + +const sessionsByThread = new Map<ThreadId, McpProviderSessionConfig>(); + +export function setMcpProviderSession(config: McpProviderSessionConfig): void { + sessionsByThread.set(config.threadId, config); +} + +export function readMcpProviderSession(threadId: ThreadId): McpProviderSessionConfig | undefined { + return sessionsByThread.get(threadId); +} + +export function clearMcpProviderSession(threadId: ThreadId): void { + sessionsByThread.delete(threadId); +} + +export function clearAllMcpProviderSessions(): void { + sessionsByThread.clear(); +} diff --git a/apps/server/src/mcp/Services/McpSessionRegistry.ts b/apps/server/src/mcp/Services/McpSessionRegistry.ts new file mode 100644 index 00000000000..df2ca991271 --- /dev/null +++ b/apps/server/src/mcp/Services/McpSessionRegistry.ts @@ -0,0 +1,29 @@ +import type { ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { McpInvocationScope } from "./McpInvocationContext.ts"; +import type { McpProviderSessionConfig } from "./McpProviderSession.ts"; + +export interface McpCredentialRequest { + readonly threadId: ThreadId; + readonly providerInstanceId: ProviderInstanceId; +} + +export interface McpIssuedCredential { + readonly config: McpProviderSessionConfig; + readonly expiresAt: number; +} + +export interface McpSessionRegistryShape { + readonly issue: (request: McpCredentialRequest) => Effect.Effect<McpIssuedCredential, never>; + readonly resolve: (rawToken: string) => Effect.Effect<McpInvocationScope | undefined, never>; + readonly revokeProviderSession: (providerSessionId: string) => Effect.Effect<void>; + readonly revokeThread: (threadId: ThreadId) => Effect.Effect<void>; + readonly revokeAll: Effect.Effect<void>; +} + +export class McpSessionRegistry extends Context.Service< + McpSessionRegistry, + McpSessionRegistryShape +>()("t3/mcp/Services/McpSessionRegistry") {} diff --git a/apps/server/src/mcp/Services/PreviewAutomationBroker.ts b/apps/server/src/mcp/Services/PreviewAutomationBroker.ts new file mode 100644 index 00000000000..1af1a3d24bf --- /dev/null +++ b/apps/server/src/mcp/Services/PreviewAutomationBroker.ts @@ -0,0 +1,40 @@ +import type { + PreviewAutomationError, + PreviewAutomationOperation, + PreviewAutomationOwner, + PreviewAutomationRequest, + PreviewAutomationResponse, + PreviewTabId, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import type * as Stream from "effect/Stream"; + +import type { McpInvocationScope } from "./McpInvocationContext.ts"; + +export interface PreviewAutomationInvokeInput { + readonly scope: McpInvocationScope; + readonly operation: PreviewAutomationOperation; + readonly input: unknown; + readonly tabId?: PreviewTabId; + readonly timeoutMs?: number; +} + +export interface PreviewAutomationBrokerShape { + readonly connect: (clientId: string) => Effect.Effect<Stream.Stream<PreviewAutomationRequest>>; + readonly reportOwner: ( + owner: PreviewAutomationOwner, + ) => Effect.Effect<void, PreviewAutomationError>; + readonly clearOwner: (clientId: string) => Effect.Effect<void>; + readonly respond: ( + response: PreviewAutomationResponse, + ) => Effect.Effect<void, PreviewAutomationError>; + readonly invoke: <A = unknown>( + request: PreviewAutomationInvokeInput, + ) => Effect.Effect<A, PreviewAutomationError>; +} + +export class PreviewAutomationBroker extends Context.Service< + PreviewAutomationBroker, + PreviewAutomationBrokerShape +>()("t3/mcp/Services/PreviewAutomationBroker") {} diff --git a/apps/server/src/mcp/toolkits/preview/handlers.ts b/apps/server/src/mcp/toolkits/preview/handlers.ts new file mode 100644 index 00000000000..f5e19bfcf3e --- /dev/null +++ b/apps/server/src/mcp/toolkits/preview/handlers.ts @@ -0,0 +1,47 @@ +import * as Effect from "effect/Effect"; +import type { + PreviewAutomationOperation, + PreviewAutomationSnapshot, + PreviewAutomationStatus, +} from "@t3tools/contracts"; + +import { requireMcpCapability } from "../../Services/McpInvocationContext.ts"; +import { previewAutomationBroker } from "../../Layers/PreviewAutomationBroker.ts"; +import { PreviewToolkit } from "./tools.ts"; + +const invoke = <A>( + operation: PreviewAutomationOperation, + input: unknown, + timeoutMs?: number, +): Effect.Effect< + A, + import("@t3tools/contracts").PreviewAutomationError, + import("../../Services/McpInvocationContext.ts").McpInvocationContext +> => + Effect.gen(function* () { + const scope = yield* requireMcpCapability("preview"); + return yield* previewAutomationBroker.invoke<A>({ + scope, + operation, + input, + ...(timeoutMs === undefined ? {} : { timeoutMs }), + }); + }); + +export const PreviewToolkitHandlersLive = PreviewToolkit.toLayer({ + preview_status: () => invoke<PreviewAutomationStatus>("status", {}), + preview_open: (input) => + invoke<PreviewAutomationStatus>("open", { + ...input, + show: input.show ?? true, + reuseExistingTab: input.reuseExistingTab ?? true, + }), + preview_navigate: (input) => invoke<PreviewAutomationStatus>("navigate", input, input.timeoutMs), + preview_snapshot: () => invoke<PreviewAutomationSnapshot>("snapshot", {}), + preview_click: (input) => invoke<void>("click", input, input.timeoutMs), + preview_type: (input) => invoke<void>("type", input, input.timeoutMs), + preview_press: (input) => invoke<void>("press", input), + preview_scroll: (input) => invoke<void>("scroll", input), + preview_evaluate: (input) => invoke<unknown>("evaluate", input), + preview_wait_for: (input) => invoke<void>("waitFor", input, input.timeoutMs), +}); diff --git a/apps/server/src/mcp/toolkits/preview/tools.ts b/apps/server/src/mcp/toolkits/preview/tools.ts new file mode 100644 index 00000000000..32baf050f2f --- /dev/null +++ b/apps/server/src/mcp/toolkits/preview/tools.ts @@ -0,0 +1,138 @@ +import { + PreviewAutomationClickInput, + PreviewAutomationError, + PreviewAutomationEvaluateInput, + PreviewAutomationNavigateInput, + PreviewAutomationOpenInput, + PreviewAutomationPressInput, + PreviewAutomationScrollInput, + PreviewAutomationSnapshot, + PreviewAutomationStatus, + PreviewAutomationTypeInput, + PreviewAutomationWaitForInput, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import { Tool, Toolkit } from "effect/unstable/ai"; + +import { McpInvocationContext } from "../../Services/McpInvocationContext.ts"; + +const browserTool = <T extends Tool.Any>(tool: T): T => + tool.annotate(Tool.Destructive, false).annotate(Tool.OpenWorld, true) as T; + +export const PreviewStatusTool = Tool.make("preview_status", { + description: + "Report whether the scoped thread has an automation-capable desktop preview, including its active tab, URL, title, visibility, and loading state.", + success: PreviewAutomationStatus, + failure: PreviewAutomationError, + dependencies: [McpInvocationContext], +}) + .annotate(Tool.Title, "Get preview status") + .annotate(Tool.Readonly, true) + .annotate(Tool.Destructive, false) + .annotate(Tool.Idempotent, true); + +export const PreviewOpenTool = browserTool( + Tool.make("preview_open", { + description: + "Show and initialize the browser preview for the scoped thread, optionally reusing its current tab and navigating to a URL.", + parameters: PreviewAutomationOpenInput, + success: PreviewAutomationStatus, + failure: PreviewAutomationError, + dependencies: [McpInvocationContext], + }).annotate(Tool.Title, "Open browser preview"), +); + +export const PreviewNavigateTool = browserTool( + Tool.make("preview_navigate", { + description: + "Navigate the scoped thread's active preview tab to a URL and wait for the requested readiness condition.", + parameters: PreviewAutomationNavigateInput, + success: PreviewAutomationStatus, + failure: PreviewAutomationError, + dependencies: [McpInvocationContext], + }).annotate(Tool.Title, "Navigate browser preview"), +); + +export const PreviewSnapshotTool = Tool.make("preview_snapshot", { + description: + "Capture bounded page metadata, visible text, interactive elements, accessibility data, and a PNG screenshot from the scoped preview tab.", + success: PreviewAutomationSnapshot, + failure: PreviewAutomationError, + dependencies: [McpInvocationContext], +}) + .annotate(Tool.Title, "Capture preview snapshot") + .annotate(Tool.Readonly, true) + .annotate(Tool.Destructive, false); + +export const PreviewClickTool = browserTool( + Tool.make("preview_click", { + description: + "Click an element selected by CSS selector or click viewport coordinates in the scoped preview tab.", + parameters: PreviewAutomationClickInput, + failure: PreviewAutomationError, + dependencies: [McpInvocationContext], + }).annotate(Tool.Title, "Click preview page"), +); + +export const PreviewTypeTool = browserTool( + Tool.make("preview_type", { + description: + "Type text into the focused element or a CSS-selected element, optionally clearing its existing value first.", + parameters: PreviewAutomationTypeInput, + failure: PreviewAutomationError, + dependencies: [McpInvocationContext], + }).annotate(Tool.Title, "Type into preview page"), +); + +export const PreviewPressTool = browserTool( + Tool.make("preview_press", { + description: "Dispatch a keyboard key with optional modifiers to the scoped preview tab.", + parameters: PreviewAutomationPressInput, + failure: PreviewAutomationError, + dependencies: [McpInvocationContext], + }).annotate(Tool.Title, "Press key in preview page"), +); + +export const PreviewScrollTool = browserTool( + Tool.make("preview_scroll", { + description: + "Scroll the preview viewport or a CSS-selected scroll container by the requested deltas.", + parameters: PreviewAutomationScrollInput, + failure: PreviewAutomationError, + dependencies: [McpInvocationContext], + }).annotate(Tool.Title, "Scroll preview page"), +); + +export const PreviewEvaluateTool = browserTool( + Tool.make("preview_evaluate", { + description: + "Evaluate bounded JavaScript in the scoped preview tab and return a serializable result of at most 64 KB.", + parameters: PreviewAutomationEvaluateInput, + success: Schema.Unknown, + failure: PreviewAutomationError, + dependencies: [McpInvocationContext], + }).annotate(Tool.Title, "Evaluate JavaScript in preview"), +); + +export const PreviewWaitForTool = browserTool( + Tool.make("preview_wait_for", { + description: + "Wait until a CSS selector, visible-text substring, or URL substring appears in the scoped preview tab.", + parameters: PreviewAutomationWaitForInput, + failure: PreviewAutomationError, + dependencies: [McpInvocationContext], + }).annotate(Tool.Title, "Wait for preview page condition"), +); + +export const PreviewToolkit = Toolkit.make( + PreviewStatusTool, + PreviewOpenTool, + PreviewNavigateTool, + PreviewSnapshotTool, + PreviewClickTool, + PreviewTypeTool, + PreviewPressTool, + PreviewScrollTool, + PreviewEvaluateTool, + PreviewWaitForTool, +); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 38b77c69262..b7cdd931ca6 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -69,6 +69,7 @@ import * as Stream from "effect/Stream"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; +import { readMcpProviderSession } from "../../mcp/Services/McpProviderSession.ts"; import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; import { getClaudeModelCapabilities, @@ -3445,6 +3446,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(fastMode ? { fastMode: true } : {}), ...(ultracode ? { ultracode: true } : {}), }; + const mcpSession = readMcpProviderSession(input.threadId); const queryOptions: ClaudeQueryOptions = { ...(input.cwd ? { cwd: input.cwd } : {}), ...(apiModelId ? { model: apiModelId } : {}), @@ -3470,6 +3472,19 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( env: claudeEnvironment, ...(input.cwd ? { additionalDirectories: [input.cwd] } : {}), ...(Object.keys(extraArgs).length > 0 ? { extraArgs } : {}), + ...(mcpSession + ? { + mcpServers: { + "t3-code": { + type: "http", + url: mcpSession.endpoint, + headers: { + Authorization: mcpSession.authorizationHeader, + }, + }, + }, + } + : {}), }; yield* Effect.annotateCurrentSpan({ diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 8c9969e2bc4..ea2d898730a 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -39,6 +39,7 @@ import * as EffectCodexSchema from "effect-codex-app-server/schema"; import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import { getCodexServiceTierOptionValue } from "../../codexModelOptions.ts"; +import { readMcpProviderSession } from "../../mcp/Services/McpProviderSession.ts"; import { ProviderAdapterRequestError, @@ -1382,6 +1383,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( input.modelSelection?.instanceId === boundInstanceId ? getCodexServiceTierOptionValue(input.modelSelection) : undefined; + const mcpSession = readMcpProviderSession(input.threadId); const runtimeInput: CodexSessionRuntimeOptions = { threadId: input.threadId, providerInstanceId: boundInstanceId, @@ -1397,6 +1399,20 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( ? { model: input.modelSelection.model } : {}), ...(serviceTier ? { serviceTier } : {}), + ...(mcpSession + ? { + environment: { + ...(options?.environment ?? process.env), + T3_MCP_BEARER_TOKEN: mcpSession.authorizationHeader.replace(/^Bearer\s+/, ""), + }, + appServerArgs: [ + "-c", + `mcp_servers.t3-code.url=${mcpSession.endpoint}`, + "-c", + 'mcp_servers.t3-code.bearer_token_env_var="T3_MCP_BEARER_TOKEN"', + ], + } + : {}), }; const sessionScope = yield* Scope.make("sequential"); let sessionScopeTransferred = false; diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index f9b9c6ab4fb..fc31ca3aef7 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -103,6 +103,7 @@ export interface CodexSessionRuntimeOptions { readonly model?: string; readonly serviceTier?: CodexServiceTier | undefined; readonly resumeCursor?: CodexResumeCursor; + readonly appServerArgs?: ReadonlyArray<string>; } export interface CodexSessionRuntimeSendTurnInput { @@ -720,7 +721,7 @@ export const makeCodexSessionRuntime = ( }; const child = yield* spawner .spawn( - ChildProcess.make(options.binaryPath, ["app-server"], { + ChildProcess.make(options.binaryPath, ["app-server", ...(options.appServerArgs ?? [])], { cwd: options.cwd, env, forceKillAfter: CODEX_APP_SERVER_FORCE_KILL_AFTER, diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index cdb3c224b97..c9af2de3557 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -42,6 +42,7 @@ import type * as EffectAcpSchema from "effect-acp/schema"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; +import { readMcpProviderSession } from "../../mcp/Services/McpProviderSession.ts"; import { ProviderAdapterProcessError, ProviderAdapterRequestError, @@ -530,6 +531,7 @@ export function makeCursorAdapter( ? yield* options.resolveSettings : cursorSettings; + const mcpSession = readMcpProviderSession(input.threadId); const acp = yield* makeCursorAcpRuntime({ cursorSettings: effectiveCursorSettings, ...(options?.environment ? { environment: options.environment } : {}), @@ -537,6 +539,23 @@ export function makeCursorAdapter( cwd, ...(resumeSessionId ? { resumeSessionId } : {}), clientInfo: { name: "t3-code", version: "0.0.0" }, + ...(mcpSession + ? { + mcpServers: [ + { + type: "http" as const, + name: "t3-code", + url: mcpSession.endpoint, + headers: [ + { + name: "Authorization", + value: mcpSession.authorizationHeader, + }, + ], + }, + ], + } + : {}), ...acpNativeLoggers, }).pipe( Effect.provideService(Scope.Scope, sessionScope), diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index 0f1007f261b..35cf7fb98e9 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -33,6 +33,7 @@ import type * as EffectAcpSchema from "effect-acp/schema"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; +import { readMcpProviderSession } from "../../mcp/Services/McpProviderSession.ts"; import { ProviderAdapterProcessError, ProviderAdapterRequestError, @@ -374,6 +375,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte threadId: input.threadId, }); + const mcpSession = readMcpProviderSession(input.threadId); const acp = yield* makeGrokAcpRuntime({ grokSettings, ...(options?.environment ? { environment: options.environment } : {}), @@ -381,6 +383,23 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte cwd, ...(resumeSessionId ? { resumeSessionId } : {}), clientInfo: { name: "t3-code", version: "0.0.0" }, + ...(mcpSession + ? { + mcpServers: [ + { + type: "http" as const, + name: "t3-code", + url: mcpSession.endpoint, + headers: [ + { + name: "Authorization", + value: mcpSession.authorizationHeader, + }, + ], + }, + ], + } + : {}), ...acpNativeLoggers, }).pipe( Effect.provideService(Scope.Scope, sessionScope), diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 54444ce586d..7361638924d 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -26,6 +26,7 @@ import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; +import { readMcpProviderSession } from "../../mcp/Services/McpProviderSession.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; import { ProviderAdapterProcessError, @@ -1053,6 +1054,22 @@ export function makeOpenCodeAdapter( directory, ...(server.external && serverPassword ? { serverPassword } : {}), }); + const mcpSession = readMcpProviderSession(input.threadId); + if (mcpSession && !server.external) { + yield* runOpenCodeSdk("mcp.add", () => + client.mcp.add({ + name: "t3-code", + config: { + type: "remote", + url: mcpSession.endpoint, + headers: { + Authorization: mcpSession.authorizationHeader, + }, + oauth: false, + }, + }), + ); + } const openCodeSession = yield* runOpenCodeSdk("session.create", () => client.session.create({ title: `T3 Code ${input.threadId}`, diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 2bce1f483b7..7d653419b27 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -56,6 +56,16 @@ import { import { type EventNdjsonLogger } from "./EventNdjsonLogger.ts"; import { ProviderEventLoggers } from "./ProviderEventLoggers.ts"; import { AnalyticsService } from "../../telemetry/Services/AnalyticsService.ts"; +import { + issueActiveMcpCredential, + revokeActiveMcpThread, + revokeAllActiveMcpCredentials, +} from "../../mcp/Layers/McpSessionRegistry.ts"; +import { + clearAllMcpProviderSessions, + clearMcpProviderSession, + setMcpProviderSession, +} from "../../mcp/Services/McpProviderSession.ts"; const isModelSelection = Schema.is(ModelSelection); /** @@ -212,6 +222,16 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( const directory = yield* ProviderSessionDirectory; const runtimeEventPubSub = yield* PubSub.unbounded<ProviderRuntimeEvent>(); const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + const prepareMcpSession = (threadId: ThreadId, providerInstanceId: ProviderInstanceId) => + issueActiveMcpCredential({ threadId, providerInstanceId }).pipe( + Effect.tap((credential) => + credential ? Effect.sync(() => setMcpProviderSession(credential.config)) : Effect.void, + ), + ); + const clearMcpSession = (threadId: ThreadId) => + revokeActiveMcpThread(threadId).pipe( + Effect.tap(() => Effect.sync(() => clearMcpProviderSession(threadId))), + ); const publishRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect<void> => Effect.succeed(event).pipe( @@ -383,16 +403,20 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( const persistedCwd = readPersistedCwd(input.binding.runtimePayload); const persistedModelSelection = readPersistedModelSelection(input.binding.runtimePayload); - const resumed = yield* adapter.startSession({ - threadId: input.binding.threadId, - provider: input.binding.provider, - providerInstanceId: bindingInstanceId, - ...(persistedCwd ? { cwd: persistedCwd } : {}), - ...(persistedModelSelection ? { modelSelection: persistedModelSelection } : {}), - ...(hasResumeCursor ? { resumeCursor: input.binding.resumeCursor } : {}), - runtimeMode: input.binding.runtimeMode ?? "full-access", - }); + yield* prepareMcpSession(input.binding.threadId, bindingInstanceId); + const resumed = yield* adapter + .startSession({ + threadId: input.binding.threadId, + provider: input.binding.provider, + providerInstanceId: bindingInstanceId, + ...(persistedCwd ? { cwd: persistedCwd } : {}), + ...(persistedModelSelection ? { modelSelection: persistedModelSelection } : {}), + ...(hasResumeCursor ? { resumeCursor: input.binding.resumeCursor } : {}), + runtimeMode: input.binding.runtimeMode ?? "full-access", + }) + .pipe(Effect.onError(() => clearMcpSession(input.binding.threadId))); if (resumed.provider !== adapter.provider) { + yield* clearMcpSession(input.binding.threadId); return yield* toValidationError( input.operation, `Adapter/provider mismatch while recovering thread '${input.binding.threadId}'. Expected '${adapter.provider}', received '${resumed.provider}'.`, @@ -572,14 +596,18 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( "provider.cwd.effective": effectiveCwd ?? "", }); const adapter = yield* registry.getByInstance(resolvedInstanceId); - const session = yield* adapter.startSession({ - ...input, - providerInstanceId: resolvedInstanceId, - ...(effectiveCwd !== undefined ? { cwd: effectiveCwd } : {}), - ...(effectiveResumeCursor !== undefined ? { resumeCursor: effectiveResumeCursor } : {}), - }); + yield* prepareMcpSession(threadId, resolvedInstanceId); + const session = yield* adapter + .startSession({ + ...input, + providerInstanceId: resolvedInstanceId, + ...(effectiveCwd !== undefined ? { cwd: effectiveCwd } : {}), + ...(effectiveResumeCursor !== undefined ? { resumeCursor: effectiveResumeCursor } : {}), + }) + .pipe(Effect.onError(() => clearMcpSession(threadId))); if (session.provider !== adapter.provider) { + yield* clearMcpSession(threadId); return yield* toValidationError( "ProviderService.startSession", `Adapter/provider mismatch: requested '${adapter.provider}', received '${session.provider}'.`, @@ -827,6 +855,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( if (routed.isActive) { yield* routed.adapter.stopSession(routed.threadId); } + yield* clearMcpSession(input.threadId); yield* directory.upsert({ threadId: input.threadId, provider: routed.adapter.provider, @@ -998,6 +1027,8 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ), ).pipe(Effect.asVoid); yield* Effect.forEach(currentAdapters, ([, adapter]) => adapter.stopAll()).pipe(Effect.asVoid); + yield* revokeAllActiveMcpCredentials(); + clearAllMcpProviderSessions(); const bindings = yield* directory.listBindings().pipe(Effect.orElseSucceed(() => [])); yield* Effect.forEach(bindings, (binding) => Effect.gen(function* () { diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index 4ed64890fc3..47a8c845e56 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -47,6 +47,7 @@ export interface AcpSessionRuntimeOptions { readonly version: string; }; readonly authMethodId: string; + readonly mcpServers?: ReadonlyArray<EffectAcpSchema.McpServer>; readonly requestLogger?: (event: AcpSessionRequestLogEvent) => Effect.Effect<void, never>; readonly protocolLogging?: { readonly logIncoming?: boolean; @@ -400,7 +401,7 @@ const makeAcpSessionRuntime = ( const loadPayload = { sessionId: options.resumeSessionId, cwd: options.cwd, - mcpServers: [], + mcpServers: options.mcpServers ?? [], } satisfies EffectAcpSchema.LoadSessionRequest; const resumed = yield* runLoggedRequest( "session/load", @@ -413,7 +414,7 @@ const makeAcpSessionRuntime = ( } else { const createPayload = { cwd: options.cwd, - mcpServers: [], + mcpServers: options.mcpServers ?? [], } satisfies EffectAcpSchema.NewSessionRequest; const created = yield* runLoggedRequest( "session/new", @@ -426,7 +427,7 @@ const makeAcpSessionRuntime = ( } else { const createPayload = { cwd: options.cwd, - mcpServers: [], + mcpServers: options.mcpServers ?? [], } satisfies EffectAcpSchema.NewSessionRequest; const created = yield* runLoggedRequest( "session/new", diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 378e4c6060b..e14a08b7952 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -37,6 +37,8 @@ import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/Provide import { TerminalManagerLive } from "./terminal/Layers/Manager.ts"; import { PreviewManagerLive } from "./preview/Layers/Manager.ts"; import { PreviewPortScannerLive } from "./preview/Layers/PortScanner.ts"; +import { McpHttpServerLive } from "./mcp/Layers/McpHttpServer.ts"; +import { McpSessionRegistryLive } from "./mcp/Layers/McpSessionRegistry.ts"; import * as ProcessRunner from "./processRunner.ts"; import * as GitManager from "./git/GitManager.ts"; import { KeybindingsLive } from "./keybindings.ts"; @@ -335,18 +337,21 @@ const RuntimeServicesLive = ServerRuntimeStartupLive.pipe( ); export const makeRoutesLayer = Layer.mergeAll( - HttpApiBuilder.layer(EnvironmentHttpApi).pipe( - Layer.provide(authHttpApiLayer), - Layer.provide(connectHttpApiLayer), - Layer.provide(orchestrationHttpApiLayer), - Layer.provide(serverEnvironmentHttpApiLayer), - Layer.provide(environmentAuthenticatedAuthLayer), + Layer.mergeAll( + HttpApiBuilder.layer(EnvironmentHttpApi).pipe( + Layer.provide(authHttpApiLayer), + Layer.provide(connectHttpApiLayer), + Layer.provide(orchestrationHttpApiLayer), + Layer.provide(serverEnvironmentHttpApiLayer), + Layer.provide(environmentAuthenticatedAuthLayer), + ), + attachmentsRouteLayer, + otlpTracesProxyRouteLayer, + projectFaviconRouteLayer, + staticAndDevRouteLayer, + websocketRpcRouteLayer, ), - attachmentsRouteLayer, - otlpTracesProxyRouteLayer, - projectFaviconRouteLayer, - staticAndDevRouteLayer, - websocketRpcRouteLayer, + McpHttpServerLive.pipe(Layer.provide(McpSessionRegistryLive)), ).pipe(Layer.provide(browserApiCorsLayer)); export const makeServerLayer = Layer.unwrap( diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index afd7f569d59..900e80dfa15 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -73,6 +73,7 @@ import { redactServerSettingsForClient, ServerSettingsService } from "./serverSe import { TerminalManager } from "./terminal/Services/Manager.ts"; import { PreviewManager } from "./preview/Services/Manager.ts"; import { PreviewPortScanner } from "./preview/Services/PortScanner.ts"; +import { previewAutomationBroker } from "./mcp/Layers/PreviewAutomationBroker.ts"; import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries.ts"; import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem.ts"; import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePaths.ts"; @@ -189,6 +190,10 @@ const RPC_REQUIRED_SCOPE = new Map<string, AuthEnvironmentScope>([ [WS_METHODS.previewClose, AuthOrchestrationOperateScope], [WS_METHODS.previewList, AuthOrchestrationReadScope], [WS_METHODS.previewReportStatus, AuthOrchestrationOperateScope], + [WS_METHODS.previewAutomationConnect, AuthOrchestrationOperateScope], + [WS_METHODS.previewAutomationRespond, AuthOrchestrationOperateScope], + [WS_METHODS.previewAutomationReportOwner, AuthOrchestrationOperateScope], + [WS_METHODS.previewAutomationClearOwner, AuthOrchestrationOperateScope], [WS_METHODS.subscribePreviewEvents, AuthOrchestrationReadScope], [WS_METHODS.subscribeDiscoveredLocalServers, AuthOrchestrationReadScope], [WS_METHODS.subscribeServerConfig, AuthOrchestrationReadScope], @@ -1387,6 +1392,30 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => observeRpcEffect(WS_METHODS.previewReportStatus, previewManager.reportStatus(input), { "rpc.aggregate": "preview", }), + [WS_METHODS.previewAutomationConnect]: (input) => + observeRpcStreamEffect( + WS_METHODS.previewAutomationConnect, + previewAutomationBroker.connect(input.clientId), + { "rpc.aggregate": "preview-automation" }, + ), + [WS_METHODS.previewAutomationRespond]: (input) => + observeRpcEffect( + WS_METHODS.previewAutomationRespond, + previewAutomationBroker.respond(input), + { "rpc.aggregate": "preview-automation" }, + ), + [WS_METHODS.previewAutomationReportOwner]: (input) => + observeRpcEffect( + WS_METHODS.previewAutomationReportOwner, + previewAutomationBroker.reportOwner(input), + { "rpc.aggregate": "preview-automation" }, + ), + [WS_METHODS.previewAutomationClearOwner]: (input) => + observeRpcEffect( + WS_METHODS.previewAutomationClearOwner, + previewAutomationBroker.clearOwner(input.clientId), + { "rpc.aggregate": "preview-automation" }, + ), [WS_METHODS.subscribePreviewEvents]: (_input) => observeRpcStream(WS_METHODS.subscribePreviewEvents, previewManager.events, { "rpc.aggregate": "preview", diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index f0e9ebb7e0c..e1f43bda46f 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -285,6 +285,12 @@ function createMockEnvironmentApi(input: { reportStatus: () => { throw new Error("Not implemented in browser test."); }, + automation: { + connect: () => () => undefined, + respond: () => Promise.resolve(), + reportOwner: () => Promise.resolve(), + clearOwner: () => Promise.resolve(), + }, onEvent: () => () => undefined, subscribePorts: () => () => undefined, } as EnvironmentApi["preview"], diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index e08563b234b..bbb59fd6bb8 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -12,6 +12,7 @@ import { type EnvironmentState, useStore } from "../store"; import { type Thread } from "../types"; import { + MAX_HIDDEN_MOUNTED_PREVIEW_THREADS, MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, buildExpiredTerminalContextToastCopy, createLocalDispatchSnapshot, @@ -19,6 +20,7 @@ import { getStartedThreadModelChangeBlockReason, hasServerAcknowledgedLocalDispatch, reconcileMountedTerminalThreadIds, + reconcileRetainedMountedThreadIds, resolveSendEnvMode, shouldWriteThreadErrorToCurrentServerThread, waitForStartedServerThread, @@ -274,6 +276,50 @@ describe("reconcileMountedTerminalThreadIds", () => { }); }); +describe("reconcileRetainedMountedThreadIds", () => { + it("retains hidden open threads and adds the active open thread", () => { + expect( + reconcileRetainedMountedThreadIds({ + currentThreadIds: [ThreadId.make("thread-hidden")], + openThreadIds: [ThreadId.make("thread-hidden")], + activeThreadId: ThreadId.make("thread-active"), + activeThreadOpen: true, + maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_PREVIEW_THREADS, + }), + ).toEqual([ThreadId.make("thread-hidden"), ThreadId.make("thread-active")]); + }); + + it("can retain the active thread as hidden when it is inactive", () => { + expect( + reconcileRetainedMountedThreadIds({ + currentThreadIds: [ThreadId.make("thread-active")], + openThreadIds: [ThreadId.make("thread-active")], + activeThreadId: ThreadId.make("thread-active"), + activeThreadOpen: false, + maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_PREVIEW_THREADS, + retainInactiveActiveThread: true, + }), + ).toEqual([ThreadId.make("thread-active")]); + }); + + it("evicts the oldest hidden threads beyond the configured cap", () => { + const currentThreadIds = Array.from( + { length: MAX_HIDDEN_MOUNTED_PREVIEW_THREADS + 2 }, + (_, index) => ThreadId.make(`thread-${index + 1}`), + ); + + expect( + reconcileRetainedMountedThreadIds({ + currentThreadIds, + openThreadIds: currentThreadIds, + activeThreadId: null, + activeThreadOpen: false, + maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_PREVIEW_THREADS, + }), + ).toEqual(currentThreadIds.slice(-MAX_HIDDEN_MOUNTED_PREVIEW_THREADS)); + }); +}); + describe("shouldWriteThreadErrorToCurrentServerThread", () => { it("routes errors to the active server thread when route and target match", () => { const threadId = ThreadId.make("thread-1"); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index e1417773c32..0012bee256b 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -22,6 +22,7 @@ import type { DraftThreadEnvMode } from "../composerDraftStore"; export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10; +export const MAX_HIDDEN_MOUNTED_PREVIEW_THREADS = 3; export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String); @@ -79,15 +80,31 @@ export function reconcileMountedTerminalThreadIds(input: { activeThreadId: string | null; activeThreadTerminalOpen: boolean; maxHiddenThreadCount?: number; +}): string[] { + return reconcileRetainedMountedThreadIds({ + currentThreadIds: input.currentThreadIds, + openThreadIds: input.openThreadIds, + activeThreadId: input.activeThreadId, + activeThreadOpen: input.activeThreadTerminalOpen, + maxHiddenThreadCount: input.maxHiddenThreadCount ?? MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, + }); +} + +export function reconcileRetainedMountedThreadIds(input: { + currentThreadIds: ReadonlyArray<string>; + openThreadIds: ReadonlyArray<string>; + activeThreadId: string | null; + activeThreadOpen: boolean; + maxHiddenThreadCount: number; + retainInactiveActiveThread?: boolean; }): string[] { const openThreadIdSet = new Set(input.openThreadIds); const hiddenThreadIds = input.currentThreadIds.filter( - (threadId) => threadId !== input.activeThreadId && openThreadIdSet.has(threadId), - ); - const maxHiddenThreadCount = Math.max( - 0, - input.maxHiddenThreadCount ?? MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, + (threadId) => + (threadId !== input.activeThreadId || input.retainInactiveActiveThread === true) && + openThreadIdSet.has(threadId), ); + const maxHiddenThreadCount = Math.max(0, input.maxHiddenThreadCount); const nextThreadIds = hiddenThreadIds.length > maxHiddenThreadCount ? hiddenThreadIds.slice(-maxHiddenThreadCount) @@ -95,7 +112,7 @@ export function reconcileMountedTerminalThreadIds(input: { if ( input.activeThreadId && - input.activeThreadTerminalOpen && + input.activeThreadOpen && !nextThreadIds.includes(input.activeThreadId) ) { nextThreadIds.push(input.activeThreadId); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index adc40747f74..4dd6d9003dd 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -99,7 +99,8 @@ import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; import { useMediaQuery } from "../hooks/useMediaQuery"; import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout"; import { selectActiveRightPanelKindWithUrl, useRightPanelStore } from "../rightPanelStore"; -import { isPreviewSupportedInRuntime } from "../previewStateStore"; +import { PreviewAutomationOwner } from "./preview/PreviewAutomationOwner"; +import { isPreviewSupportedInRuntime, usePreviewStateStore } from "../previewStateStore"; import { subscribePreviewAction } from "./preview/previewActionBus"; // Lazy: keeps the entire preview component graph (webview host, favicon // helper, Chromium error icon) out of the web bundle until first open. @@ -166,6 +167,7 @@ import { ProviderStatusBanner } from "./chat/ProviderStatusBanner"; import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack"; import { + MAX_HIDDEN_MOUNTED_PREVIEW_THREADS, MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, buildExpiredTerminalContextToastCopy, buildLocalDraftThread, @@ -182,6 +184,7 @@ import { deriveLockedProvider, readFileAsDataUrl, reconcileMountedTerminalThreadIds, + reconcileRetainedMountedThreadIds, resolveSendEnvMode, revokeBlobPreviewUrl, revokeUserMessagePreviewUrls, @@ -996,6 +999,22 @@ export default function ChatView(props: ChatViewProps) { }), [mountedTerminalThreadKeys], ); + const previewSessionThreadKeys = usePreviewStateStore( + useShallow((state) => + Object.entries(state.byThreadKey).flatMap(([nextThreadKey, nextPreviewState]) => + nextPreviewState.snapshot ? [nextThreadKey] : [], + ), + ), + ); + const [mountedPreviewThreadKeys, setMountedPreviewThreadKeys] = useState<string[]>([]); + const mountedPreviewThreadRefs = useMemo( + () => + mountedPreviewThreadKeys.flatMap((mountedThreadKey) => { + const mountedThreadRef = parseScopedThreadKey(mountedThreadKey); + return mountedThreadRef ? [{ key: mountedThreadKey, threadRef: mountedThreadRef }] : []; + }), + [mountedPreviewThreadKeys], + ); const fallbackDraftProjectRef = draftThread ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) @@ -1092,6 +1111,12 @@ export default function ChatView(props: ChatViewProps) { const existingThreadKeys = new Set<string>([...serverThreadKeys, ...draftThreadKeys]); return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey)); }, [draftThreadKeys, openTerminalThreadKeys, serverThreadKeys]); + const existingPreviewSessionThreadKeys = useMemo(() => { + const existingThreadKeys = new Set<string>([...serverThreadKeys, ...draftThreadKeys]); + return previewSessionThreadKeys.filter((nextThreadKey) => + existingThreadKeys.has(nextThreadKey), + ); + }, [draftThreadKeys, previewSessionThreadKeys, serverThreadKeys]); const activeLatestTurn = activeThread?.latestTurn ?? null; const threadPlanCatalog = useThreadPlanCatalog( useMemo(() => { @@ -1121,6 +1146,27 @@ export default function ChatView(props: ChatViewProps) { : nextThreadIds; }); }, [activeThreadKey, existingOpenTerminalThreadKeys, terminalUiState.terminalOpen]); + useEffect(() => { + setMountedPreviewThreadKeys((currentThreadIds) => { + const nextThreadIds = reconcileRetainedMountedThreadIds({ + currentThreadIds, + openThreadIds: existingPreviewSessionThreadKeys, + activeThreadId: activeThreadKey, + activeThreadOpen: Boolean(activeThreadKey && !shouldUsePlanSidebarSheet), + maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_PREVIEW_THREADS, + retainInactiveActiveThread: true, + }); + return currentThreadIds.length === nextThreadIds.length && + currentThreadIds.every((nextThreadId, index) => nextThreadId === nextThreadIds[index]) + ? currentThreadIds + : nextThreadIds; + }); + }, [ + activeThreadKey, + existingPreviewSessionThreadKeys, + previewPanelOpen, + shouldUsePlanSidebarSheet, + ]); const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); const activeProjectRef = activeThread ? scopeProjectRef(activeThread.environmentId, activeThread.projectId) @@ -4148,14 +4194,35 @@ export default function ChatView(props: ChatViewProps) { onClose={closePlanSidebar} /> ) : null} - {previewPanelOpen && !shouldUsePlanSidebarSheet && activeThreadRef ? ( - <Suspense fallback={null}> - <PreviewPanel mode="inline" threadRef={activeThreadRef} visible /> - </Suspense> - ) : null} + {!shouldUsePlanSidebarSheet + ? mountedPreviewThreadRefs.map( + ({ key: mountedThreadKey, threadRef: mountedThreadRef }) => { + const visible = previewPanelOpen && mountedThreadKey === activeThreadKey; + return ( + <div + key={mountedThreadKey} + className={cn( + visible + ? "contents" + : "pointer-events-none fixed -left-[10000px] top-0 h-px w-px overflow-hidden opacity-0", + )} + aria-hidden={visible ? undefined : true} + > + <Suspense fallback={null}> + <PreviewPanel mode="inline" threadRef={mountedThreadRef} visible={visible} /> + </Suspense> + </div> + ); + }, + ) + : null} </div> {/* end horizontal flex container */} + {activeThreadRef ? ( + <PreviewAutomationOwner threadRef={activeThreadRef} visible={previewPanelOpen} /> + ) : null} + {mountedTerminalThreadRefs.map(({ key: mountedThreadKey, threadRef: mountedThreadRef }) => ( <PersistentThreadTerminalDrawer key={mountedThreadKey} diff --git a/apps/web/src/components/preview/PreviewAutomationOwner.tsx b/apps/web/src/components/preview/PreviewAutomationOwner.tsx new file mode 100644 index 00000000000..f9dabb58759 --- /dev/null +++ b/apps/web/src/components/preview/PreviewAutomationOwner.tsx @@ -0,0 +1,287 @@ +"use client"; +// @effect-diagnostics cryptoRandomUUID:off + +import { scopedThreadKey } from "@t3tools/client-runtime"; +import type { + PreviewAutomationNavigateInput, + PreviewAutomationOpenInput, + PreviewAutomationRequest, + PreviewAutomationResponse, + PreviewAutomationStatus, + ScopedThreadRef, +} from "@t3tools/contracts"; +import { useCallback, useEffect, useRef } from "react"; + +import { ensureEnvironmentApi } from "~/environmentApi"; +import { selectThreadPreviewState, usePreviewStateStore } from "~/previewStateStore"; +import { useRightPanelStore } from "~/rightPanelStore"; + +import { previewBridge } from "./previewBridge"; + +const automationClientId = + typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" + ? crypto.randomUUID() + : `preview-${Math.random().toString(36).slice(2)}`; + +const waitForDesktopOverlay = async ( + threadRef: ScopedThreadRef, + timeoutMs: number, +): Promise<void> => { + const deadline = Date.now() + timeoutMs; + while (Date.now() <= deadline) { + const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, threadRef); + const tabId = state.snapshot?.tabId; + if (tabId && state.desktopOverlay && previewBridge) { + const status = await previewBridge.automation.status(tabId); + if (status.available) return; + } + await new Promise<void>((resolve) => window.setTimeout(resolve, 50)); + } + const error = new Error(`Preview webview did not register within ${timeoutMs}ms.`); + error.name = "PreviewAutomationTimeoutError"; + throw error; +}; + +const waitForNavigationReadiness = async ( + tabId: string, + readiness: PreviewAutomationNavigateInput["readiness"], + timeoutMs: number, +): Promise<void> => { + if (!previewBridge || readiness === "none") return; + const deadline = Date.now() + timeoutMs; + while (Date.now() <= deadline) { + if (readiness === "domContentLoaded") { + const readyState = await previewBridge.automation.evaluate(tabId, { + expression: "document.readyState", + }); + if (readyState === "interactive" || readyState === "complete") return; + } else { + const status = await previewBridge.automation.status(tabId); + if (!status.loading) return; + } + await new Promise<void>((resolve) => window.setTimeout(resolve, 50)); + } + const error = new Error(`Preview navigation did not become ready within ${timeoutMs}ms.`); + error.name = "PreviewAutomationTimeoutError"; + throw error; +}; + +const currentStatus = async ( + threadRef: ScopedThreadRef, + visible: boolean, +): Promise<PreviewAutomationStatus> => { + const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, threadRef); + const tabId = state.snapshot?.tabId ?? null; + if (tabId && previewBridge && state.desktopOverlay) { + const status = await previewBridge.automation.status(tabId); + return { ...status, visible }; + } + const navStatus = state.snapshot?.navStatus; + return { + available: Boolean(previewBridge?.automation), + visible, + tabId, + url: navStatus && navStatus._tag !== "Idle" ? navStatus.url : null, + title: navStatus && navStatus._tag !== "Idle" ? navStatus.title : null, + loading: navStatus?._tag === "Loading", + }; +}; + +const serializeError = (error: unknown): NonNullable<PreviewAutomationResponse["error"]> => { + if (error instanceof Error) { + const detail = + "detail" in error && (error as { detail?: unknown }).detail !== undefined + ? (error as { detail?: unknown }).detail + : undefined; + return { + _tag: error.name.startsWith("PreviewAutomation") + ? error.name + : "PreviewAutomationExecutionError", + message: error.message, + ...(detail === undefined ? {} : { detail }), + }; + } + return { + _tag: "PreviewAutomationExecutionError", + message: String(error), + }; +}; + +export function PreviewAutomationOwner(props: { + readonly threadRef: ScopedThreadRef; + readonly visible: boolean; +}) { + const { threadRef, visible } = props; + const ownerStateRef = useRef({ threadRef, visible }); + const handlerRef = useRef<(request: PreviewAutomationRequest) => Promise<unknown>>( + async () => undefined, + ); + ownerStateRef.current = { threadRef, visible }; + + const handleRequest = useCallback( + async (request: PreviewAutomationRequest): Promise<unknown> => { + if (request.threadId !== threadRef.threadId) { + const error = new Error("Preview automation request targeted a stale thread owner."); + error.name = "PreviewAutomationUnavailableError"; + throw error; + } + const api = ensureEnvironmentApi(threadRef.environmentId); + const state = selectThreadPreviewState( + usePreviewStateStore.getState().byThreadKey, + threadRef, + ); + const tabId = request.tabId ?? state.snapshot?.tabId ?? null; + switch (request.operation) { + case "status": + return currentStatus(threadRef, visible); + case "open": { + const input = request.input as PreviewAutomationOpenInput; + if (input.show ?? true) { + useRightPanelStore.getState().open(threadRef, "preview"); + } + let activeTabId = + (input.reuseExistingTab ?? true) ? (state.snapshot?.tabId ?? null) : null; + if (!activeTabId) { + const snapshot = await api.preview.open({ + threadId: threadRef.threadId, + ...(input.url ? { url: input.url } : {}), + }); + usePreviewStateStore.getState().applyServerSnapshot(threadRef, snapshot); + activeTabId = snapshot.tabId; + } else if (input.url && previewBridge) { + await previewBridge.navigate(activeTabId, input.url); + } + await waitForDesktopOverlay(threadRef, request.timeoutMs); + return currentStatus(threadRef, input.show ?? true); + } + case "navigate": { + if (!previewBridge || !tabId) throw new Error("Preview tab is not initialized."); + const input = request.input as PreviewAutomationNavigateInput; + await previewBridge.navigate(tabId, input.url); + await waitForNavigationReadiness( + tabId, + input.readiness ?? "load", + input.timeoutMs ?? request.timeoutMs, + ); + return currentStatus(threadRef, visible); + } + case "snapshot": + if (!previewBridge || !tabId) throw new Error("Preview tab is not initialized."); + return previewBridge.automation.snapshot(tabId); + case "click": + if (!previewBridge || !tabId) throw new Error("Preview tab is not initialized."); + return previewBridge.automation.click( + tabId, + request.input as Parameters<typeof previewBridge.automation.click>[1], + ); + case "type": + if (!previewBridge || !tabId) throw new Error("Preview tab is not initialized."); + return previewBridge.automation.type( + tabId, + request.input as Parameters<typeof previewBridge.automation.type>[1], + ); + case "press": + if (!previewBridge || !tabId) throw new Error("Preview tab is not initialized."); + return previewBridge.automation.press( + tabId, + request.input as Parameters<typeof previewBridge.automation.press>[1], + ); + case "scroll": + if (!previewBridge || !tabId) throw new Error("Preview tab is not initialized."); + return previewBridge.automation.scroll( + tabId, + request.input as Parameters<typeof previewBridge.automation.scroll>[1], + ); + case "evaluate": + if (!previewBridge || !tabId) throw new Error("Preview tab is not initialized."); + return previewBridge.automation.evaluate( + tabId, + request.input as Parameters<typeof previewBridge.automation.evaluate>[1], + ); + case "waitFor": + if (!previewBridge || !tabId) throw new Error("Preview tab is not initialized."); + return previewBridge.automation.waitFor( + tabId, + request.input as Parameters<typeof previewBridge.automation.waitFor>[1], + ); + } + }, + [threadRef, visible], + ); + handlerRef.current = handleRequest; + + useEffect(() => { + const api = ensureEnvironmentApi(threadRef.environmentId); + return api.preview.automation.connect( + { clientId: automationClientId }, + (request) => { + void handlerRef.current(request).then( + (result) => + api.preview.automation.respond({ + requestId: request.requestId, + ok: true, + ...(result === undefined ? {} : { result }), + }), + (error) => + api.preview.automation.respond({ + requestId: request.requestId, + ok: false, + error: serializeError(error), + }), + ); + }, + { + onResubscribe: () => { + const ownerState = ownerStateRef.current; + const state = selectThreadPreviewState( + usePreviewStateStore.getState().byThreadKey, + ownerState.threadRef, + ); + void api.preview.automation.reportOwner({ + clientId: automationClientId, + environmentId: ownerState.threadRef.environmentId, + threadId: ownerState.threadRef.threadId, + tabId: state.snapshot?.tabId ?? null, + visible: ownerState.visible, + supportsAutomation: Boolean(previewBridge?.automation), + focusedAt: new Date().toISOString(), + }); + }, + }, + ); + }, [threadRef.environmentId]); + + useEffect(() => { + const api = ensureEnvironmentApi(threadRef.environmentId); + const report = () => { + const state = selectThreadPreviewState( + usePreviewStateStore.getState().byThreadKey, + threadRef, + ); + void api.preview.automation.reportOwner({ + clientId: automationClientId, + environmentId: threadRef.environmentId, + threadId: threadRef.threadId, + tabId: state.snapshot?.tabId ?? null, + visible, + supportsAutomation: Boolean(previewBridge?.automation), + focusedAt: new Date().toISOString(), + }); + }; + report(); + window.addEventListener("focus", report); + const unsubscribe = usePreviewStateStore.subscribe((state, previous) => { + const key = scopedThreadKey(threadRef); + if (state.byThreadKey[key]?.snapshot?.tabId !== previous.byThreadKey[key]?.snapshot?.tabId) { + report(); + } + }); + return () => { + window.removeEventListener("focus", report); + unsubscribe(); + void api.preview.automation.clearOwner({ clientId: automationClientId }); + }; + }, [threadRef, visible]); + + return null; +} diff --git a/apps/web/src/environmentApi.ts b/apps/web/src/environmentApi.ts index 0268d98dcd3..2c2166d5085 100644 --- a/apps/web/src/environmentApi.ts +++ b/apps/web/src/environmentApi.ts @@ -65,6 +65,13 @@ export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi { close: (input) => rpcClient.preview.close(input as never), list: (input) => rpcClient.preview.list(input as never), reportStatus: (input) => rpcClient.preview.reportStatus(input as never), + automation: { + connect: (input, callback, options) => + rpcClient.preview.automation.connect(input as never, callback, options), + respond: (response) => rpcClient.preview.automation.respond(response as never), + reportOwner: (owner) => rpcClient.preview.automation.reportOwner(owner as never), + clearOwner: (input) => rpcClient.preview.automation.clearOwner(input as never), + }, onEvent: (callback) => rpcClient.preview.onEvent(callback), subscribePorts: (callback, options) => rpcClient.preview.subscribePorts(callback, options), }, diff --git a/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts b/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts index b1fe715f491..94292da1d96 100644 --- a/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts +++ b/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts @@ -111,6 +111,12 @@ vi.mock("@t3tools/client-runtime", async (importOriginal) => { close: vi.fn(), list: vi.fn(), reportStatus: vi.fn(), + automation: { + connect: vi.fn(() => () => undefined), + respond: vi.fn(), + reportOwner: vi.fn(), + clearOwner: vi.fn(), + }, onEvent: vi.fn(() => () => undefined), subscribePorts: vi.fn(() => () => undefined), }, diff --git a/packages/client-runtime/src/wsRpcClient.ts b/packages/client-runtime/src/wsRpcClient.ts index 733c65ee13e..ca01302cbe3 100644 --- a/packages/client-runtime/src/wsRpcClient.ts +++ b/packages/client-runtime/src/wsRpcClient.ts @@ -86,6 +86,12 @@ export interface WsRpcClient { readonly close: RpcUnaryMethod<typeof WS_METHODS.previewClose>; readonly list: RpcUnaryMethod<typeof WS_METHODS.previewList>; readonly reportStatus: RpcUnaryMethod<typeof WS_METHODS.previewReportStatus>; + readonly automation: { + readonly connect: RpcInputStreamMethod<typeof WS_METHODS.previewAutomationConnect>; + readonly respond: RpcUnaryMethod<typeof WS_METHODS.previewAutomationRespond>; + readonly reportOwner: RpcUnaryMethod<typeof WS_METHODS.previewAutomationReportOwner>; + readonly clearOwner: RpcUnaryMethod<typeof WS_METHODS.previewAutomationClearOwner>; + }; readonly onEvent: RpcStreamMethod<typeof WS_METHODS.subscribePreviewEvents>; readonly subscribePorts: RpcStreamMethod<typeof WS_METHODS.subscribeDiscoveredLocalServers>; }; @@ -230,6 +236,20 @@ export function createWsRpcClient( list: (input) => transport.request((client) => client[WS_METHODS.previewList](input)), reportStatus: (input) => transport.request((client) => client[WS_METHODS.previewReportStatus](input)), + automation: { + connect: (input, listener, options) => + transport.subscribe( + (client) => client[WS_METHODS.previewAutomationConnect](input), + listener, + subscriptionOptions(options, WS_METHODS.previewAutomationConnect), + ), + respond: (input) => + transport.request((client) => client[WS_METHODS.previewAutomationRespond](input)), + reportOwner: (input) => + transport.request((client) => client[WS_METHODS.previewAutomationReportOwner](input)), + clearOwner: (input) => + transport.request((client) => client[WS_METHODS.previewAutomationClearOwner](input)), + }, onEvent: (listener, options) => transport.subscribe( (client) => client[WS_METHODS.subscribePreviewEvents]({}), diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 1cb46f6c79d..4ffb7642d17 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -23,4 +23,5 @@ export * from "./project.ts"; export * from "./filesystem.ts"; export * from "./review.ts"; export * from "./preview.ts"; +export * from "./previewAutomation.ts"; export * from "./rpc.ts"; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 161aaf69a6b..bb0a1bd1eb5 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -66,6 +66,19 @@ import type { PreviewReportStatusInput, PreviewSessionSnapshot, } from "./preview.ts"; +import type { + PreviewAutomationClickInput, + PreviewAutomationEvaluateInput, + PreviewAutomationOwner, + PreviewAutomationPressInput, + PreviewAutomationRequest, + PreviewAutomationResponse, + PreviewAutomationScrollInput, + PreviewAutomationSnapshot, + PreviewAutomationStatus, + PreviewAutomationTypeInput, + PreviewAutomationWaitForInput, +} from "./previewAutomation.ts"; import type { ClientOrchestrationCommand, OrchestrationGetFullThreadDiffInput, @@ -668,6 +681,16 @@ export interface DesktopPreviewBridge { pickElement: (tabId: string) => Promise<PreviewAnnotationPayload | null>; /** Cancel an in-flight preview annotation session. */ cancelPickElement: (tabId: string) => Promise<void>; + automation: { + status: (tabId: string) => Promise<PreviewAutomationStatus>; + snapshot: (tabId: string) => Promise<PreviewAutomationSnapshot>; + click: (tabId: string, input: PreviewAutomationClickInput) => Promise<void>; + type: (tabId: string, input: PreviewAutomationTypeInput) => Promise<void>; + press: (tabId: string, input: PreviewAutomationPressInput) => Promise<void>; + scroll: (tabId: string, input: PreviewAutomationScrollInput) => Promise<void>; + evaluate: (tabId: string, input: PreviewAutomationEvaluateInput) => Promise<unknown>; + waitFor: (tabId: string, input: PreviewAutomationWaitForInput) => Promise<void>; + }; onStateChange: (listener: (tabId: string, state: DesktopPreviewTabState) => void) => () => void; } @@ -835,6 +858,16 @@ export interface EnvironmentApi { close: (input: typeof PreviewCloseInput.Encoded) => Promise<void>; list: (input: typeof PreviewListInput.Encoded) => Promise<PreviewListResult>; reportStatus: (input: typeof PreviewReportStatusInput.Encoded) => Promise<void>; + automation: { + connect: ( + input: { clientId: string }, + callback: (request: PreviewAutomationRequest) => void, + options?: { onResubscribe?: () => void }, + ) => () => void; + respond: (response: PreviewAutomationResponse) => Promise<void>; + reportOwner: (owner: PreviewAutomationOwner) => Promise<void>; + clearOwner: (input: { clientId: string }) => Promise<void>; + }; onEvent: (callback: (event: PreviewEvent) => void) => () => void; subscribePorts: ( callback: (servers: DiscoveredLocalServerList) => void, diff --git a/packages/contracts/src/previewAutomation.ts b/packages/contracts/src/previewAutomation.ts new file mode 100644 index 00000000000..eaadbcb48c9 --- /dev/null +++ b/packages/contracts/src/previewAutomation.ts @@ -0,0 +1,219 @@ +import { Schema } from "effect"; + +import { EnvironmentId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { PreviewTabId } from "./preview.ts"; + +const BoundedUrl = TrimmedNonEmptyString.check(Schema.isMaxLength(2048)); +const OptionalTimeoutMs = Schema.optional( + Schema.Int.check(Schema.isGreaterThan(0)).check(Schema.isLessThanOrEqualTo(60_000)), +); + +export const PreviewAutomationOperation = Schema.Literals([ + "status", + "open", + "navigate", + "snapshot", + "click", + "type", + "press", + "scroll", + "evaluate", + "waitFor", +]); +export type PreviewAutomationOperation = typeof PreviewAutomationOperation.Type; + +export const PreviewAutomationStatus = Schema.Struct({ + available: Schema.Boolean, + visible: Schema.Boolean, + tabId: Schema.NullOr(PreviewTabId), + url: Schema.NullOr(Schema.String), + title: Schema.NullOr(Schema.String), + loading: Schema.Boolean, +}); +export type PreviewAutomationStatus = typeof PreviewAutomationStatus.Type; + +export const PreviewAutomationOpenInput = Schema.Struct({ + url: Schema.optional(BoundedUrl), + show: Schema.optional(Schema.Boolean), + reuseExistingTab: Schema.optional(Schema.Boolean), +}); +export type PreviewAutomationOpenInput = typeof PreviewAutomationOpenInput.Type; + +export const PreviewAutomationNavigateInput = Schema.Struct({ + url: BoundedUrl, + readiness: Schema.optional(Schema.Literals(["load", "domContentLoaded", "none"])), + timeoutMs: OptionalTimeoutMs, +}); +export type PreviewAutomationNavigateInput = typeof PreviewAutomationNavigateInput.Type; + +export const PreviewAutomationClickInput = Schema.Union([ + Schema.Struct({ + selector: TrimmedNonEmptyString, + timeoutMs: OptionalTimeoutMs, + }), + Schema.Struct({ + x: Schema.Number, + y: Schema.Number, + timeoutMs: OptionalTimeoutMs, + }), +]); +export type PreviewAutomationClickInput = typeof PreviewAutomationClickInput.Type; + +export const PreviewAutomationTypeInput = Schema.Struct({ + text: Schema.String, + selector: Schema.optional(TrimmedNonEmptyString), + clear: Schema.optional(Schema.Boolean), + timeoutMs: OptionalTimeoutMs, +}); +export type PreviewAutomationTypeInput = typeof PreviewAutomationTypeInput.Type; + +export const PreviewAutomationPressInput = Schema.Struct({ + key: TrimmedNonEmptyString, + modifiers: Schema.optional(Schema.Array(Schema.Literals(["Alt", "Control", "Meta", "Shift"]))), +}); +export type PreviewAutomationPressInput = typeof PreviewAutomationPressInput.Type; + +export const PreviewAutomationScrollInput = Schema.Struct({ + deltaX: Schema.optional(Schema.Number), + deltaY: Schema.optional(Schema.Number), + selector: Schema.optional(TrimmedNonEmptyString), +}); +export type PreviewAutomationScrollInput = typeof PreviewAutomationScrollInput.Type; + +export const PreviewAutomationEvaluateInput = Schema.Struct({ + expression: TrimmedNonEmptyString.check(Schema.isMaxLength(64_000)), + awaitPromise: Schema.optional(Schema.Boolean), + returnByValue: Schema.optional(Schema.Boolean), +}); +export type PreviewAutomationEvaluateInput = typeof PreviewAutomationEvaluateInput.Type; + +export const PreviewAutomationWaitForInput = Schema.Struct({ + selector: Schema.optional(TrimmedNonEmptyString), + text: Schema.optional(TrimmedNonEmptyString), + urlIncludes: Schema.optional(TrimmedNonEmptyString), + timeoutMs: OptionalTimeoutMs, +}); +export type PreviewAutomationWaitForInput = typeof PreviewAutomationWaitForInput.Type; + +export const PreviewAutomationElement = Schema.Struct({ + tag: Schema.String, + role: Schema.NullOr(Schema.String), + name: Schema.String, + selector: Schema.String, + x: Schema.Number, + y: Schema.Number, + width: Schema.Number, + height: Schema.Number, +}); +export type PreviewAutomationElement = typeof PreviewAutomationElement.Type; + +export const PreviewAutomationSnapshot = Schema.Struct({ + url: Schema.String, + title: Schema.String, + loading: Schema.Boolean, + visibleText: Schema.String, + interactiveElements: Schema.Array(PreviewAutomationElement), + accessibilityTree: Schema.Unknown, + screenshot: Schema.Struct({ + mimeType: Schema.Literal("image/png"), + data: Schema.String, + width: Schema.Int, + height: Schema.Int, + }), +}); +export type PreviewAutomationSnapshot = typeof PreviewAutomationSnapshot.Type; + +export const PreviewAutomationOwner = Schema.Struct({ + clientId: TrimmedNonEmptyString, + environmentId: EnvironmentId, + threadId: ThreadId, + tabId: Schema.NullOr(PreviewTabId), + visible: Schema.Boolean, + supportsAutomation: Schema.Boolean, + focusedAt: Schema.String, +}); +export type PreviewAutomationOwner = typeof PreviewAutomationOwner.Type; + +export const PreviewAutomationRequest = Schema.Struct({ + requestId: TrimmedNonEmptyString, + threadId: ThreadId, + tabId: Schema.optional(PreviewTabId), + operation: PreviewAutomationOperation, + input: Schema.Unknown, + timeoutMs: Schema.Int.check(Schema.isGreaterThan(0)), +}); +export type PreviewAutomationRequest = typeof PreviewAutomationRequest.Type; + +export const PreviewAutomationResponse = Schema.Struct({ + requestId: TrimmedNonEmptyString, + ok: Schema.Boolean, + result: Schema.optional(Schema.Unknown), + error: Schema.optional( + Schema.Struct({ + _tag: TrimmedNonEmptyString, + message: Schema.String, + detail: Schema.optional(Schema.Unknown), + }), + ), +}); +export type PreviewAutomationResponse = typeof PreviewAutomationResponse.Type; + +export class PreviewAutomationUnavailableError extends Schema.TaggedErrorClass<PreviewAutomationUnavailableError>()( + "PreviewAutomationUnavailableError", + { message: Schema.String }, +) {} + +export class PreviewAutomationNoFocusedOwnerError extends Schema.TaggedErrorClass<PreviewAutomationNoFocusedOwnerError>()( + "PreviewAutomationNoFocusedOwnerError", + { message: Schema.String }, +) {} + +export class PreviewAutomationUnsupportedClientError extends Schema.TaggedErrorClass<PreviewAutomationUnsupportedClientError>()( + "PreviewAutomationUnsupportedClientError", + { message: Schema.String }, +) {} + +export class PreviewAutomationTabNotFoundError extends Schema.TaggedErrorClass<PreviewAutomationTabNotFoundError>()( + "PreviewAutomationTabNotFoundError", + { message: Schema.String }, +) {} + +export class PreviewAutomationTimeoutError extends Schema.TaggedErrorClass<PreviewAutomationTimeoutError>()( + "PreviewAutomationTimeoutError", + { message: Schema.String }, +) {} + +export class PreviewAutomationExecutionError extends Schema.TaggedErrorClass<PreviewAutomationExecutionError>()( + "PreviewAutomationExecutionError", + { message: Schema.String, detail: Schema.optional(Schema.Unknown) }, +) {} + +export class PreviewAutomationInvalidSelectorError extends Schema.TaggedErrorClass<PreviewAutomationInvalidSelectorError>()( + "PreviewAutomationInvalidSelectorError", + { message: Schema.String, selector: Schema.String }, +) {} + +export class PreviewAutomationResultTooLargeError extends Schema.TaggedErrorClass<PreviewAutomationResultTooLargeError>()( + "PreviewAutomationResultTooLargeError", + { message: Schema.String, maximumBytes: Schema.Int }, +) {} + +export const PreviewAutomationError = Schema.Union([ + PreviewAutomationUnavailableError, + PreviewAutomationNoFocusedOwnerError, + PreviewAutomationUnsupportedClientError, + PreviewAutomationTabNotFoundError, + PreviewAutomationTimeoutError, + PreviewAutomationExecutionError, + PreviewAutomationInvalidSelectorError, + PreviewAutomationResultTooLargeError, +]); +export type PreviewAutomationError = typeof PreviewAutomationError.Type; + +export const PreviewUrlResolution = Schema.Struct({ + requestedUrl: Schema.String, + resolvedUrl: Schema.String, + resolutionKind: Schema.Literal("direct"), + environmentId: EnvironmentId, +}); +export type PreviewUrlResolution = typeof PreviewUrlResolution.Type; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index e3d462bb82a..189b1bdb679 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -98,6 +98,12 @@ import { PreviewReportStatusInput, PreviewSessionSnapshot, } from "./preview.ts"; +import { + PreviewAutomationError, + PreviewAutomationOwner, + PreviewAutomationRequest, + PreviewAutomationResponse, +} from "./previewAutomation.ts"; import { ServerConfigStreamEvent, ServerConfig, @@ -177,6 +183,10 @@ export const WS_METHODS = { previewClose: "preview.close", previewList: "preview.list", previewReportStatus: "preview.reportStatus", + previewAutomationConnect: "previewAutomation.connect", + previewAutomationRespond: "previewAutomation.respond", + previewAutomationReportOwner: "previewAutomation.reportOwner", + previewAutomationClearOwner: "previewAutomation.clearOwner", // Server meta serverGetConfig: "server.getConfig", @@ -510,6 +520,28 @@ export const WsPreviewReportStatusRpc = Rpc.make(WS_METHODS.previewReportStatus, error: Schema.Union([PreviewError, EnvironmentAuthorizationError]), }); +export const WsPreviewAutomationConnectRpc = Rpc.make(WS_METHODS.previewAutomationConnect, { + payload: Schema.Struct({ clientId: Schema.String }), + success: PreviewAutomationRequest, + error: Schema.Union([PreviewAutomationError, EnvironmentAuthorizationError]), + stream: true, +}); + +export const WsPreviewAutomationRespondRpc = Rpc.make(WS_METHODS.previewAutomationRespond, { + payload: PreviewAutomationResponse, + error: Schema.Union([PreviewAutomationError, EnvironmentAuthorizationError]), +}); + +export const WsPreviewAutomationReportOwnerRpc = Rpc.make(WS_METHODS.previewAutomationReportOwner, { + payload: PreviewAutomationOwner, + error: Schema.Union([PreviewAutomationError, EnvironmentAuthorizationError]), +}); + +export const WsPreviewAutomationClearOwnerRpc = Rpc.make(WS_METHODS.previewAutomationClearOwner, { + payload: Schema.Struct({ clientId: Schema.String }), + error: Schema.Union([PreviewAutomationError, EnvironmentAuthorizationError]), +}); + export const WsSubscribePreviewEventsRpc = Rpc.make(WS_METHODS.subscribePreviewEvents, { payload: Schema.Struct({}), success: PreviewEvent, @@ -668,6 +700,10 @@ export const WsRpcGroup = RpcGroup.make( WsPreviewCloseRpc, WsPreviewListRpc, WsPreviewReportStatusRpc, + WsPreviewAutomationConnectRpc, + WsPreviewAutomationRespondRpc, + WsPreviewAutomationReportOwnerRpc, + WsPreviewAutomationClearOwnerRpc, WsSubscribePreviewEventsRpc, WsSubscribeDiscoveredLocalServersRpc, WsSubscribeServerConfigRpc, diff --git a/vite.config.ts b/vite.config.ts index 1ce94c68754..d235efd60be 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,7 +1,13 @@ import "vite-plus/test/config"; import { defineConfig } from "vite-plus"; +import { fileURLToPath } from "node:url"; export default defineConfig({ + resolve: { + alias: { + "~": fileURLToPath(new URL("./apps/web/src", import.meta.url)), + }, + }, test: { environment: "node", exclude: [ From d58f7bf96fdf1ae6022ba4fa1e79f5a41228a69c Mon Sep 17 00:00:00 2001 From: Julius Marminge <julius0216@outlook.com> Date: Fri, 12 Jun 2026 13:29:48 -0700 Subject: [PATCH 10/25] Refine collaborative browser preview Co-authored-by: codex <codex@users.noreply.github.com> --- apps/desktop/package.json | 1 + apps/desktop/src/ipc/channels.ts | 5 + apps/desktop/src/ipc/methods/preview.ts | 37 +- .../src/playwright-injected-runtime.test.ts | 20 + .../src/playwright-injected-runtime.ts | 49 + apps/desktop/src/preload.ts | 32 +- apps/desktop/src/preview-pick-preload.ts | 8 + apps/desktop/src/preview-view-manager.test.ts | 58 ++ apps/desktop/src/preview-view-manager.ts | 524 ++++++++-- apps/desktop/src/window/DesktopWindow.ts | 5 +- .../src/mcp/Layers/McpHttpServer.test.ts | 18 + .../Layers/PreviewAutomationBroker.test.ts | 24 + .../src/mcp/Layers/PreviewAutomationBroker.ts | 12 +- .../src/mcp/toolkits/preview/handlers.ts | 4 + .../src/mcp/toolkits/preview/tools.test.ts | 37 + apps/server/src/mcp/toolkits/preview/tools.ts | 75 +- .../provider/CodexDeveloperInstructions.ts | 13 + .../src/provider/Layers/CodexAdapter.test.ts | 58 ++ .../src/provider/Layers/CodexAdapter.ts | 7 +- .../Layers/CodexSessionRuntime.test.ts | 26 + .../provider/Layers/CodexSessionRuntime.ts | 13 + apps/web/src/browser/BrowserSurfaceSlot.tsx | 45 + apps/web/src/browser/ElectronBrowserHost.tsx | 44 + apps/web/src/browser/HostedBrowserWebview.tsx | 116 +++ apps/web/src/browser/browserRecording.ts | 115 +++ apps/web/src/browser/browserSurfaceStore.ts | 53 + .../src/browser/browserTargetResolver.test.ts | 81 ++ apps/web/src/browser/browserTargetResolver.ts | 81 ++ apps/web/src/browser/desktopTabLifetime.ts | 30 + apps/web/src/components/ChatView.tsx | 944 ++++++++++-------- apps/web/src/components/DiffPanelShell.tsx | 6 +- apps/web/src/components/PlanSidebar.tsx | 2 +- apps/web/src/components/RightPanelTabs.tsx | 148 +++ .../src/components/ThreadTerminalDrawer.tsx | 50 +- apps/web/src/components/chat/ChatHeader.tsx | 96 +- .../src/components/chat/MessagesTimeline.tsx | 23 +- .../preview/PreviewAutomationOwner.tsx | 50 +- .../components/preview/PreviewChromeRow.tsx | 51 +- .../components/preview/PreviewEmptyState.tsx | 32 +- .../preview/PreviewLocalServerCard.tsx | 12 +- .../components/preview/PreviewMoreMenu.tsx | 2 - .../src/components/preview/PreviewPanel.tsx | 12 +- .../components/preview/PreviewPanelShell.tsx | 6 +- .../src/components/preview/PreviewView.tsx | 105 +- .../src/components/preview/PreviewWebview.tsx | 142 --- .../preview/useDiscoveredLocalServers.ts | 8 +- .../components/preview/usePreviewBridge.ts | 19 +- .../components/preview/usePreviewSession.ts | 2 +- apps/web/src/main.tsx | 8 +- apps/web/src/previewStateStore.test.ts | 24 +- apps/web/src/previewStateStore.ts | 128 ++- apps/web/src/rightPanelStore.test.ts | 43 +- apps/web/src/rightPanelStore.ts | 194 +++- .../routes/_chat.$environmentId.$threadId.tsx | 244 +---- apps/web/src/session-logic.test.ts | 61 ++ apps/web/src/session-logic.ts | 9 + packages/contracts/src/ipc.ts | 40 +- packages/contracts/src/previewAutomation.ts | 367 ++++++- pnpm-lock.yaml | 3 + 59 files changed, 3222 insertions(+), 1200 deletions(-) create mode 100644 apps/desktop/src/playwright-injected-runtime.test.ts create mode 100644 apps/desktop/src/playwright-injected-runtime.ts create mode 100644 apps/server/src/mcp/toolkits/preview/tools.test.ts create mode 100644 apps/web/src/browser/BrowserSurfaceSlot.tsx create mode 100644 apps/web/src/browser/ElectronBrowserHost.tsx create mode 100644 apps/web/src/browser/HostedBrowserWebview.tsx create mode 100644 apps/web/src/browser/browserRecording.ts create mode 100644 apps/web/src/browser/browserSurfaceStore.ts create mode 100644 apps/web/src/browser/browserTargetResolver.test.ts create mode 100644 apps/web/src/browser/browserTargetResolver.ts create mode 100644 apps/web/src/browser/desktopTabLifetime.ts create mode 100644 apps/web/src/components/RightPanelTabs.tsx delete mode 100644 apps/web/src/components/preview/PreviewWebview.tsx diff --git a/apps/desktop/package.json b/apps/desktop/package.json index f26f2cd1ae4..0d18b4c71f5 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -21,6 +21,7 @@ "effect": "catalog:", "electron": "41.5.0", "electron-updater": "^6.6.2", + "playwright-core": "1.60.0", "react-grab": "^0.1.32" }, "devDependencies": { diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 11832664d88..fe7b5b6f839 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -56,6 +56,7 @@ export const PREVIEW_CLEAR_CACHE_CHANNEL = "desktop:preview-clear-cache"; export const PREVIEW_GET_CONFIG_CHANNEL = "desktop:preview-get-config"; export const PREVIEW_PICK_ELEMENT_CHANNEL = "desktop:preview-pick-element"; export const PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL = "desktop:preview-cancel-pick-element"; +export const PREVIEW_CAPTURE_SCREENSHOT_CHANNEL = "desktop:preview-capture-screenshot"; export const PREVIEW_AUTOMATION_STATUS_CHANNEL = "desktop:preview-automation-status"; export const PREVIEW_AUTOMATION_SNAPSHOT_CHANNEL = "desktop:preview-automation-snapshot"; export const PREVIEW_AUTOMATION_CLICK_CHANNEL = "desktop:preview-automation-click"; @@ -64,4 +65,8 @@ export const PREVIEW_AUTOMATION_PRESS_CHANNEL = "desktop:preview-automation-pres export const PREVIEW_AUTOMATION_SCROLL_CHANNEL = "desktop:preview-automation-scroll"; export const PREVIEW_AUTOMATION_EVALUATE_CHANNEL = "desktop:preview-automation-evaluate"; export const PREVIEW_AUTOMATION_WAIT_FOR_CHANNEL = "desktop:preview-automation-wait-for"; +export const PREVIEW_RECORDING_START_CHANNEL = "desktop:preview-recording-start"; +export const PREVIEW_RECORDING_STOP_CHANNEL = "desktop:preview-recording-stop"; +export const PREVIEW_RECORDING_SAVE_CHANNEL = "desktop:preview-recording-save"; +export const PREVIEW_RECORDING_FRAME_CHANNEL = "desktop:preview-recording-frame"; export const PREVIEW_STATE_CHANGE_CHANNEL = "desktop:preview-state-change"; diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index 904cef30b41..8683cd9d0b6 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -17,6 +17,14 @@ previewViewManager.onStateChange((tabId, state) => { } }); +previewViewManager.onRecordingFrame((frame) => { + for (const window of BrowserWindow.getAllWindows()) { + if (!window.isDestroyed()) { + window.webContents.send(IpcChannels.PREVIEW_RECORDING_FRAME_CHANNEL, frame); + } + } +}); + const tabIdFrom = (raw: unknown): string => { if (typeof raw !== "object" || raw === null || !("tabId" in raw)) { throw new Error("preview tab id is required"); @@ -95,10 +103,16 @@ export const previewMethods = [ ), method(IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL, () => previewViewManager.clearCookies()), method(IpcChannels.PREVIEW_CLEAR_CACHE_CHANNEL, () => previewViewManager.clearCache()), - method(IpcChannels.PREVIEW_GET_CONFIG_CHANNEL, () => { + method(IpcChannels.PREVIEW_GET_CONFIG_CHANNEL, (raw) => { + const environmentId = + typeof raw === "object" && raw !== null && "environmentId" in raw ? raw.environmentId : null; + if (typeof environmentId !== "string" || environmentId.length === 0) { + throw new Error("preview environment id is required"); + } + previewViewManager.getBrowserSession(environmentId); const preloadPath = `${__dirname}/preview-pick-preload.cjs`; return { - partition: previewViewManager.getBrowserPartition(), + partition: previewViewManager.getBrowserPartition(environmentId), webPreferences: PREVIEW_WEBVIEW_PREFERENCES, preloadUrl: pathToFileURL(preloadPath).href, }; @@ -109,6 +123,9 @@ export const previewMethods = [ method(IpcChannels.PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL, (raw) => previewViewManager.cancelPickElement(tabIdFrom(raw)), ), + method(IpcChannels.PREVIEW_CAPTURE_SCREENSHOT_CHANNEL, (raw) => + previewViewManager.captureScreenshot(tabIdFrom(raw)), + ), method(IpcChannels.PREVIEW_AUTOMATION_STATUS_CHANNEL, (raw) => previewViewManager.automationStatus(tabIdFrom(raw)), ), @@ -151,4 +168,20 @@ export const previewMethods = [ inputFrom(raw) as Parameters<typeof previewViewManager.automationWaitFor>[1], ), ), + method(IpcChannels.PREVIEW_RECORDING_START_CHANNEL, (raw) => + previewViewManager.startRecording(tabIdFrom(raw)), + ), + method(IpcChannels.PREVIEW_RECORDING_STOP_CHANNEL, (raw) => + previewViewManager.stopRecording(tabIdFrom(raw)), + ), + method(IpcChannels.PREVIEW_RECORDING_SAVE_CHANNEL, (raw) => { + const tabId = tabIdFrom(raw); + if (typeof raw !== "object" || raw === null) throw new Error("recording payload is required"); + const mimeType = "mimeType" in raw ? raw.mimeType : null; + const data = "data" in raw ? raw.data : null; + if (typeof mimeType !== "string" || !(data instanceof Uint8Array)) { + throw new Error("recording mimeType and bytes are required"); + } + return previewViewManager.saveRecording(tabId, mimeType, data); + }), ] as const; diff --git a/apps/desktop/src/playwright-injected-runtime.test.ts b/apps/desktop/src/playwright-injected-runtime.test.ts new file mode 100644 index 00000000000..20692063672 --- /dev/null +++ b/apps/desktop/src/playwright-injected-runtime.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + playwrightInjectedRuntimeInstallExpression, + playwrightInjectedRuntimeSource, +} from "./playwright-injected-runtime.ts"; + +describe("playwright injected runtime", () => { + it("extracts the pinned runtime from playwright-core", async () => { + const source = await playwrightInjectedRuntimeSource(); + expect(source.length).toBeGreaterThan(100_000); + expect(source).toContain("InjectedScript"); + }); + + it("builds an idempotent install expression", async () => { + const expression = await playwrightInjectedRuntimeInstallExpression(); + expect(expression).toContain("__t3PlaywrightInjected"); + expect(expression).toContain('testIdAttributeName":"data-testid'); + }); +}); diff --git a/apps/desktop/src/playwright-injected-runtime.ts b/apps/desktop/src/playwright-injected-runtime.ts new file mode 100644 index 00000000000..d4d7c6bd940 --- /dev/null +++ b/apps/desktop/src/playwright-injected-runtime.ts @@ -0,0 +1,49 @@ +// @effect-diagnostics nodeBuiltinImport:off +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; +import { readFile } from "node:fs/promises"; +import { runInNewContext } from "node:vm"; + +const require = createRequire(import.meta.url); +let sourcePromise: Promise<string> | null = null; + +export const playwrightInjectedRuntimeSource = (): Promise<string> => { + sourcePromise ??= (async () => { + const packageJsonPath = require.resolve("playwright-core/package.json"); + const coreBundle = await readFile(join(dirname(packageJsonPath), "lib/coreBundle.js"), "utf8"); + const marker = "source3 = "; + const start = coreBundle.indexOf(marker); + if (start < 0) throw new Error("Playwright injected runtime marker was not found."); + const literalStart = start + marker.length; + const literalEnd = coreBundle.indexOf(";\n }\n});", literalStart); + if (literalEnd < 0) throw new Error("Playwright injected runtime terminator was not found."); + const literal = coreBundle.slice(literalStart, literalEnd); + const source = runInNewContext(literal, Object.create(null), { timeout: 1_000 }); + if (typeof source !== "string" || source.length < 100_000) { + throw new Error("Playwright injected runtime extraction returned invalid source."); + } + return source; + })(); + return sourcePromise; +}; + +export const playwrightInjectedRuntimeInstallExpression = async (): Promise<string> => { + const source = await playwrightInjectedRuntimeSource(); + const options = { + isUnderTest: false, + sdkLanguage: "javascript", + testIdAttributeName: "data-testid", + stableRafCount: 1, + browserName: "chromium", + shouldPrependErrorPrefix: false, + isUtilityWorld: false, + customEngines: [], + }; + return `(() => { + if (globalThis.__t3PlaywrightInjected) return true; + const module = { exports: {} }; + ${source} + globalThis.__t3PlaywrightInjected = new (module.exports.InjectedScript())(globalThis, ${JSON.stringify(options)}); + return true; + })()`; +}; diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 8f890c27e78..8326673a862 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -1,4 +1,8 @@ -import type { DesktopBridge, DesktopPreviewTabState } from "@t3tools/contracts"; +import type { + DesktopBridge, + DesktopPreviewRecordingFrame, + DesktopPreviewTabState, +} from "@t3tools/contracts"; import { contextBridge, ipcRenderer } from "electron"; import * as IpcChannels from "./ipc/channels.ts"; @@ -159,10 +163,34 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.invoke(IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, { tabId }), clearCookies: () => ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL), clearCache: () => ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_CACHE_CHANNEL), - getPreviewConfig: () => ipcRenderer.invoke(IpcChannels.PREVIEW_GET_CONFIG_CHANNEL), + getPreviewConfig: (environmentId) => + ipcRenderer.invoke(IpcChannels.PREVIEW_GET_CONFIG_CHANNEL, { environmentId }), pickElement: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL, { tabId }), cancelPickElement: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL, { tabId }), + captureScreenshot: (tabId) => + ipcRenderer.invoke(IpcChannels.PREVIEW_CAPTURE_SCREENSHOT_CHANNEL, { tabId }), + recording: { + startScreencast: (tabId) => + ipcRenderer.invoke(IpcChannels.PREVIEW_RECORDING_START_CHANNEL, { tabId }), + stopScreencast: (tabId) => + ipcRenderer.invoke(IpcChannels.PREVIEW_RECORDING_STOP_CHANNEL, { tabId }), + save: (tabId, mimeType, data) => + ipcRenderer.invoke(IpcChannels.PREVIEW_RECORDING_SAVE_CHANNEL, { + tabId, + mimeType, + data, + }), + onFrame: (listener) => { + const wrappedListener = (_event: Electron.IpcRendererEvent, frame: unknown) => { + if (typeof frame !== "object" || frame === null) return; + listener(frame as DesktopPreviewRecordingFrame); + }; + ipcRenderer.on(IpcChannels.PREVIEW_RECORDING_FRAME_CHANNEL, wrappedListener); + return () => + ipcRenderer.removeListener(IpcChannels.PREVIEW_RECORDING_FRAME_CHANNEL, wrappedListener); + }, + }, automation: { status: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_STATUS_CHANNEL, { tabId }), diff --git a/apps/desktop/src/preview-pick-preload.ts b/apps/desktop/src/preview-pick-preload.ts index be4df208ed8..7ca1da41b59 100644 --- a/apps/desktop/src/preview-pick-preload.ts +++ b/apps/desktop/src/preview-pick-preload.ts @@ -16,6 +16,7 @@ const START_PICK_CHANNEL = "preview:start-pick"; const CANCEL_PICK_CHANNEL = "preview:cancel-pick"; const ELEMENT_PICKED_CHANNEL = "preview:element-picked"; const ANNOTATION_CAPTURED_CHANNEL = "preview:annotation-captured"; +const HUMAN_INPUT_CHANNEL = "preview:human-input"; const OVERLAY_ATTRIBUTE = "data-t3code-annotation-ui"; const Z_INDEX_OVERLAY = 2147483646; const ACCENT = "#7c3aed"; @@ -42,6 +43,13 @@ interface AnnotationSession { let activeSession: AnnotationSession | null = null; let idSequence = 0; +const reportHumanInput = (event: Event): void => { + if (event.isTrusted) ipcRenderer.send(HUMAN_INPUT_CHANNEL); +}; + +window.addEventListener("pointerdown", reportHumanInput, true); +window.addEventListener("keydown", reportHumanInput, true); + const nextId = (prefix: string): string => { idSequence += 1; return `${prefix}_${idSequence.toString(36)}`; diff --git a/apps/desktop/src/preview-view-manager.test.ts b/apps/desktop/src/preview-view-manager.test.ts index 54d33a85699..c704ae6630c 100644 --- a/apps/desktop/src/preview-view-manager.test.ts +++ b/apps/desktop/src/preview-view-manager.test.ts @@ -1,8 +1,15 @@ import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; const fromId = vi.fn(() => null); +const mkdir = vi.fn(async () => undefined); +const writeFile = vi.fn(async () => undefined); + +vi.mock("node:fs/promises", () => ({ mkdir, writeFile })); vi.mock("electron", () => ({ + app: { + getPath: vi.fn(() => "/tmp/t3-code-test"), + }, session: { fromPartition: vi.fn(), }, @@ -14,6 +21,8 @@ vi.mock("electron", () => ({ describe("PreviewViewManager automation status", () => { beforeEach(() => { fromId.mockClear(); + mkdir.mockClear(); + writeFile.mockClear(); }); it("reports an unregistered webview as temporarily unavailable", async () => { @@ -41,4 +50,53 @@ describe("PreviewViewManager automation status", () => { }); expect(fromId).not.toHaveBeenCalled(); }); + + it("captures a PNG screenshot into browser artifacts", async () => { + const png = Buffer.from("preview-png"); + const capturePage = vi.fn(async () => ({ toPNG: () => png })); + const listeners = new Map<string, (...args: never[]) => void>(); + fromId.mockReturnValue({ + id: 42, + isDestroyed: () => false, + getType: () => "webview", + getURL: () => "https://example.com/", + getTitle: () => "Example", + isLoading: () => false, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + on: vi.fn((event: string, listener: (...args: never[]) => void) => { + listeners.set(event, listener); + }), + off: vi.fn(), + ipc: { on: vi.fn(), off: vi.fn() }, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand: vi.fn(async () => undefined), + on: vi.fn(), + off: vi.fn(), + }, + capturePage, + } as never); + const { PreviewViewManager } = await import("./preview-view-manager.ts"); + const manager = new PreviewViewManager(); + manager.createTab("tab_1"); + manager.registerWebview("tab_1", 42); + + const artifact = await manager.captureScreenshot("tab_1"); + + expect(capturePage).toHaveBeenCalledOnce(); + expect(mkdir).toHaveBeenCalledWith("/tmp/t3-code-test/browser-artifacts", { + recursive: true, + }); + expect(writeFile).toHaveBeenCalledWith(artifact.path, png); + expect(artifact).toMatchObject({ + tabId: "tab_1", + mimeType: "image/png", + sizeBytes: png.byteLength, + }); + expect(artifact.path).toMatch(/\/browser-artifacts\/browser-screenshot-[^.]+\.png$/); + }); }); diff --git a/apps/desktop/src/preview-view-manager.ts b/apps/desktop/src/preview-view-manager.ts index ce8f9964529..d740334207c 100644 --- a/apps/desktop/src/preview-view-manager.ts +++ b/apps/desktop/src/preview-view-manager.ts @@ -1,4 +1,5 @@ // @effect-diagnostics globalDate:off +// @effect-diagnostics nodeBuiltinImport:off /** * PreviewViewManager — desktop side of the in-app browser preview. * @@ -9,9 +10,15 @@ import type { PreviewAnnotationPayload, PreviewAnnotationRect, + DesktopPreviewRecordingArtifact, + DesktopPreviewRecordingFrame, + DesktopPreviewScreenshotArtifact, PreviewAutomationClickInput, + PreviewAutomationActionEvent, + PreviewAutomationConsoleEntry, PreviewAutomationEvaluateInput, PreviewAutomationPressInput, + PreviewAutomationNetworkEntry, PreviewAutomationScrollInput, PreviewAutomationSnapshot, PreviewAutomationStatus, @@ -19,16 +26,21 @@ import type { PreviewAutomationWaitForInput, } from "@t3tools/contracts"; import { normalizePreviewUrl } from "@t3tools/shared/preview"; -import { type BrowserWindow, type Session, session, webContents } from "electron"; +import { app, type BrowserWindow, type Session, session, webContents } from "electron"; +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { createHash } from "node:crypto"; import { setTimeout as sleep } from "node:timers/promises"; import { isPreviewAnnotationPayload } from "./picked-element-payload.ts"; +import { playwrightInjectedRuntimeInstallExpression } from "./playwright-injected-runtime.ts"; -const PREVIEW_PARTITION = "persist:t3code-preview"; +const PREVIEW_PARTITION_PREFIX = "persist:t3code-preview-"; const START_PICK_CHANNEL = "preview:start-pick"; const CANCEL_PICK_CHANNEL = "preview:cancel-pick"; const ELEMENT_PICKED_CHANNEL = "preview:element-picked"; const ANNOTATION_CAPTURED_CHANNEL = "preview:annotation-captured"; +const HUMAN_INPUT_CHANNEL = "preview:human-input"; // Re-export the guest webview security posture from its dedicated module so // the constant is unit-testable in isolation. See @@ -55,6 +67,7 @@ export interface PreviewTabState { canGoBack: boolean; canGoForward: boolean; zoomFactor: number; + controller: "human" | "agent" | "none"; updatedAt: string; } @@ -69,6 +82,7 @@ const MAX_EVALUATION_BYTES = 64_000; const MAX_VISIBLE_TEXT_LENGTH = 20_000; const MAX_INTERACTIVE_ELEMENTS = 200; const MAX_SCREENSHOT_WIDTH = 1280; +const DIAGNOSTIC_BUFFER_LIMIT = 200; interface CdpEvaluationResult { readonly result?: { @@ -86,7 +100,8 @@ const automationError = ( | "PreviewAutomationExecutionError" | "PreviewAutomationInvalidSelectorError" | "PreviewAutomationResultTooLargeError" - | "PreviewAutomationTimeoutError", + | "PreviewAutomationTimeoutError" + | "PreviewAutomationControlInterruptedError", message: string, detail?: unknown, ): Error & { detail?: unknown } => { @@ -165,10 +180,12 @@ const nextZoomLevel = (current: number, direction: "in" | "out"): number => { }; type Listener = (tabId: string, state: PreviewTabState) => void; +type RecordingFrameListener = (frame: DesktopPreviewRecordingFrame) => void; interface ManagedListeners { navigate: () => void; failed: (event: Event, code: number, description: string) => void; + humanInput: () => void; } interface PickSession { @@ -176,6 +193,18 @@ interface PickSession { readonly cleanup: () => void; } +interface BrowserControlSession { + readonly webContentsId: number; + tail: Promise<void>; + initialized: Promise<void>; +} + +interface BrowserDiagnostics { + readonly consoleEntries: PreviewAutomationConsoleEntry[]; + readonly networkEntries: PreviewAutomationNetworkEntry[]; + readonly requests: Map<string, { url: string; method: string }>; +} + const APP_FORWARDED_SHORTCUTS: ReadonlyArray<{ key: string; meta: boolean; @@ -196,17 +225,30 @@ export class PreviewViewManager { private mainWindow: BrowserWindow | null = null; private readonly tabs = new Map<string, PreviewTabState>(); private readonly attached = new Map<number, ManagedListeners>(); - private browserSession: Session | null = null; + private readonly browserSessions = new Map<string, Session>(); private readonly listeners = new Set<Listener>(); + private readonly recordingFrameListeners = new Set<RecordingFrameListener>(); /** In-flight preview annotation sessions, keyed by tabId. */ private readonly pickSessions = new Map<string, PickSession>(); + /** One long-lived CDP attachment and serialized command queue per guest. */ + private readonly controlSessions = new Map<number, BrowserControlSession>(); + private readonly diagnostics = new Map<number, BrowserDiagnostics>(); + private readonly controlEpoch = new Map<string, number>(); + private readonly actionTimeline = new Map<string, PreviewAutomationActionEvent[]>(); + private actionSequence = 0; + private recordingTabId: string | null = null; setMainWindow(window: BrowserWindow): void { this.mainWindow = window; } - getBrowserPartition(): string { - return PREVIEW_PARTITION; + getBrowserPartition(scope = "shared"): string { + const digest = createHash("sha256").update(scope).digest("hex").slice(0, 20); + return `${PREVIEW_PARTITION_PREFIX}${digest}`; + } + + isBrowserPartition(partition: string): boolean { + return partition.startsWith(PREVIEW_PARTITION_PREFIX); } /** @@ -219,9 +261,11 @@ export class PreviewViewManager { return PREVIEW_WEBVIEW_PREFERENCES; } - getBrowserSession(): Session { - if (this.browserSession) return this.browserSession; - const sess = session.fromPartition(PREVIEW_PARTITION); + getBrowserSession(scope = "shared"): Session { + const partition = this.getBrowserPartition(scope); + const existing = this.browserSessions.get(partition); + if (existing) return existing; + const sess = session.fromPartition(partition); const ua = sess .getUserAgent() .replace(/Electron\/[\d.]+ /, "") @@ -231,7 +275,7 @@ export class PreviewViewManager { const allow = ["clipboard-read", "clipboard-write", "notifications", "geolocation"]; callback(allow.includes(perm)); }); - this.browserSession = sess; + this.browserSessions.set(partition, sess); return sess; } @@ -245,6 +289,7 @@ export class PreviewViewManager { canGoBack: false, canGoForward: false, zoomFactor: DEFAULT_ZOOM_FACTOR, + controller: "none", updatedAt: new Date().toISOString(), }; this.tabs.set(tabId, initial); @@ -257,6 +302,7 @@ export class PreviewViewManager { if (!tab) return; this.cancelPickElement(tabId); if (tab.webContentsId != null) { + this.detachControlSession(tab.webContentsId); this.detachListeners(tab.webContentsId); } const closed: PreviewTabState = { @@ -266,6 +312,7 @@ export class PreviewViewManager { canGoBack: false, canGoForward: false, zoomFactor: DEFAULT_ZOOM_FACTOR, + controller: "none", updatedAt: new Date().toISOString(), }; this.tabs.delete(tabId); @@ -294,6 +341,7 @@ export class PreviewViewManager { return; } if (tab.webContentsId != null && tab.webContentsId !== webContentsId) { + this.detachControlSession(tab.webContentsId); this.detachListeners(tab.webContentsId); // Any in-flight pick is bound to the OLD WebContents via `wc.ipc.on`. // Cancel it so the toggle button doesn't get stuck pressed waiting @@ -301,6 +349,7 @@ export class PreviewViewManager { this.cancelPickElement(tabId); } this.attachListeners(tabId, wc); + void this.ensureControlSession(wc).catch(() => undefined); // Restore the persisted zoom factor onto the freshly-attached WebContents // so a thread-switch + remount lands the user back where they were. if (Math.abs(tab.zoomFactor - DEFAULT_ZOOM_FACTOR) > ZOOM_EPSILON) { @@ -364,6 +413,10 @@ export class PreviewViewManager { wc.devToolsWebContents?.focus(); return; } + this.detachControlSession(wc.id); + wc.once("devtools-closed", () => { + if (!wc.isDestroyed()) void this.ensureControlSession(wc).catch(() => undefined); + }); wc.openDevTools({ mode: "detach" }); } @@ -372,16 +425,18 @@ export class PreviewViewManager { * preview tab since they all share `persist:t3code-preview`. */ async clearCookies(): Promise<void> { - const sess = this.getBrowserSession(); - await sess.clearStorageData({ - storages: ["cookies", "localstorage", "indexdb", "websql", "serviceworkers"], - }); + await Promise.all( + [...this.browserSessions.values()].map((sess) => + sess.clearStorageData({ + storages: ["cookies", "localstorage", "indexdb", "websql", "serviceworkers"], + }), + ), + ); } /** Drop the HTTP cache for the preview partition. */ async clearCache(): Promise<void> { - const sess = this.getBrowserSession(); - await sess.clearCache(); + await Promise.all([...this.browserSessions.values()].map((sess) => sess.clearCache())); } /** @@ -485,6 +540,65 @@ export class PreviewViewManager { session.resolve(null); } + async startRecording(tabId: string): Promise<void> { + if (this.recordingTabId && this.recordingTabId !== tabId) { + throw new Error("Only one browser recording can be active per window."); + } + const wc = this.requireWebContents(tabId); + await this.withControlSession(tabId, wc, "recording.start", async (send) => { + await send("Page.enable"); + await send("Page.startScreencast", { + format: "jpeg", + quality: 80, + maxWidth: 1600, + maxHeight: 1200, + everyNthFrame: 1, + }); + }); + this.recordingTabId = tabId; + } + + async captureScreenshot(tabId: string): Promise<DesktopPreviewScreenshotArtifact> { + const wc = this.requireWebContents(tabId); + const createdAt = new Date().toISOString(); + const id = `browser-screenshot-${Date.now().toString(36)}`; + const directory = join(app.getPath("userData"), "browser-artifacts"); + const path = join(directory, `${id}.png`); + const data = (await wc.capturePage()).toPNG(); + await mkdir(directory, { recursive: true }); + await writeFile(path, data); + return { id, tabId, path, mimeType: "image/png", sizeBytes: data.byteLength, createdAt }; + } + + async stopRecording(tabId: string): Promise<void> { + if (this.recordingTabId !== tabId) return; + const wc = this.requireWebContents(tabId); + await this.withControlSession(tabId, wc, "recording.stop", async (send) => { + await send("Page.stopScreencast"); + }); + this.recordingTabId = null; + } + + async saveRecording( + tabId: string, + mimeType: string, + data: Uint8Array, + ): Promise<DesktopPreviewRecordingArtifact> { + const createdAt = new Date().toISOString(); + const id = `browser-recording-${Date.now().toString(36)}`; + const extension = mimeType.includes("mp4") ? "mp4" : "webm"; + const directory = join(app.getPath("userData"), "browser-artifacts"); + const path = join(directory, `${id}.${extension}`); + await mkdir(directory, { recursive: true }); + await writeFile(path, data); + return { id, tabId, path, mimeType, sizeBytes: data.byteLength, createdAt }; + } + + onRecordingFrame(listener: RecordingFrameListener): () => void { + this.recordingFrameListeners.add(listener); + return () => this.recordingFrameListeners.delete(listener); + } + zoomIn(tabId: string): void { this.applyZoom(tabId, (current) => nextZoomLevel(current, "in")); } @@ -533,7 +647,7 @@ export class PreviewViewManager { async automationSnapshot(tabId: string): Promise<PreviewAutomationSnapshot> { const wc = this.requireWebContents(tabId); - return this.withDebugger(wc, async (send) => { + return this.withControlSession(tabId, wc, "snapshot", async (send) => { await Promise.all([send("Runtime.enable"), send("Accessibility.enable")]); const page = await this.evaluateWithDebugger<{ url: string; @@ -604,6 +718,9 @@ export class PreviewViewManager { return { ...page, accessibilityTree: accessibility, + consoleEntries: [...(this.diagnostics.get(wc.id)?.consoleEntries ?? [])], + networkEntries: [...(this.diagnostics.get(wc.id)?.networkEntries ?? [])], + actionTimeline: [...(this.actionTimeline.get(tabId) ?? [])], screenshot: { mimeType: "image/png", data: image.toPNG().toString("base64"), @@ -616,22 +733,29 @@ export class PreviewViewManager { async automationClick(tabId: string, input: PreviewAutomationClickInput): Promise<void> { const wc = this.requireWebContents(tabId); - await this.withDebugger(wc, async (send) => { + await this.withControlSession(tabId, wc, "click", async (send) => { await Promise.all([ send("Runtime.enable"), send("Input.setIgnoreInputEvents", { ignore: false }), ]); let x: number; let y: number; - if ("selector" in input) { + if ("selector" in input || "locator" in input) { + await this.ensurePlaywrightInjected(send); + const locator = this.automationLocator(input); const point = await this.evaluateWithDebugger< { x: number; y: number } | { invalidSelector: true; message: string } | { notFound: true } >( send, `(() => { try { - const element = document.querySelector(${JSON.stringify(input.selector)}); + const injected = globalThis.__t3PlaywrightInjected; + const parsed = injected.parseSelector(${JSON.stringify(locator)}); + const element = injected.querySelector(parsed, document, true); if (!element) return { notFound: true }; + const visible = injected.elementState(element, "visible"); + const enabled = injected.elementState(element, "enabled"); + if (!visible.matches || !enabled.matches) return { notFound: true }; element.scrollIntoView({ block: "center", inline: "center" }); const rect = element.getBoundingClientRect(); return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }; @@ -643,20 +767,20 @@ export class PreviewViewManager { ); if ("invalidSelector" in point) { throw automationError("PreviewAutomationInvalidSelectorError", point.message, { - selector: input.selector, + selector: locator, }); } if ("notFound" in point) { throw automationError( "PreviewAutomationExecutionError", - `No element matches selector ${input.selector}.`, + `No element matches locator ${locator}.`, ); } x = point.x; y = point.y; } else { - x = input.x; - y = input.y; + x = input.x!; + y = input.y!; } const viewport = await this.evaluateWithDebugger<{ width: number; height: number }>( send, @@ -688,19 +812,17 @@ export class PreviewViewManager { async automationType(tabId: string, input: PreviewAutomationTypeInput): Promise<void> { const wc = this.requireWebContents(tabId); - await this.withDebugger(wc, async (send) => { + await this.withControlSession(tabId, wc, "type", async (send) => { await send("Runtime.enable"); + const locator = this.automationLocator(input); + if (locator) await this.ensurePlaywrightInjected(send); const focusResult = await this.evaluateWithDebugger< { ok: true } | { invalidSelector: true; message: string } | { notFound: true } >( send, `(() => { try { - const element = ${ - input.selector - ? `document.querySelector(${JSON.stringify(input.selector)})` - : "document.activeElement" - }; + const element = ${locator ? `(() => { const injected = globalThis.__t3PlaywrightInjected; return injected.querySelector(injected.parseSelector(${JSON.stringify(locator)}), document, true); })()` : "document.activeElement"}; if (!element) return { notFound: true }; element.focus(); if (${input.clear ?? false}) { @@ -723,8 +845,8 @@ export class PreviewViewManager { if ("notFound" in focusResult) { throw automationError( "PreviewAutomationExecutionError", - input.selector - ? `No element matches selector ${input.selector}.` + locator + ? `No element matches locator ${locator}.` : "No element is focused in the preview.", ); } @@ -743,7 +865,7 @@ export class PreviewViewManager { async automationPress(tabId: string, input: PreviewAutomationPressInput): Promise<void> { const wc = this.requireWebContents(tabId); - await this.withDebugger(wc, async (send) => { + await this.withControlSession(tabId, wc, "press", async (send) => { const modifiers = (input.modifiers ?? []).reduce((value, modifier) => { switch (modifier) { case "Alt": @@ -771,19 +893,17 @@ export class PreviewViewManager { async automationScroll(tabId: string, input: PreviewAutomationScrollInput): Promise<void> { const wc = this.requireWebContents(tabId); - await this.withDebugger(wc, async (send) => { + await this.withControlSession(tabId, wc, "scroll", async (send) => { await send("Runtime.enable"); + const locator = this.automationLocator(input); + if (locator) await this.ensurePlaywrightInjected(send); const result = await this.evaluateWithDebugger< { ok: true } | { invalidSelector: true; message: string } | { notFound: true } >( send, `(() => { try { - const target = ${ - input.selector - ? `document.querySelector(${JSON.stringify(input.selector)})` - : "window" - }; + const target = ${locator ? `(() => { const injected = globalThis.__t3PlaywrightInjected; return injected.querySelector(injected.parseSelector(${JSON.stringify(locator)}), document, true); })()` : "window"}; if (!target) return { notFound: true }; target.scrollBy({ left: ${input.deltaX ?? 0}, top: ${input.deltaY ?? 0}, behavior: "instant" }); return { ok: true }; @@ -801,7 +921,7 @@ export class PreviewViewManager { if ("notFound" in result) { throw automationError( "PreviewAutomationExecutionError", - `No element matches selector ${input.selector}.`, + `No element matches locator ${locator}.`, ); } }); @@ -809,7 +929,7 @@ export class PreviewViewManager { async automationEvaluate(tabId: string, input: PreviewAutomationEvaluateInput): Promise<unknown> { const wc = this.requireWebContents(tabId); - return this.withDebugger(wc, async (send) => { + return this.withControlSession(tabId, wc, "evaluate", async (send) => { await send("Runtime.enable"); const value = await this.evaluateWithDebugger( send, @@ -835,8 +955,10 @@ export class PreviewViewManager { async automationWaitFor(tabId: string, input: PreviewAutomationWaitForInput): Promise<void> { const wc = this.requireWebContents(tabId); const timeoutMs = input.timeoutMs ?? 15_000; - await this.withDebugger(wc, async (send) => { + await this.withControlSession(tabId, wc, "waitFor", async (send) => { await send("Runtime.enable"); + const locator = this.automationLocator(input); + if (locator) await this.ensurePlaywrightInjected(send); const deadline = Date.now() + timeoutMs; while (Date.now() <= deadline) { const result = await this.evaluateWithDebugger< @@ -845,11 +967,7 @@ export class PreviewViewManager { send, `(() => { try { - const selectorMatched = ${ - input.selector - ? `document.querySelector(${JSON.stringify(input.selector)}) !== null` - : "true" - }; + const selectorMatched = ${locator ? `(() => { const injected = globalThis.__t3PlaywrightInjected; return injected.querySelector(injected.parseSelector(${JSON.stringify(locator)}), document, false) !== null; })()` : "true"}; const textMatched = ${ input.text ? `(document.body?.innerText || "").includes(${JSON.stringify(input.text)})` @@ -896,36 +1014,282 @@ export class PreviewViewManager { this.update(tabId, { zoomFactor: next }); } - private async withDebugger<A>( + private async withControlSession<A>( + tabId: string, wc: Electron.WebContents, + action: string, use: ( send: (method: string, commandParams?: Record<string, unknown>) => Promise<unknown>, ) => Promise<A>, ): Promise<A> { - if (wc.debugger.isAttached()) { - throw automationError( - "PreviewAutomationExecutionError", - "Preview automation is unavailable while another debugger is attached.", - ); - } - wc.debugger.attach("1.3"); + const actionEvent: PreviewAutomationActionEvent = { + id: `browser-action-${Date.now().toString(36)}-${(this.actionSequence++).toString(36)}`, + action, + status: "running", + startedAt: new Date().toISOString(), + }; + this.pushAction(tabId, actionEvent); + const epoch = this.controlEpoch.get(tabId) ?? 0; + const control = await this.ensureControlSession(wc); + let resolveTail: () => void = () => undefined; + const previous = control.tail; + control.tail = new Promise<void>((resolve) => { + resolveTail = resolve; + }); + await previous; + this.update(tabId, { controller: "agent" }); try { - return await use((method, commandParams) => wc.debugger.sendCommand(method, commandParams)); + const send = async (method: string, commandParams?: Record<string, unknown>) => { + if ((this.controlEpoch.get(tabId) ?? 0) !== epoch) { + throw automationError( + "PreviewAutomationControlInterruptedError", + "Browser control was interrupted by human input.", + ); + } + const result = await wc.debugger.sendCommand(method, commandParams); + if ((this.controlEpoch.get(tabId) ?? 0) !== epoch) { + throw automationError( + "PreviewAutomationControlInterruptedError", + "Browser control was interrupted by human input.", + ); + } + return result; + }; + const result = await use(send); + this.replaceAction(tabId, { + ...actionEvent, + status: "succeeded", + completedAt: new Date().toISOString(), + }); + return result; } catch (cause) { + const interrupted = + cause instanceof Error && cause.name === "PreviewAutomationControlInterruptedError"; + this.replaceAction(tabId, { + ...actionEvent, + status: interrupted ? "interrupted" : "failed", + completedAt: new Date().toISOString(), + error: cause instanceof Error ? cause.message : String(cause), + }); if (cause instanceof Error && cause.name.startsWith("PreviewAutomation")) throw cause; throw automationError( "PreviewAutomationExecutionError", cause instanceof Error ? cause.message : String(cause), - cause, + { tabId, cause }, ); } finally { - if (wc.debugger.isAttached()) { - try { - wc.debugger.detach(); - } catch { - // The target can disappear while an operation is completing. + if (this.tabs.has(tabId)) this.update(tabId, { controller: "none" }); + resolveTail(); + } + } + + private pushAction(tabId: string, event: PreviewAutomationActionEvent): void { + const timeline = this.actionTimeline.get(tabId) ?? []; + timeline.push(event); + if (timeline.length > 200) timeline.splice(0, timeline.length - 200); + this.actionTimeline.set(tabId, timeline); + } + + private replaceAction(tabId: string, event: PreviewAutomationActionEvent): void { + const timeline = this.actionTimeline.get(tabId); + if (!timeline) return; + const index = timeline.findIndex((candidate) => candidate.id === event.id); + if (index >= 0) timeline[index] = event; + } + + private async ensureControlSession(wc: Electron.WebContents): Promise<BrowserControlSession> { + const existing = this.controlSessions.get(wc.id); + if (existing) { + await existing.initialized; + return existing; + } + if (wc.isDevToolsOpened()) { + throw automationError( + "PreviewAutomationExecutionError", + "Close preview DevTools before using agent browser control.", + ); + } + if (wc.debugger.isAttached()) { + throw automationError( + "PreviewAutomationExecutionError", + "Preview control cannot attach because another debugger owns this page.", + ); + } + const control: BrowserControlSession = { + webContentsId: wc.id, + tail: Promise.resolve(), + initialized: Promise.resolve(), + }; + const diagnostics: BrowserDiagnostics = { + consoleEntries: [], + networkEntries: [], + requests: new Map(), + }; + this.diagnostics.set(wc.id, diagnostics); + wc.debugger.on("message", (_event, method, params) => { + if (method === "Page.screencastFrame") { + const frame = params as Record<string, unknown>; + const sessionId = frame["sessionId"]; + if (typeof sessionId === "number") { + void wc.debugger + .sendCommand("Page.screencastFrameAck", { sessionId }) + .catch(() => undefined); + } + const tabId = this.tabIdForWebContents(wc.id); + const metadata = + typeof frame["metadata"] === "object" && frame["metadata"] !== null + ? (frame["metadata"] as Record<string, unknown>) + : {}; + if (tabId && typeof frame["data"] === "string") { + const payload: DesktopPreviewRecordingFrame = { + tabId, + data: frame["data"], + width: typeof metadata["deviceWidth"] === "number" ? metadata["deviceWidth"] : 0, + height: typeof metadata["deviceHeight"] === "number" ? metadata["deviceHeight"] : 0, + receivedAt: new Date().toISOString(), + }; + for (const listener of this.recordingFrameListeners) listener(payload); } } + this.captureDiagnosticMessage(diagnostics, method, params as Record<string, unknown>); + }); + control.initialized = (async () => { + wc.debugger.attach("1.3"); + await Promise.all([ + wc.debugger.sendCommand("Runtime.enable"), + wc.debugger.sendCommand("Accessibility.enable"), + wc.debugger.sendCommand("Network.enable"), + wc.debugger.sendCommand("Log.enable"), + ]); + })(); + this.controlSessions.set(wc.id, control); + try { + await control.initialized; + return control; + } catch (cause) { + this.controlSessions.delete(wc.id); + throw cause; + } + } + + private detachControlSession(webContentsId: number): void { + this.controlSessions.delete(webContentsId); + this.diagnostics.delete(webContentsId); + const wc = webContents.fromId(webContentsId); + if (!wc || wc.isDestroyed() || !wc.debugger.isAttached()) return; + try { + wc.debugger.detach(); + } catch { + // Target teardown can race detachment. + } + } + + private tabIdForWebContents(webContentsId: number): string | null { + for (const [tabId, tab] of this.tabs) { + if (tab.webContentsId === webContentsId) return tabId; + } + return null; + } + + private captureDiagnosticMessage( + diagnostics: BrowserDiagnostics, + method: string, + params: Record<string, unknown>, + ): void { + const timestamp = new Date().toISOString(); + if (method === "Runtime.consoleAPICalled") { + const args = Array.isArray(params["args"]) ? params["args"] : []; + const text = args + .map((arg) => { + if (typeof arg !== "object" || arg === null) return String(arg); + const value = arg as Record<string, unknown>; + return String(value["value"] ?? value["description"] ?? ""); + }) + .join(" "); + this.pushBounded(diagnostics.consoleEntries, { + level: typeof params["type"] === "string" ? params["type"] : "log", + text, + timestamp, + source: "console", + }); + return; + } + if (method === "Runtime.exceptionThrown") { + const details = + typeof params["exceptionDetails"] === "object" && params["exceptionDetails"] !== null + ? (params["exceptionDetails"] as Record<string, unknown>) + : {}; + this.pushBounded(diagnostics.consoleEntries, { + level: "error", + text: String(details["text"] ?? "Uncaught exception"), + timestamp, + source: "exception", + }); + return; + } + if (method === "Log.entryAdded") { + const entry = + typeof params["entry"] === "object" && params["entry"] !== null + ? (params["entry"] as Record<string, unknown>) + : {}; + this.pushBounded(diagnostics.consoleEntries, { + level: typeof entry["level"] === "string" ? entry["level"] : "info", + text: String(entry["text"] ?? ""), + timestamp, + source: typeof entry["source"] === "string" ? entry["source"] : "log", + }); + return; + } + const requestId = typeof params["requestId"] === "string" ? params["requestId"] : null; + if (method === "Network.requestWillBeSent" && requestId) { + const request = + typeof params["request"] === "object" && params["request"] !== null + ? (params["request"] as Record<string, unknown>) + : {}; + diagnostics.requests.set(requestId, { + url: String(request["url"] ?? ""), + method: String(request["method"] ?? "GET"), + }); + return; + } + if (method === "Network.responseReceived" && requestId) { + const request = diagnostics.requests.get(requestId); + const response = + typeof params["response"] === "object" && params["response"] !== null + ? (params["response"] as Record<string, unknown>) + : {}; + const status = typeof response["status"] === "number" ? response["status"] : null; + if (request && status !== null && status >= 400) { + this.pushBounded(diagnostics.networkEntries, { + ...request, + status, + failed: true, + timestamp, + }); + } + return; + } + if (method === "Network.loadingFailed" && requestId) { + const request = diagnostics.requests.get(requestId); + if (request) { + this.pushBounded(diagnostics.networkEntries, { + ...request, + status: null, + failed: true, + errorText: String(params["errorText"] ?? "Network request failed"), + timestamp, + }); + } + diagnostics.requests.delete(requestId); + return; + } + if (method === "Network.loadingFinished" && requestId) diagnostics.requests.delete(requestId); + } + + private pushBounded<A>(buffer: A[], entry: A): void { + buffer.push(entry); + if (buffer.length > DIAGNOSTIC_BUFFER_LIMIT) { + buffer.splice(0, buffer.length - DIAGNOSTIC_BUFFER_LIMIT); } } @@ -952,6 +1316,28 @@ export class PreviewViewManager { return response.result?.value as A; } + private automationLocator(input: { + readonly selector?: string | undefined; + readonly locator?: string | undefined; + }): string | null { + if (input.locator) return input.locator; + if (input.selector) return `css=${input.selector}`; + return null; + } + + private async ensurePlaywrightInjected( + send: (method: string, commandParams?: Record<string, unknown>) => Promise<unknown>, + ): Promise<void> { + const installed = await this.evaluateWithDebugger<boolean>( + send, + "Boolean(globalThis.__t3PlaywrightInjected)", + true, + ); + if (installed) return; + const expression = await playwrightInjectedRuntimeInstallExpression(); + await this.evaluateWithDebugger(send, expression, true); + } + onStateChange(listener: Listener): () => void { this.listeners.add(listener); return () => { @@ -964,6 +1350,7 @@ export class PreviewViewManager { this.closeTab(tabId); } this.listeners.clear(); + this.recordingFrameListeners.clear(); } private attachListeners(tabId: string, wc: Electron.WebContents): void { @@ -988,6 +1375,15 @@ export class PreviewViewManager { }, }); }; + const humanInput = (): void => { + this.controlEpoch.set(tabId, (this.controlEpoch.get(tabId) ?? 0) + 1); + this.update(tabId, { controller: "human" }); + void sleep(750).then(() => { + if (this.tabs.get(tabId)?.controller === "human") { + this.update(tabId, { controller: "none" }); + } + }); + }; wc.on("did-navigate", sync); wc.on("did-navigate-in-page", sync); @@ -995,6 +1391,7 @@ export class PreviewViewManager { wc.on("did-start-loading", sync); wc.on("did-stop-loading", sync); wc.on("did-fail-load", failed as never); + wc.ipc.on(HUMAN_INPUT_CHANNEL, humanInput); // Keep external links inside the same view (matches ami's policy). wc.setWindowOpenHandler(({ url }) => { @@ -1020,7 +1417,7 @@ export class PreviewViewManager { } }); - this.attached.set(wc.id, { navigate: sync, failed }); + this.attached.set(wc.id, { navigate: sync, failed, humanInput }); } private detachListeners(webContentsId: number): void { @@ -1035,6 +1432,7 @@ export class PreviewViewManager { wc.off("did-start-loading", handlers.navigate); wc.off("did-stop-loading", handlers.navigate); wc.off("did-fail-load", handlers.failed as never); + wc.ipc.off(HUMAN_INPUT_CHANNEL, handlers.humanInput); } private isAppShortcut(input: Electron.Input): boolean { diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 0450b413c13..953c7a3adb2 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -200,7 +200,10 @@ const make = Effect.gen(function* () { previewViewManager.setMainWindow(window); window.webContents.on("will-attach-webview", (event, webPreferences, params) => { - if (params.partition !== previewViewManager.getBrowserPartition()) { + if ( + typeof params.partition !== "string" || + !previewViewManager.isBrowserPartition(params.partition) + ) { event.preventDefault(); return; } diff --git a/apps/server/src/mcp/Layers/McpHttpServer.test.ts b/apps/server/src/mcp/Layers/McpHttpServer.test.ts index 8d01277ce3e..90843634130 100644 --- a/apps/server/src/mcp/Layers/McpHttpServer.test.ts +++ b/apps/server/src/mcp/Layers/McpHttpServer.test.ts @@ -65,6 +65,9 @@ it.effect("registers annotated tools and preserves authenticated request context visibleText: "Example", interactiveElements: [], accessibilityTree: {}, + consoleEntries: [], + networkEntries: [], + actionTimeline: [], screenshot: { mimeType: "image/png", data: Buffer.from("png").toString("base64"), @@ -96,6 +99,21 @@ it.effect("registers annotated tools and preserves authenticated request context const statusTool = server.tools.find(({ tool }) => tool.name === "preview_status"); expect(statusTool?.tool.annotations?.readOnlyHint).toBe(true); expect(statusTool?.tool.annotations?.idempotentHint).toBe(true); + expect(statusTool?.tool.annotations?.destructiveHint).toBe(false); + + const snapshotTool = server.tools.find(({ tool }) => tool.name === "preview_snapshot"); + expect(snapshotTool?.tool.annotations?.readOnlyHint).toBe(true); + expect(snapshotTool?.tool.annotations?.idempotentHint).toBe(true); + expect(snapshotTool?.tool.annotations?.openWorldHint).toBe(true); + + const clickTool = server.tools.find(({ tool }) => tool.name === "preview_click"); + expect(clickTool?.tool.annotations?.readOnlyHint).toBe(false); + expect(clickTool?.tool.annotations?.destructiveHint).toBe(true); + expect(clickTool?.tool.annotations?.openWorldHint).toBe(true); + + const navigateTool = server.tools.find(({ tool }) => tool.name === "preview_navigate"); + expect(navigateTool?.tool.annotations?.destructiveHint).toBe(false); + expect(navigateTool?.tool.annotations?.openWorldHint).toBe(true); const status = yield* server .callTool({ name: "preview_status", arguments: {} }) diff --git a/apps/server/src/mcp/Layers/PreviewAutomationBroker.test.ts b/apps/server/src/mcp/Layers/PreviewAutomationBroker.test.ts index d3e43e4c53c..7c97da39f2e 100644 --- a/apps/server/src/mcp/Layers/PreviewAutomationBroker.test.ts +++ b/apps/server/src/mcp/Layers/PreviewAutomationBroker.test.ts @@ -63,3 +63,27 @@ it.effect("rejects calls when no focused owner exists", () => expect(error).toBeInstanceOf(PreviewAutomationNoFocusedOwnerError); }), ); + +it.effect("routes interactive commands to a hidden durable browser host", () => + Effect.scoped( + Effect.gen(function* () { + const broker = makePreviewAutomationBroker(); + const requests = yield* broker.connect("client-hidden"); + yield* Stream.runForEach(requests, (request) => + broker.respond({ requestId: request.requestId, ok: true }), + ).pipe(Effect.forkScoped); + yield* Effect.yieldNow; + yield* broker.reportOwner({ + clientId: "client-hidden", + environmentId: scope.environmentId, + threadId: scope.threadId, + tabId: "tab-hidden", + visible: false, + supportsAutomation: true, + focusedAt: "2026-06-11T00:00:00.000Z", + }); + + yield* broker.invoke<void>({ scope, operation: "click", input: { x: 10, y: 10 } }); + }), + ), +); diff --git a/apps/server/src/mcp/Layers/PreviewAutomationBroker.ts b/apps/server/src/mcp/Layers/PreviewAutomationBroker.ts index 67d3a6ed575..d32523375b1 100644 --- a/apps/server/src/mcp/Layers/PreviewAutomationBroker.ts +++ b/apps/server/src/mcp/Layers/PreviewAutomationBroker.ts @@ -1,4 +1,5 @@ import { + PreviewAutomationControlInterruptedError, PreviewAutomationExecutionError, PreviewAutomationInvalidSelectorError, PreviewAutomationNoFocusedOwnerError, @@ -56,6 +57,8 @@ const makeResponseError = ( return new PreviewAutomationTabNotFoundError({ message: error.message }); case "PreviewAutomationTimeoutError": return new PreviewAutomationTimeoutError({ message: error.message }); + case "PreviewAutomationControlInterruptedError": + return new PreviewAutomationControlInterruptedError({ message: error.message }); case "PreviewAutomationInvalidSelectorError": { const detail = typeof error.detail === "object" && error.detail !== null ? error.detail : undefined; @@ -193,20 +196,19 @@ export const makePreviewAutomationBroker = (): PreviewAutomationBrokerShape => { (owner) => owner.environmentId === input.scope.environmentId && owner.threadId === input.scope.threadId && - owner.supportsAutomation && - (input.operation === "open" || input.operation === "status" || owner.visible), + owner.supportsAutomation, ) .sort((left, right) => right.focusedAt.localeCompare(left.focusedAt)); const owner = candidates[0]; if (!owner) { return yield* new PreviewAutomationNoFocusedOwnerError({ - message: "No focused desktop preview owner is available for this thread.", + message: "No desktop browser host is available for this thread.", }); } const connection = current.clients.get(owner.clientId); if (!connection) { return yield* new PreviewAutomationUnavailableError({ - message: "The focused preview owner is not connected.", + message: "The browser host is not connected.", }); } if ( @@ -216,7 +218,7 @@ export const makePreviewAutomationBroker = (): PreviewAutomationBrokerShape => { !input.tabId ) { return yield* new PreviewAutomationTabNotFoundError({ - message: "The focused preview owner does not have an active tab.", + message: "The browser host does not have an active tab.", }); } const requestId = `preview-${requestSequence++}`; diff --git a/apps/server/src/mcp/toolkits/preview/handlers.ts b/apps/server/src/mcp/toolkits/preview/handlers.ts index f5e19bfcf3e..62f91421343 100644 --- a/apps/server/src/mcp/toolkits/preview/handlers.ts +++ b/apps/server/src/mcp/toolkits/preview/handlers.ts @@ -1,6 +1,8 @@ import * as Effect from "effect/Effect"; import type { PreviewAutomationOperation, + PreviewAutomationRecordingArtifact, + PreviewAutomationRecordingStatus, PreviewAutomationSnapshot, PreviewAutomationStatus, } from "@t3tools/contracts"; @@ -44,4 +46,6 @@ export const PreviewToolkitHandlersLive = PreviewToolkit.toLayer({ preview_scroll: (input) => invoke<void>("scroll", input), preview_evaluate: (input) => invoke<unknown>("evaluate", input), preview_wait_for: (input) => invoke<void>("waitFor", input, input.timeoutMs), + preview_recording_start: () => invoke<PreviewAutomationRecordingStatus>("recordingStart", {}), + preview_recording_stop: () => invoke<PreviewAutomationRecordingArtifact>("recordingStop", {}), }); diff --git a/apps/server/src/mcp/toolkits/preview/tools.test.ts b/apps/server/src/mcp/toolkits/preview/tools.test.ts new file mode 100644 index 00000000000..1347e0db0ec --- /dev/null +++ b/apps/server/src/mcp/toolkits/preview/tools.test.ts @@ -0,0 +1,37 @@ +import { expect, it } from "@effect/vitest"; +import { Tool } from "effect/unstable/ai"; + +import { PreviewToolkit } from "./tools.ts"; + +const schemaHasDescription = (schema: unknown): boolean => { + if (!schema || typeof schema !== "object") return false; + const record = schema as Record<string, unknown>; + if (typeof record.description === "string" && record.description.length > 0) return true; + return [record.anyOf, record.oneOf, record.allOf] + .filter(Array.isArray) + .some((members) => members.some(schemaHasDescription)); +}; + +it("exports provider-compatible object schemas with described parameters", () => { + for (const tool of Object.values(PreviewToolkit.tools)) { + const schema = Tool.getJsonSchema(tool) as { + readonly type?: unknown; + readonly properties?: Readonly<Record<string, unknown>>; + readonly anyOf?: unknown; + readonly oneOf?: unknown; + }; + expect( + tool.description?.length ?? 0, + `${tool.name} should have a useful description`, + ).toBeGreaterThan(40); + expect(schema.type, `${tool.name} must export a top-level object schema`).toBe("object"); + expect(schema.anyOf, `${tool.name} must not export a root anyOf`).toBeUndefined(); + expect(schema.oneOf, `${tool.name} must not export a root oneOf`).toBeUndefined(); + for (const [field, fieldSchema] of Object.entries(schema.properties ?? {})) { + expect( + schemaHasDescription(fieldSchema), + `${tool.name}.${field} should explain what data the agent must pass`, + ).toBe(true); + } + } +}); diff --git a/apps/server/src/mcp/toolkits/preview/tools.ts b/apps/server/src/mcp/toolkits/preview/tools.ts index 32baf050f2f..effd87fb7e4 100644 --- a/apps/server/src/mcp/toolkits/preview/tools.ts +++ b/apps/server/src/mcp/toolkits/preview/tools.ts @@ -5,6 +5,8 @@ import { PreviewAutomationNavigateInput, PreviewAutomationOpenInput, PreviewAutomationPressInput, + PreviewAutomationRecordingArtifact, + PreviewAutomationRecordingStatus, PreviewAutomationScrollInput, PreviewAutomationSnapshot, PreviewAutomationStatus, @@ -17,7 +19,13 @@ import { Tool, Toolkit } from "effect/unstable/ai"; import { McpInvocationContext } from "../../Services/McpInvocationContext.ts"; const browserTool = <T extends Tool.Any>(tool: T): T => - tool.annotate(Tool.Destructive, false).annotate(Tool.OpenWorld, true) as T; + tool.annotate(Tool.OpenWorld, true).annotate(Tool.Destructive, true) as T; + +const safeBrowserTool = <T extends Tool.Any>(tool: T): T => + browserTool(tool).annotate(Tool.Destructive, false) as T; + +const readonlyBrowserTool = <T extends Tool.Any>(tool: T): T => + safeBrowserTool(tool).annotate(Tool.Readonly, true).annotate(Tool.Idempotent, true) as T; export const PreviewStatusTool = Tool.make("preview_status", { description: @@ -39,13 +47,15 @@ export const PreviewOpenTool = browserTool( success: PreviewAutomationStatus, failure: PreviewAutomationError, dependencies: [McpInvocationContext], - }).annotate(Tool.Title, "Open browser preview"), + }) + .annotate(Tool.Title, "Open browser preview") + .annotate(Tool.Destructive, false), ); -export const PreviewNavigateTool = browserTool( +export const PreviewNavigateTool = safeBrowserTool( Tool.make("preview_navigate", { description: - "Navigate the scoped thread's active preview tab to a URL and wait for the requested readiness condition.", + "Navigate the active collaborative browser tab. Pass {url:'https://t3.chat'} for a website, or {target:{kind:'environment-port',port:5173}} for a dev server in the current environment. Exactly one of url or target is required. Defaults to waiting for page loading to stop.", parameters: PreviewAutomationNavigateInput, success: PreviewAutomationStatus, failure: PreviewAutomationError, @@ -53,21 +63,20 @@ export const PreviewNavigateTool = browserTool( }).annotate(Tool.Title, "Navigate browser preview"), ); -export const PreviewSnapshotTool = Tool.make("preview_snapshot", { - description: - "Capture bounded page metadata, visible text, interactive elements, accessibility data, and a PNG screenshot from the scoped preview tab.", - success: PreviewAutomationSnapshot, - failure: PreviewAutomationError, - dependencies: [McpInvocationContext], -}) - .annotate(Tool.Title, "Capture preview snapshot") - .annotate(Tool.Readonly, true) - .annotate(Tool.Destructive, false); +export const PreviewSnapshotTool = readonlyBrowserTool( + Tool.make("preview_snapshot", { + description: + "Inspect the current page before interacting. Returns URL/title/loading state, visible text, semantic interactive elements with reusable selectors and coordinates, accessibility data, recent console/network failures, action history, and a PNG screenshot.", + success: PreviewAutomationSnapshot, + failure: PreviewAutomationError, + dependencies: [McpInvocationContext], + }).annotate(Tool.Title, "Inspect browser page"), +); export const PreviewClickTool = browserTool( Tool.make("preview_click", { description: - "Click an element selected by CSS selector or click viewport coordinates in the scoped preview tab.", + "Click exactly one page target. Prefer locator with a Playwright selector such as role=button[name='Send']; selector accepts legacy CSS; x and y are viewport CSS pixels and must be supplied together. Call preview_snapshot first when the target is unknown.", parameters: PreviewAutomationClickInput, failure: PreviewAutomationError, dependencies: [McpInvocationContext], @@ -77,7 +86,7 @@ export const PreviewClickTool = browserTool( export const PreviewTypeTool = browserTool( Tool.make("preview_type", { description: - "Type text into the focused element or a CSS-selected element, optionally clearing its existing value first.", + "Insert literal text into one input. Prefer locator with a Playwright role/text selector; selector accepts legacy CSS. If neither is supplied, types into the currently focused element. Set clear=true to replace existing text.", parameters: PreviewAutomationTypeInput, failure: PreviewAutomationError, dependencies: [McpInvocationContext], @@ -86,17 +95,18 @@ export const PreviewTypeTool = browserTool( export const PreviewPressTool = browserTool( Tool.make("preview_press", { - description: "Dispatch a keyboard key with optional modifiers to the scoped preview tab.", + description: + "Press one keyboard key in the active page, for example {key:'Enter'}, {key:'Escape'}, or {key:'a',modifiers:['Meta']}. This targets the page's current focus.", parameters: PreviewAutomationPressInput, failure: PreviewAutomationError, dependencies: [McpInvocationContext], }).annotate(Tool.Title, "Press key in preview page"), ); -export const PreviewScrollTool = browserTool( +export const PreviewScrollTool = safeBrowserTool( Tool.make("preview_scroll", { description: - "Scroll the preview viewport or a CSS-selected scroll container by the requested deltas.", + "Scroll by CSS pixels. Positive deltaY scrolls down and positive deltaX scrolls right. Without locator/selector it scrolls the viewport; otherwise it scrolls that container. At least one delta is required.", parameters: PreviewAutomationScrollInput, failure: PreviewAutomationError, dependencies: [McpInvocationContext], @@ -106,7 +116,7 @@ export const PreviewScrollTool = browserTool( export const PreviewEvaluateTool = browserTool( Tool.make("preview_evaluate", { description: - "Evaluate bounded JavaScript in the scoped preview tab and return a serializable result of at most 64 KB.", + "Evaluate a JavaScript expression in the page's main frame and return a serializable result up to 64 KB. Prefer preview_snapshot and semantic click/type/wait tools; use this for inspection or interactions those tools cannot express. The expression may mutate page state.", parameters: PreviewAutomationEvaluateInput, success: Schema.Unknown, failure: PreviewAutomationError, @@ -114,16 +124,35 @@ export const PreviewEvaluateTool = browserTool( }).annotate(Tool.Title, "Evaluate JavaScript in preview"), ); -export const PreviewWaitForTool = browserTool( +export const PreviewWaitForTool = readonlyBrowserTool( Tool.make("preview_wait_for", { description: - "Wait until a CSS selector, visible-text substring, or URL substring appears in the scoped preview tab.", + "Wait until all supplied conditions match: a Playwright locator, legacy CSS selector, visible-text substring, and/or URL substring. Provide at least one condition. Defaults to 15 seconds, maximum 60 seconds.", parameters: PreviewAutomationWaitForInput, failure: PreviewAutomationError, dependencies: [McpInvocationContext], }).annotate(Tool.Title, "Wait for preview page condition"), ); +export const PreviewRecordingStartTool = safeBrowserTool( + Tool.make("preview_recording_start", { + description: + "Start recording the active collaborative browser tab while keeping it interactive for both agent and human use.", + success: PreviewAutomationRecordingStatus, + failure: PreviewAutomationError, + dependencies: [McpInvocationContext], + }).annotate(Tool.Title, "Start browser recording"), +); + +export const PreviewRecordingStopTool = safeBrowserTool( + Tool.make("preview_recording_stop", { + description: "Stop the active browser recording and save it as a local evidence artifact.", + success: PreviewAutomationRecordingArtifact, + failure: PreviewAutomationError, + dependencies: [McpInvocationContext], + }).annotate(Tool.Title, "Stop browser recording"), +); + export const PreviewToolkit = Toolkit.make( PreviewStatusTool, PreviewOpenTool, @@ -135,4 +164,6 @@ export const PreviewToolkit = Toolkit.make( PreviewScrollTool, PreviewEvaluateTool, PreviewWaitForTool, + PreviewRecordingStartTool, + PreviewRecordingStopTool, ); diff --git a/apps/server/src/provider/CodexDeveloperInstructions.ts b/apps/server/src/provider/CodexDeveloperInstructions.ts index 76055f8b8be..b46a4ce1ba3 100644 --- a/apps/server/src/provider/CodexDeveloperInstructions.ts +++ b/apps/server/src/provider/CodexDeveloperInstructions.ts @@ -1,3 +1,14 @@ +const T3_CODE_BROWSER_TOOL_INSTRUCTIONS = ` + +## T3 Code collaborative browser + +You are running inside T3 Code. The \`t3-code\` MCP server is the product-native collaborative browser shared with the user. When it exposes \`preview_*\` tools, prefer those tools for browser navigation, inspection, interaction, screenshots, and recordings. + +For browser work, first call \`preview_status\`. If no automation-capable preview is attached, call \`preview_open\` before concluding that the browser is unavailable. Then use \`preview_navigate\`, \`preview_snapshot\`, and the focused interaction tools. Prefer snapshot-provided locators over coordinates. + +Do not switch to global browser skills, Chrome, Node REPL browser automation, standalone Playwright, or agent-browser merely because the preview is initially closed or a first call fails. Use an alternative browser system only when the T3 preview tools are absent, the user explicitly requests another browser, or \`preview_open\` returns an explicit unsupported/unavailable error. A failed T3 preview tool call should be inspected and retried with corrected arguments when the error is actionable. +`; + export const CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS = `<collaboration_mode># Plan Mode (Conversational) You work in 3 phases, and you should *chat your way* to a great plan before finalizing it. A great plan is very detailed-intent- and implementation-wise-so that it can be handed to another engineer or agent to be implemented right away. It must be **decision complete**, where the implementer does not need to make any decisions. @@ -118,6 +129,7 @@ plan content should be human and agent digestible. The final plan must be plan-o Do not ask "should I proceed?" in the final output. The user can easily switch out of Plan mode and request implementation if you have included a \`<proposed_plan>\` block in your response. Alternatively, they can decide to stay in Plan mode and continue refining the plan. Only produce at most one \`<proposed_plan>\` block per turn, and only when you are presenting a complete spec. +${T3_CODE_BROWSER_TOOL_INSTRUCTIONS} </collaboration_mode>`; export const CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS = `<collaboration_mode># Collaboration Mode: Default @@ -131,4 +143,5 @@ Your active mode changes only when new developer instructions with a different \ The \`request_user_input\` tool is unavailable in Default mode. If you call it while in Default mode, it will return an error. In Default mode, strongly prefer making reasonable assumptions and executing the user's request rather than stopping to ask questions. If you absolutely must ask a question because the answer cannot be discovered from local context and a reasonable assumption would be risky, ask the user directly with a concise plain-text question. Never write a multiple choice question as a textual assistant message. +${T3_CODE_BROWSER_TOOL_INSTRUCTIONS} </collaboration_mode>`; diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 04ef44d54e8..7fef85c42e0 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -491,6 +491,64 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { }), ); + it.effect("labels MCP lifecycle entries with server and tool names", () => + Effect.gen(function* () { + const { adapter, runtime } = yield* startLifecycleRuntime(); + const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); + + yield* runtime.emit({ + id: asEventId("evt-mcp-complete"), + kind: "notification", + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", + method: "item/completed", + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-1"), + itemId: asItemId("mcp_1"), + payload: { + completedAtMs: 1_778_000_000_000, + threadId: "thread-1", + turnId: "turn-1", + item: { + type: "mcpToolCall", + id: "mcp_1", + server: "t3-code", + tool: "preview_status", + arguments: {}, + durationMs: 12, + error: null, + result: { content: [{ type: "text", text: "attached" }] }, + status: "completed", + }, + }, + }); + const firstEvent = yield* Fiber.join(firstEventFiber); + + assert.equal(firstEvent._tag, "Some"); + if (firstEvent._tag !== "Some" || firstEvent.value.type !== "item.completed") { + return; + } + assert.equal(firstEvent.value.payload.itemType, "mcp_tool_call"); + assert.equal(firstEvent.value.payload.title, "t3-code · preview_status"); + assert.deepStrictEqual(firstEvent.value.payload.data, { + completedAtMs: 1_778_000_000_000, + threadId: "thread-1", + turnId: "turn-1", + item: { + type: "mcpToolCall", + id: "mcp_1", + server: "t3-code", + tool: "preview_status", + arguments: {}, + durationMs: 12, + error: null, + result: { content: [{ type: "text", text: "attached" }] }, + status: "completed", + }, + }); + }), + ); + it.effect("maps completed plan items to canonical proposed-plan completion events", () => Effect.gen(function* () { const { adapter, runtime } = yield* startLifecycleRuntime(); diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index ea2d898730a..edb2bccdbcf 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -235,7 +235,10 @@ function toCanonicalItemType(raw: string | undefined | null): CanonicalItemType return "unknown"; } -function itemTitle(itemType: CanonicalItemType): string | undefined { +function itemTitle(itemType: CanonicalItemType, item?: CodexLifecycleItem): string | undefined { + if (itemType === "mcp_tool_call" && item?.type === "mcpToolCall") { + return `${item.server} · ${item.tool}`; + } switch (itemType) { case "assistant_message": return "Assistant message"; @@ -476,7 +479,7 @@ function mapItemLifecycle( payload: { itemType, ...(status ? { status } : {}), - ...(itemTitle(itemType) ? { title: itemTitle(itemType) } : {}), + ...(itemTitle(itemType, item) ? { title: itemTitle(itemType, item) } : {}), ...(detail ? { detail } : {}), ...(event.payload !== undefined ? { data: event.payload } : {}), }, diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index d2e51139b9f..2d303039856 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -13,6 +13,7 @@ import { } from "../CodexDeveloperInstructions.ts"; import { buildTurnStartParams, + hasConfiguredMcpServer, isRecoverableThreadResumeError, openCodexThread, } from "./CodexSessionRuntime.ts"; @@ -149,6 +150,31 @@ describe("buildTurnStartParams", () => { }); }); +describe("T3 browser developer instructions", () => { + it("prefers the product-native preview tools in both collaboration modes", () => { + for (const instructions of [ + CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS, + CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS, + ]) { + assert.match(instructions, /t3-code/); + assert.match(instructions, /preview_status/); + assert.match(instructions, /preview_open/); + assert.match(instructions, /Do not switch to global browser skills/); + } + }); +}); + +describe("hasConfiguredMcpServer", () => { + it("detects inline Codex MCP configuration arguments", () => { + assert.equal(hasConfiguredMcpServer(undefined), false); + assert.equal(hasConfiguredMcpServer(["--model", "gpt-5.4"]), false); + assert.equal( + hasConfiguredMcpServer(["-c", 'mcp_servers.t3-code.url="http://127.0.0.1/mcp"']), + true, + ); + }); +}); + describe("isRecoverableThreadResumeError", () => { it("matches missing thread errors", () => { assert.equal( diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index fc31ca3aef7..e3e20106d72 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -62,6 +62,10 @@ const RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS = [ "does not exist", ]; +export function hasConfiguredMcpServer(appServerArgs: ReadonlyArray<string> | undefined): boolean { + return appServerArgs?.some((argument) => argument.includes("mcp_servers.")) === true; +} + export const CodexResumeCursorSchema = Schema.Struct({ threadId: Schema.String, }); @@ -1256,6 +1260,15 @@ export const makeCodexSessionRuntime = ( sendTurn: (input) => Effect.gen(function* () { const providerThreadId = yield* readProviderThreadId; + if (hasConfiguredMcpServer(options.appServerArgs)) { + yield* client.request("config/mcpServer/reload", undefined).pipe( + Effect.catch((cause) => + Effect.logWarning("Failed to refresh Codex MCP tool catalog before turn.", { + cause, + }), + ), + ); + } const normalizedModel = normalizeCodexModelSlug( input.model ?? (yield* Ref.get(sessionRef)).model, ); diff --git a/apps/web/src/browser/BrowserSurfaceSlot.tsx b/apps/web/src/browser/BrowserSurfaceSlot.tsx new file mode 100644 index 00000000000..90769f8fb69 --- /dev/null +++ b/apps/web/src/browser/BrowserSurfaceSlot.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { useEffect, useRef } from "react"; + +import { useBrowserSurfaceStore } from "./browserSurfaceStore"; + +export function BrowserSurfaceSlot(props: { + readonly tabId: string; + readonly visible: boolean; + readonly className?: string; +}) { + const { tabId, visible, className } = props; + const elementRef = useRef<HTMLDivElement | null>(null); + + useEffect(() => { + const element = elementRef.current; + if (!element) return; + const update = () => { + const rect = element.getBoundingClientRect(); + useBrowserSurfaceStore.getState().present( + tabId, + { + x: Math.round(rect.x), + y: Math.round(rect.y), + width: Math.max(1, Math.round(rect.width)), + height: Math.max(1, Math.round(rect.height)), + }, + visible && rect.width > 0 && rect.height > 0, + ); + }; + update(); + const observer = new ResizeObserver(update); + observer.observe(element); + window.addEventListener("resize", update); + window.addEventListener("scroll", update, true); + return () => { + observer.disconnect(); + window.removeEventListener("resize", update); + window.removeEventListener("scroll", update, true); + useBrowserSurfaceStore.getState().hide(tabId); + }; + }, [tabId, visible]); + + return <div ref={elementRef} className={className} data-browser-surface-slot={tabId} />; +} diff --git a/apps/web/src/browser/ElectronBrowserHost.tsx b/apps/web/src/browser/ElectronBrowserHost.tsx new file mode 100644 index 00000000000..cc611a22993 --- /dev/null +++ b/apps/web/src/browser/ElectronBrowserHost.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { parseScopedThreadKey } from "@t3tools/client-runtime"; +import { useMemo } from "react"; + +import { isElectron } from "~/env"; +import { usePreviewStateStore } from "~/previewStateStore"; + +import { HostedBrowserWebview } from "./HostedBrowserWebview"; + +export function ElectronBrowserHost() { + const previewByThreadKey = usePreviewStateStore((state) => state.byThreadKey); + const sessions = useMemo( + () => + Object.entries(previewByThreadKey).flatMap(([threadKey, previewState]) => { + const threadRef = parseScopedThreadKey(threadKey); + return threadRef + ? Object.values(previewState.sessions).map((snapshot) => ({ + threadRef, + snapshot, + active: previewState.activeTabId === snapshot.tabId, + })) + : []; + }), + [previewByThreadKey], + ); + + if (!isElectron) return null; + return ( + <div className="contents" data-electron-browser-host> + {sessions.map(({ threadRef, snapshot }) => { + const url = snapshot.navStatus._tag === "Idle" ? null : snapshot.navStatus.url; + return ( + <HostedBrowserWebview + key={snapshot.tabId} + threadRef={threadRef} + tabId={snapshot.tabId} + initialUrl={url} + /> + ); + })} + </div> + ); +} diff --git a/apps/web/src/browser/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx new file mode 100644 index 00000000000..e158d7ebad6 --- /dev/null +++ b/apps/web/src/browser/HostedBrowserWebview.tsx @@ -0,0 +1,116 @@ +"use client"; + +import type { DesktopPreviewWebviewConfig, ScopedThreadRef } from "@t3tools/contracts"; +import { useShallow } from "zustand/react/shallow"; +import { useCallback, useEffect, useRef, useState } from "react"; + +import { previewBridge } from "~/components/preview/previewBridge"; +import { usePreviewBridge } from "~/components/preview/usePreviewBridge"; + +import { useBrowserRecordingStore } from "./browserRecording"; +import { useBrowserSurfaceStore } from "./browserSurfaceStore"; +import { acquireDesktopTab } from "./desktopTabLifetime"; + +interface ElectronWebview extends HTMLElement { + src: string; + partition: string; + preload?: string; + webpreferences?: string; + getWebContentsId: () => number; +} + +declare global { + interface HTMLElementTagNameMap { + webview: ElectronWebview; + } +} + +export function HostedBrowserWebview(props: { + readonly threadRef: ScopedThreadRef; + readonly tabId: string; + readonly initialUrl: string | null; +}) { + const { threadRef, tabId, initialUrl } = props; + const [config, setConfig] = useState<DesktopPreviewWebviewConfig | null>(null); + const initialSrcRef = useRef(initialUrl ?? "about:blank"); + const webviewRef = useRef<ElectronWebview | null>(null); + const presentation = useBrowserSurfaceStore(useShallow((state) => state.byTabId[tabId] ?? null)); + const recording = useBrowserRecordingStore((state) => state.activeTabId === tabId); + + usePreviewBridge({ threadRef, tabId }); + + useEffect(() => acquireDesktopTab(tabId), [tabId]); + + useEffect(() => { + let cancelled = false; + void previewBridge + ?.getPreviewConfig(threadRef.environmentId) + .then((next) => { + if (!cancelled) setConfig(next); + }) + .catch(() => undefined); + return () => { + cancelled = true; + }; + }, [threadRef.environmentId]); + + const setWebviewRef = useCallback((node: HTMLElement | null) => { + webviewRef.current = node as ElectronWebview | null; + if (node && !node.hasAttribute("allowpopups")) node.setAttribute("allowpopups", "true"); + }, []); + + useEffect(() => { + const webview = webviewRef.current; + const bridge = previewBridge; + if (!webview || !config || !bridge) return; + const register = () => { + try { + const webContentsId = webview.getWebContentsId(); + if (Number.isInteger(webContentsId) && webContentsId > 0) { + void bridge.registerWebview(tabId, webContentsId); + } + } catch { + // A later dom-ready will retry registration. + } + }; + webview.addEventListener("dom-ready", register); + register(); + return () => webview.removeEventListener("dom-ready", register); + }, [config, tabId]); + + if (!config) return null; + const active = presentation?.visible === true && presentation.rect !== null; + const lastRect = presentation?.rect; + const style = + active && lastRect + ? { + left: lastRect.x, + top: lastRect.y, + width: lastRect.width, + height: lastRect.height, + zIndex: 30, + pointerEvents: "auto" as const, + } + : { + left: 0, + top: 0, + width: lastRect?.width ?? 1280, + height: lastRect?.height ?? 800, + zIndex: recording ? 0 : -1, + pointerEvents: "none" as const, + }; + + return ( + <webview + ref={setWebviewRef} + src={initialSrcRef.current} + partition={config.partition} + webpreferences={config.webPreferences} + {...(config.preloadUrl ? { preload: config.preloadUrl } : {})} + data-preview-tab={tabId} + aria-hidden={active ? undefined : true} + className="fixed flex overflow-hidden bg-background" + style={style} + /> + ); +} diff --git a/apps/web/src/browser/browserRecording.ts b/apps/web/src/browser/browserRecording.ts new file mode 100644 index 00000000000..8a1c6f41327 --- /dev/null +++ b/apps/web/src/browser/browserRecording.ts @@ -0,0 +1,115 @@ +import type { + DesktopPreviewRecordingArtifact, + DesktopPreviewRecordingFrame, +} from "@t3tools/contracts"; +import { create } from "zustand"; + +import { previewBridge } from "~/components/preview/previewBridge"; +import { useBrowserSurfaceStore } from "./browserSurfaceStore"; + +interface ActiveRecording { + readonly tabId: string; + readonly canvas: HTMLCanvasElement; + readonly context: CanvasRenderingContext2D; + readonly recorder: MediaRecorder; + readonly chunks: Blob[]; + readonly mimeType: string; + readonly startedAt: string; +} + +interface BrowserRecordingState { + activeTabId: string | null; + startedAt: string | null; + lastArtifact: DesktopPreviewRecordingArtifact | null; + setActive: (tabId: string | null, startedAt: string | null) => void; + setArtifact: (artifact: DesktopPreviewRecordingArtifact) => void; +} + +export const useBrowserRecordingStore = create<BrowserRecordingState>()((set) => ({ + activeTabId: null, + startedAt: null, + lastArtifact: null, + setActive: (activeTabId, startedAt) => set({ activeTabId, startedAt }), + setArtifact: (lastArtifact) => set({ lastArtifact }), +})); + +let active: ActiveRecording | null = null; +let unsubscribeFrames: (() => void) | null = null; + +const preferredMimeType = (): string => { + const candidates = ["video/mp4;codecs=avc1.42E01E", "video/webm;codecs=vp9", "video/webm"]; + return candidates.find((candidate) => MediaRecorder.isTypeSupported(candidate)) ?? "video/webm"; +}; + +const drawFrame = (frame: DesktopPreviewRecordingFrame): void => { + const recording = active; + if (!recording || recording.tabId !== frame.tabId) return; + const image = new Image(); + image.addEventListener( + "load", + () => { + if (active !== recording) return; + recording.context.drawImage(image, 0, 0, recording.canvas.width, recording.canvas.height); + }, + { once: true }, + ); + image.src = `data:image/jpeg;base64,${frame.data}`; +}; + +export async function startBrowserRecording(tabId: string): Promise<void> { + const bridge = previewBridge; + if (!bridge || active) return; + const rect = useBrowserSurfaceStore.getState().byTabId[tabId]?.rect; + const canvas = document.createElement("canvas"); + canvas.width = Math.max(1, rect?.width ?? 1280); + canvas.height = Math.max(1, rect?.height ?? 800); + const context = canvas.getContext("2d", { alpha: false }); + if (!context) throw new Error("Browser recording canvas is unavailable."); + const mimeType = preferredMimeType(); + const recorder = new MediaRecorder(canvas.captureStream(12), { + mimeType, + videoBitsPerSecond: 4_000_000, + }); + const startedAt = new Date().toISOString(); + const chunks: Blob[] = []; + recorder.addEventListener("dataavailable", (event) => { + if (event.data.size > 0) chunks.push(event.data); + }); + active = { tabId, canvas, context, recorder, chunks, mimeType, startedAt }; + unsubscribeFrames ??= bridge.recording.onFrame(drawFrame); + recorder.start(1_000); + try { + await bridge.recording.startScreencast(tabId); + useBrowserRecordingStore.getState().setActive(tabId, startedAt); + } catch (error) { + active = null; + recorder.stop(); + throw error; + } +} + +export async function stopBrowserRecording( + tabId: string, +): Promise<DesktopPreviewRecordingArtifact | null> { + const bridge = previewBridge; + const recording = active; + if (!bridge || !recording || recording.tabId !== tabId) return null; + await bridge.recording.stopScreencast(tabId); + const stopped = new Promise<void>((resolve) => + recording.recorder.addEventListener("stop", () => resolve(), { once: true }), + ); + recording.recorder.stop(); + await stopped; + const blob = new Blob(recording.chunks, { type: recording.mimeType }); + const artifact = await bridge.recording.save( + tabId, + recording.mimeType, + new Uint8Array(await blob.arrayBuffer()), + ); + active = null; + unsubscribeFrames?.(); + unsubscribeFrames = null; + useBrowserRecordingStore.getState().setActive(null, null); + useBrowserRecordingStore.getState().setArtifact(artifact); + return artifact; +} diff --git a/apps/web/src/browser/browserSurfaceStore.ts b/apps/web/src/browser/browserSurfaceStore.ts new file mode 100644 index 00000000000..64fd8e2df2b --- /dev/null +++ b/apps/web/src/browser/browserSurfaceStore.ts @@ -0,0 +1,53 @@ +import { create } from "zustand"; + +export interface BrowserSurfaceRect { + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; +} + +export interface BrowserSurfacePresentation { + readonly rect: BrowserSurfaceRect | null; + readonly visible: boolean; + readonly updatedAt: number; +} + +interface BrowserSurfaceStoreState { + readonly byTabId: Record<string, BrowserSurfacePresentation>; + readonly present: (tabId: string, rect: BrowserSurfaceRect, visible: boolean) => void; + readonly hide: (tabId: string) => void; +} + +const rectEquals = (left: BrowserSurfaceRect | null, right: BrowserSurfaceRect): boolean => + left !== null && + left.x === right.x && + left.y === right.y && + left.width === right.width && + left.height === right.height; + +export const useBrowserSurfaceStore = create<BrowserSurfaceStoreState>()((set) => ({ + byTabId: {}, + present: (tabId, rect, visible) => + set((state) => { + const current = state.byTabId[tabId]; + if (current && current.visible === visible && rectEquals(current.rect, rect)) return state; + return { + byTabId: { + ...state.byTabId, + [tabId]: { rect, visible, updatedAt: Date.now() }, + }, + }; + }), + hide: (tabId) => + set((state) => { + const current = state.byTabId[tabId]; + if (!current || !current.visible) return state; + return { + byTabId: { + ...state.byTabId, + [tabId]: { ...current, visible: false, updatedAt: Date.now() }, + }, + }; + }), +})); diff --git a/apps/web/src/browser/browserTargetResolver.test.ts b/apps/web/src/browser/browserTargetResolver.test.ts new file mode 100644 index 00000000000..a50275eb8c0 --- /dev/null +++ b/apps/web/src/browser/browserTargetResolver.test.ts @@ -0,0 +1,81 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const readEnvironmentConnection = vi.fn(); + +vi.mock("~/environments/runtime", () => ({ readEnvironmentConnection })); + +describe("browser target resolver", () => { + beforeEach(() => readEnvironmentConnection.mockReset()); + + it("maps environment ports onto a private network host", async () => { + readEnvironmentConnection.mockReturnValue({ + knownEnvironment: { target: { httpBaseUrl: "http://192.168.1.25:3773" } }, + }); + const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); + expect( + resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { + kind: "environment-port", + port: 5173, + path: "/dashboard", + }), + ).toEqual({ + requestedUrl: "http://localhost:5173/dashboard", + resolvedUrl: "http://192.168.1.25:5173/dashboard", + resolutionKind: "direct-private-network", + environmentId: "environment-1", + }); + }); + + it("refuses public relay hosts until the authenticated gateway exists", async () => { + readEnvironmentConnection.mockReturnValue({ + knownEnvironment: { target: { httpBaseUrl: "https://relay.example.com" } }, + }); + const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); + expect(() => + resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { + kind: "environment-port", + port: 5173, + }), + ).toThrow(/authenticated preview gateway/); + }); + + it("normalizes schemeless localhost server-picker values", async () => { + readEnvironmentConnection.mockReturnValue({ + knownEnvironment: { target: { httpBaseUrl: "http://localhost:3773" } }, + }); + const { resolveDiscoveredServerUrl } = await import("./browserTargetResolver"); + expect(resolveDiscoveredServerUrl(EnvironmentId.make("environment-1"), "localhost:5173")).toBe( + "http://localhost:5173/", + ); + expect( + resolveDiscoveredServerUrl(EnvironmentId.make("environment-1"), "0.0.0.0:3000/app"), + ).toBe("http://localhost:3000/app"); + }); + + it("normalizes public URLs without treating them as environment ports", async () => { + const { resolveDiscoveredServerUrl } = await import("./browserTargetResolver"); + expect(resolveDiscoveredServerUrl(EnvironmentId.make("environment-1"), "example.com/app")).toBe( + "https://example.com/app", + ); + }); + + it("supports private IPv6 environment hosts", async () => { + readEnvironmentConnection.mockReturnValue({ + knownEnvironment: { target: { httpBaseUrl: "http://[::1]:3773" } }, + }); + const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); + expect( + resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { + kind: "environment-port", + port: 5173, + path: "/app?mode=test", + }).resolvedUrl, + ).toBe("http://[::1]:5173/app?mode=test"); + }); + + it("leaves malformed input for the normal navigation error path", async () => { + const { resolveDiscoveredServerUrl } = await import("./browserTargetResolver"); + expect(resolveDiscoveredServerUrl(EnvironmentId.make("environment-1"), " ")).toBe(" "); + }); +}); diff --git a/apps/web/src/browser/browserTargetResolver.ts b/apps/web/src/browser/browserTargetResolver.ts new file mode 100644 index 00000000000..12276673002 --- /dev/null +++ b/apps/web/src/browser/browserTargetResolver.ts @@ -0,0 +1,81 @@ +import type { + BrowserNavigationTarget, + EnvironmentId, + PreviewUrlResolution, +} from "@t3tools/contracts"; +import { isLoopbackHost, normalizePreviewUrl } from "@t3tools/shared/preview"; + +import { readEnvironmentConnection } from "~/environments/runtime"; + +const isPrivateNetworkHost = (host: string): boolean => { + const normalized = host.toLowerCase().replace(/^\[|\]$/g, ""); + if (normalized === "localhost" || normalized === "::1" || normalized.endsWith(".local")) { + return true; + } + if (normalized.endsWith(".ts.net")) return true; + const parts = normalized.split(".").map(Number); + if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part))) return false; + return ( + parts[0] === 10 || + (parts[0] === 172 && parts[1]! >= 16 && parts[1]! <= 31) || + (parts[0] === 192 && parts[1] === 168) || + parts[0] === 127 || + (parts[0] === 169 && parts[1] === 254) + ); +}; + +export function resolveBrowserNavigationTarget( + environmentId: EnvironmentId, + target: BrowserNavigationTarget, +): PreviewUrlResolution { + if (target.kind === "url") { + return { + requestedUrl: target.url, + resolvedUrl: target.url, + resolutionKind: "direct", + environmentId, + }; + } + const connection = readEnvironmentConnection(environmentId); + if (!connection) throw new Error(`Environment ${environmentId} is not connected.`); + const environmentUrl = new URL(connection.knownEnvironment.target.httpBaseUrl); + if (!isPrivateNetworkHost(environmentUrl.hostname)) { + throw new Error( + "This environment port needs the planned authenticated preview gateway; its server address is not directly private-network reachable.", + ); + } + const protocol = target.protocol ?? "http"; + const path = target.path?.startsWith("/") ? target.path : `/${target.path ?? ""}`; + const requestedUrl = `${protocol}://localhost:${target.port}${path}`; + const normalizedEnvironmentHost = environmentUrl.hostname.replace(/^\[|\]$/g, ""); + const resolvedHost = normalizedEnvironmentHost.includes(":") + ? `[${normalizedEnvironmentHost}]` + : normalizedEnvironmentHost; + const resolved = new URL(path, `${protocol}://${resolvedHost}:${target.port}`); + return { + requestedUrl, + resolvedUrl: resolved.toString(), + resolutionKind: + normalizedEnvironmentHost === "localhost" || normalizedEnvironmentHost === "127.0.0.1" + ? "direct" + : "direct-private-network", + environmentId, + }; +} + +export function resolveDiscoveredServerUrl(environmentId: EnvironmentId, rawUrl: string): string { + try { + const normalizedUrl = normalizePreviewUrl(rawUrl); + const parsed = new URL(normalizedUrl); + if (!isLoopbackHost(parsed.hostname)) return normalizedUrl; + const port = Number(parsed.port || (parsed.protocol === "https:" ? 443 : 80)); + return resolveBrowserNavigationTarget(environmentId, { + kind: "environment-port", + port, + protocol: parsed.protocol === "https:" ? "https" : "http", + path: `${parsed.pathname}${parsed.search}${parsed.hash}`, + }).resolvedUrl; + } catch { + return rawUrl; + } +} diff --git a/apps/web/src/browser/desktopTabLifetime.ts b/apps/web/src/browser/desktopTabLifetime.ts new file mode 100644 index 00000000000..4254c7e6afc --- /dev/null +++ b/apps/web/src/browser/desktopTabLifetime.ts @@ -0,0 +1,30 @@ +import { previewBridge } from "~/components/preview/previewBridge"; + +interface DesktopTabLease { + references: number; + closeTimer: number | null; +} + +const leases = new Map<string, DesktopTabLease>(); + +export function acquireDesktopTab(tabId: string): () => void { + const current = leases.get(tabId) ?? { references: 0, closeTimer: null }; + if (current.closeTimer !== null) window.clearTimeout(current.closeTimer); + current.references += 1; + current.closeTimer = null; + leases.set(tabId, current); + if (current.references === 1) void previewBridge?.createTab(tabId); + + return () => { + const lease = leases.get(tabId); + if (!lease) return; + lease.references = Math.max(0, lease.references - 1); + if (lease.references > 0) return; + lease.closeTimer = window.setTimeout(() => { + const latest = leases.get(tabId); + if (!latest || latest.references > 0) return; + leases.delete(tabId); + void previewBridge?.closeTab(tabId); + }, 0); + }; +} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 4dd6d9003dd..0bda0fb7edd 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -21,12 +21,7 @@ import { RuntimeMode, TerminalOpenInput, } from "@t3tools/contracts"; -import { - parseScopedThreadKey, - scopedThreadKey, - scopeProjectRef, - scopeThreadRef, -} from "@t3tools/client-runtime"; +import { scopedThreadKey, scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime"; import { applyClaudePromptEffortPrefix, createModelSelection, @@ -43,6 +38,7 @@ import { useVcsStatus } from "~/lib/vcsStatusState"; import { usePrimaryEnvironmentId } from "../environments/primary"; import { readEnvironmentApi } from "../environmentApi"; import { isElectron } from "../env"; +import { PreviewAutomationOwner } from "./preview/PreviewAutomationOwner"; import { readLocalApi } from "../localApi"; import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; import { @@ -70,11 +66,7 @@ import { togglePendingUserInputOptionSelection, type PendingUserInputDraftAnswer, } from "../pendingUserInput"; -import { - selectProjectsAcrossEnvironments, - selectThreadsAcrossEnvironments, - useStore, -} from "../store"; +import { selectProjectsAcrossEnvironments, useStore } from "../store"; import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors"; import { useUiStateStore } from "../uiStateStore"; import { @@ -98,20 +90,32 @@ import { useCommandPaletteStore } from "../commandPaletteStore"; import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; import { useMediaQuery } from "../hooks/useMediaQuery"; import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout"; -import { selectActiveRightPanelKindWithUrl, useRightPanelStore } from "../rightPanelStore"; -import { PreviewAutomationOwner } from "./preview/PreviewAutomationOwner"; -import { isPreviewSupportedInRuntime, usePreviewStateStore } from "../previewStateStore"; +import { + selectActiveRightPanelKindWithUrl, + selectActiveRightPanelSurface, + selectThreadRightPanelState, + type RightPanelSurface, + useRightPanelStore, +} from "../rightPanelStore"; +import { + isPreviewSupportedInRuntime, + selectThreadPreviewState, + usePreviewStateStore, +} from "../previewStateStore"; import { subscribePreviewAction } from "./preview/previewActionBus"; // Lazy: keeps the entire preview component graph (webview host, favicon // helper, Chromium error icon) out of the web bundle until first open. const PreviewPanel = lazy(() => import("./preview/PreviewPanel").then((mod) => ({ default: mod.PreviewPanel })), ); +const DiffPanel = lazy(() => import("./DiffPanel")); import { BranchToolbar } from "./BranchToolbar"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import PlanSidebar from "./PlanSidebar"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; import { ChevronDownIcon, TriangleAlertIcon, WifiOffIcon } from "lucide-react"; +import { RightPanelTabs } from "./RightPanelTabs"; +import { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; import { cn, randomHex } from "~/lib/utils"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; @@ -167,8 +171,6 @@ import { ProviderStatusBanner } from "./chat/ProviderStatusBanner"; import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack"; import { - MAX_HIDDEN_MOUNTED_PREVIEW_THREADS, - MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, buildExpiredTerminalContextToastCopy, buildLocalDraftThread, collectUserMessageBlobPreviewUrls, @@ -183,8 +185,6 @@ import { cloneComposerImageForRetry, deriveLockedProvider, readFileAsDataUrl, - reconcileMountedTerminalThreadIds, - reconcileRetainedMountedThreadIds, resolveSendEnvMode, revokeBlobPreviewUrl, revokeUserMessagePreviewUrls, @@ -535,6 +535,7 @@ interface PersistentThreadTerminalDrawerProps { threadRef: { environmentId: EnvironmentId; threadId: ThreadId }; threadId: ThreadId; visible: boolean; + mode?: "drawer" | "panel"; launchContext: PersistentTerminalLaunchContext | null; focusRequestId: number; splitShortcutLabel: string | undefined; @@ -548,6 +549,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra threadRef, threadId, visible, + mode = "drawer", launchContext, focusRequestId, splitShortcutLabel, @@ -795,8 +797,9 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra } return ( - <div className={visible ? undefined : "hidden"}> + <div className={cn(visible ? undefined : "hidden", mode === "panel" && "h-full min-h-0")}> <ThreadTerminalDrawer + mode={mode} threadRef={threadRef} threadId={threadId} cwd={cwd} @@ -962,59 +965,11 @@ export default function ChatView(props: ChatViewProps) { const terminalUiState = useTerminalUiStateStore((state) => selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef), ); - const openTerminalThreadKeys = useTerminalUiStateStore( - useShallow((state) => - Object.entries(state.terminalUiStateByThreadKey).flatMap( - ([nextThreadKey, nextTerminalUiState]) => - nextTerminalUiState.terminalOpen ? [nextThreadKey] : [], - ), - ), - ); const storeSetTerminalOpen = useTerminalUiStateStore((s) => s.setTerminalOpen); const storeSplitTerminal = useTerminalUiStateStore((s) => s.splitTerminal); const storeNewTerminal = useTerminalUiStateStore((s) => s.newTerminal); const storeSetActiveTerminal = useTerminalUiStateStore((s) => s.setActiveTerminal); const storeCloseTerminal = useTerminalUiStateStore((s) => s.closeTerminal); - const serverThreadKeys = useStore( - useShallow((state) => - selectThreadsAcrossEnvironments(state).map((thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - ), - ), - ); - const draftThreadsByThreadKey = useComposerDraftStore((store) => store.draftThreadsByThreadKey); - const draftThreadKeys = useMemo( - () => - Object.values(draftThreadsByThreadKey).map((draftThread) => - scopedThreadKey(scopeThreadRef(draftThread.environmentId, draftThread.threadId)), - ), - [draftThreadsByThreadKey], - ); - const [mountedTerminalThreadKeys, setMountedTerminalThreadKeys] = useState<string[]>([]); - const mountedTerminalThreadRefs = useMemo( - () => - mountedTerminalThreadKeys.flatMap((mountedThreadKey) => { - const mountedThreadRef = parseScopedThreadKey(mountedThreadKey); - return mountedThreadRef ? [{ key: mountedThreadKey, threadRef: mountedThreadRef }] : []; - }), - [mountedTerminalThreadKeys], - ); - const previewSessionThreadKeys = usePreviewStateStore( - useShallow((state) => - Object.entries(state.byThreadKey).flatMap(([nextThreadKey, nextPreviewState]) => - nextPreviewState.snapshot ? [nextThreadKey] : [], - ), - ), - ); - const [mountedPreviewThreadKeys, setMountedPreviewThreadKeys] = useState<string[]>([]); - const mountedPreviewThreadRefs = useMemo( - () => - mountedPreviewThreadKeys.flatMap((mountedThreadKey) => { - const mountedThreadRef = parseScopedThreadKey(mountedThreadKey); - return mountedThreadRef ? [{ key: mountedThreadKey, threadRef: mountedThreadRef }] : []; - }), - [mountedPreviewThreadKeys], - ); const fallbackDraftProjectRef = draftThread ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) @@ -1105,18 +1060,40 @@ export default function ChatView(props: ChatViewProps) { const activeRightPanelKind = useRightPanelStore((store) => selectActiveRightPanelKindWithUrl(store.byThreadKey, activeThreadRef, diffOpen), ); + const rightPanelState = useRightPanelStore((store) => + selectThreadRightPanelState(store.byThreadKey, activeThreadRef), + ); + const activeRightPanelSurface = useRightPanelStore((store) => + selectActiveRightPanelSurface(store.byThreadKey, activeThreadRef), + ); + const activePreviewState = usePreviewStateStore((state) => + selectThreadPreviewState(state.byThreadKey, activeThreadRef), + ); const planSidebarOpen = activeRightPanelKind === "plan"; const previewPanelOpen = activeRightPanelKind === "preview" && isPreviewSupportedInRuntime(); - const existingOpenTerminalThreadKeys = useMemo(() => { - const existingThreadKeys = new Set<string>([...serverThreadKeys, ...draftThreadKeys]); - return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey)); - }, [draftThreadKeys, openTerminalThreadKeys, serverThreadKeys]); - const existingPreviewSessionThreadKeys = useMemo(() => { - const existingThreadKeys = new Set<string>([...serverThreadKeys, ...draftThreadKeys]); - return previewSessionThreadKeys.filter((nextThreadKey) => - existingThreadKeys.has(nextThreadKey), + const terminalPanelOpen = activeRightPanelKind === "terminal"; + const rightPanelOpen = activeRightPanelSurface !== null; + + useEffect(() => { + if (!activeThreadRef) return; + useRightPanelStore + .getState() + .reconcileBrowserSurfaces(activeThreadRef, Object.keys(activePreviewState.sessions)); + }, [activePreviewState.sessions, activeThreadRef]); + + useEffect(() => { + if (!activeThreadRef || !diffOpen) return; + useRightPanelStore.getState().open(activeThreadRef, "diff"); + }, [activeThreadRef, diffOpen]); + useEffect(() => { + if (!activeThreadRef || !terminalUiState.terminalOpen) return; + const state = selectThreadRightPanelState( + useRightPanelStore.getState().byThreadKey, + activeThreadRef, ); - }, [draftThreadKeys, previewSessionThreadKeys, serverThreadKeys]); + if (state.surfaces.some((surface) => surface.kind === "terminal")) return; + useRightPanelStore.getState().open(activeThreadRef, "terminal"); + }, [activeThreadRef, terminalUiState.terminalOpen]); const activeLatestTurn = activeThread?.latestTurn ?? null; const threadPlanCatalog = useThreadPlanCatalog( useMemo(() => { @@ -1131,42 +1108,6 @@ export default function ChatView(props: ChatViewProps) { return threadIds; }, [activeLatestTurn?.sourceProposedPlan?.threadId, activeThread?.id]), ); - useEffect(() => { - setMountedTerminalThreadKeys((currentThreadIds) => { - const nextThreadIds = reconcileMountedTerminalThreadIds({ - currentThreadIds, - openThreadIds: existingOpenTerminalThreadKeys, - activeThreadId: activeThreadKey, - activeThreadTerminalOpen: Boolean(activeThreadKey && terminalUiState.terminalOpen), - maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, - }); - return currentThreadIds.length === nextThreadIds.length && - currentThreadIds.every((nextThreadId, index) => nextThreadId === nextThreadIds[index]) - ? currentThreadIds - : nextThreadIds; - }); - }, [activeThreadKey, existingOpenTerminalThreadKeys, terminalUiState.terminalOpen]); - useEffect(() => { - setMountedPreviewThreadKeys((currentThreadIds) => { - const nextThreadIds = reconcileRetainedMountedThreadIds({ - currentThreadIds, - openThreadIds: existingPreviewSessionThreadKeys, - activeThreadId: activeThreadKey, - activeThreadOpen: Boolean(activeThreadKey && !shouldUsePlanSidebarSheet), - maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_PREVIEW_THREADS, - retainInactiveActiveThread: true, - }); - return currentThreadIds.length === nextThreadIds.length && - currentThreadIds.every((nextThreadId, index) => nextThreadId === nextThreadIds[index]) - ? currentThreadIds - : nextThreadIds; - }); - }, [ - activeThreadKey, - existingPreviewSessionThreadKeys, - previewPanelOpen, - shouldUsePlanSidebarSheet, - ]); const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); const activeProjectRef = activeThread ? scopeProjectRef(activeThread.environmentId, activeThread.projectId) @@ -1961,19 +1902,6 @@ export default function ChatView(props: ChatViewProps) { }), [terminalUiState.terminalOpen], ); - const nonTerminalShortcutLabelOptions = useMemo( - () => ({ - context: { - terminalFocus: false, - terminalOpen: Boolean(terminalUiState.terminalOpen), - }, - }), - [terminalUiState.terminalOpen], - ); - const terminalToggleShortcutLabel = useMemo( - () => shortcutLabelForCommand(keybindings, "terminal.toggle"), - [keybindings], - ); const splitTerminalShortcutLabel = useMemo( () => shortcutLabelForCommand(keybindings, "terminal.split", terminalShortcutLabelOptions), [keybindings, terminalShortcutLabelOptions], @@ -1986,14 +1914,37 @@ export default function ChatView(props: ChatViewProps) { () => shortcutLabelForCommand(keybindings, "terminal.close", terminalShortcutLabelOptions), [keybindings, terminalShortcutLabelOptions], ); - const diffPanelShortcutLabel = useMemo( - () => shortcutLabelForCommand(keybindings, "diff.toggle", nonTerminalShortcutLabelOptions), - [keybindings, nonTerminalShortcutLabelOptions], - ); - const previewPanelShortcutLabel = useMemo( - () => shortcutLabelForCommand(keybindings, "preview.toggle", nonTerminalShortcutLabelOptions), - [keybindings, nonTerminalShortcutLabelOptions], - ); + const createBrowserSurface = useCallback(() => { + if (!activeThreadRef) return; + const api = readEnvironmentApi(activeThreadRef.environmentId); + if (!api) return; + void api.preview + .open({ threadId: activeThreadRef.threadId }) + .then((snapshot) => { + usePreviewStateStore.getState().applyServerSnapshot(activeThreadRef, snapshot); + useRightPanelStore.getState().openBrowser(activeThreadRef, snapshot.tabId); + }) + .catch(() => undefined); + }, [activeThreadRef]); + const addDiffSurface = useCallback(() => { + if (!activeThreadRef || !isServerThread || !isGitRepo) return; + useRightPanelStore.getState().open(activeThreadRef, "diff"); + onDiffPanelOpen?.(); + void navigate({ + to: "/$environmentId/$threadId", + params: { environmentId, threadId }, + replace: true, + search: (previous) => ({ ...stripDiffSearchParams(previous), diff: "1" }), + }); + }, [ + activeThreadRef, + environmentId, + isGitRepo, + isServerThread, + navigate, + onDiffPanelOpen, + threadId, + ]); // Right-panel arbitration: // - The diff panel's openness is mirrored by the `?diff=1` URL search // param so it deep-links cleanly. The store still records preview/plan @@ -2017,20 +1968,33 @@ export default function ChatView(props: ChatViewProps) { search: (previous) => ({ ...stripDiffSearchParams(previous), diff: undefined }), }); } - useRightPanelStore.getState().open(activeThreadRef, "preview"); - }, [activeThreadRef, diffOpen, environmentId, navigate, previewPanelOpen, threadId]); + const activeTabId = activePreviewState.activeTabId; + if (!activeTabId) { + createBrowserSurface(); + return; + } + useRightPanelStore.getState().openBrowser(activeThreadRef, activeTabId); + }, [ + activePreviewState.activeTabId, + activeThreadRef, + createBrowserSurface, + diffOpen, + environmentId, + navigate, + previewPanelOpen, + threadId, + ]); const onToggleDiff = useCallback(() => { if (!isServerThread) { return; } if (!diffOpen) { onDiffPanelOpen?.(); - // Switching to diff: drop whatever the store had (e.g. preview) so - // that when the user later closes diff, an old store entry doesn't - // unexpectedly resurface. if (activeThreadRef) { - useRightPanelStore.getState().close(activeThreadRef); + useRightPanelStore.getState().open(activeThreadRef, "diff"); } + } else if (activeThreadRef) { + useRightPanelStore.getState().closeSurface(activeThreadRef, "diff"); } void navigate({ to: "/$environmentId/$threadId", @@ -2147,8 +2111,13 @@ export default function ChatView(props: ChatViewProps) { ); const toggleTerminalVisibility = useCallback(() => { if (!activeThreadRef) return; - setTerminalOpen(!terminalUiState.terminalOpen); - }, [activeThreadRef, setTerminalOpen, terminalUiState.terminalOpen]); + if (terminalPanelOpen) { + useRightPanelStore.getState().close(activeThreadRef); + return; + } + setTerminalOpen(true); + useRightPanelStore.getState().open(activeThreadRef, "terminal"); + }, [activeThreadRef, setTerminalOpen, terminalPanelOpen]); const splitTerminal = useCallback(() => { if (!activeThreadRef || hasReachedSplitLimit || !activeThreadId || !activeProject) { return; @@ -2348,8 +2317,12 @@ export default function ChatView(props: ChatViewProps) { activeThreadRef ) { try { - await api.preview.open({ threadId: activeThreadId, url: script.previewUrl }); - useRightPanelStore.getState().open(activeThreadRef, "preview"); + const snapshot = await api.preview.open({ + threadId: activeThreadId, + url: script.previewUrl, + }); + usePreviewStateStore.getState().applyServerSnapshot(activeThreadRef, snapshot); + useRightPanelStore.getState().openBrowser(activeThreadRef, snapshot.tabId); } catch { // Preview open failures are surfaced via the panel itself. } @@ -2579,8 +2552,96 @@ export default function ChatView(props: ChatViewProps) { const closePreviewPanel = useCallback(() => { if (!activeThreadRef) return; useRightPanelStore.getState().close(activeThreadRef); - }, [activeThreadRef]); - + if (diffOpen) { + void navigate({ + to: "/$environmentId/$threadId", + params: { environmentId, threadId }, + replace: true, + search: (previous) => ({ ...stripDiffSearchParams(previous), diff: undefined }), + }); + } + }, [activeThreadRef, diffOpen, environmentId, navigate, threadId]); + const activateRightPanelSurface = useCallback( + (surface: RightPanelSurface) => { + if (!activeThreadRef) return; + useRightPanelStore.getState().activateSurface(activeThreadRef, surface.id); + if (surface.kind === "preview" && surface.resourceId) { + usePreviewStateStore.getState().setActiveTab(activeThreadRef, surface.resourceId); + } + if (surface.kind === "terminal") { + setTerminalOpen(true); + } + if (surface.kind === "diff" && !diffOpen) { + onDiffPanelOpen?.(); + void navigate({ + to: "/$environmentId/$threadId", + params: { environmentId, threadId }, + replace: true, + search: (previous) => ({ ...stripDiffSearchParams(previous), diff: "1" }), + }); + } else if (surface.kind !== "diff" && diffOpen) { + void navigate({ + to: "/$environmentId/$threadId", + params: { environmentId, threadId }, + replace: true, + search: (previous) => ({ ...stripDiffSearchParams(previous), diff: undefined }), + }); + } + }, + [ + activeThreadRef, + diffOpen, + environmentId, + navigate, + onDiffPanelOpen, + setTerminalOpen, + threadId, + ], + ); + const toggleRightPanel = useCallback(() => { + if (!activeThreadRef) return; + if (rightPanelOpen) { + closePreviewPanel(); + return; + } + const surface = rightPanelState.surfaces.at(-1) ?? null; + if (surface) { + activateRightPanelSurface(surface); + return; + } + createBrowserSurface(); + }, [ + activeThreadRef, + activateRightPanelSurface, + closePreviewPanel, + createBrowserSurface, + rightPanelOpen, + rightPanelState.surfaces, + ]); + const closeRightPanelSurface = useCallback( + (surface: RightPanelSurface) => { + if (!activeThreadRef) return; + useRightPanelStore.getState().closeSurface(activeThreadRef, surface.id); + if (surface.kind === "preview" && surface.resourceId) { + const api = readEnvironmentApi(activeThreadRef.environmentId); + void api?.preview + .close({ threadId: activeThreadRef.threadId, tabId: surface.resourceId }) + .catch(() => undefined); + } + if (surface.kind === "terminal") { + setTerminalOpen(false); + } + if (surface.kind === "diff" && diffOpen) { + void navigate({ + to: "/$environmentId/$threadId", + params: { environmentId, threadId }, + replace: true, + search: (previous) => ({ ...stripDiffSearchParams(previous), diff: undefined }), + }); + } + }, + [activeThreadRef, diffOpen, environmentId, navigate, setTerminalOpen, threadId], + ); const persistThreadSettingsForNextTurn = useCallback( async (input: { threadId: ThreadId; @@ -3952,314 +4013,323 @@ export default function ChatView(props: ChatViewProps) { } return ( - <div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-x-hidden bg-background"> - {/* Top bar */} - <header - className={cn( - "border-b border-border", - isElectron - ? cn( - "drag-region flex h-[52px] items-center px-3 sm:px-5 wco:h-[env(titlebar-area-height)]", - reserveTitleBarControlInset && - "wco:pr-[calc(100vw-env(titlebar-area-width)-env(titlebar-area-x)+1em)]", - ) - : "pb-2 pl-[calc(env(safe-area-inset-left)+0.75rem)] pr-[calc(env(safe-area-inset-right)+0.75rem)] pt-2 sm:pb-3 sm:pl-[calc(env(safe-area-inset-left)+1.25rem)] sm:pr-[calc(env(safe-area-inset-right)+1.25rem)] sm:pt-3", - )} - > - <ChatHeader - activeThreadEnvironmentId={activeThread.environmentId} - activeThreadId={activeThread.id} - {...(routeKind === "draft" && draftId ? { draftId } : {})} - activeThreadTitle={activeThread.title} - activeProjectName={activeProject?.name} - isGitRepo={isGitRepo} - openInCwd={gitCwd} - activeProjectScripts={activeProject?.scripts} - preferredScriptId={ - activeProject ? (lastInvokedScriptByProjectId[activeProject.id] ?? null) : null - } - keybindings={keybindings} - availableEditors={availableEditors} - terminalAvailable={activeProject !== undefined} - terminalOpen={terminalUiState.terminalOpen} - terminalToggleShortcutLabel={terminalToggleShortcutLabel} - diffToggleShortcutLabel={diffPanelShortcutLabel} - previewAvailable={isPreviewSupportedInRuntime()} - previewOpen={previewPanelOpen} - previewToggleShortcutLabel={previewPanelShortcutLabel} - gitCwd={gitCwd} - diffOpen={diffOpen} - onRunProjectScript={runProjectScript} - onAddProjectScript={saveProjectScript} - onUpdateProjectScript={updateProjectScript} - onDeleteProjectScript={deleteProjectScript} - onToggleTerminal={toggleTerminalVisibility} - onTogglePreview={onTogglePreview} - onToggleDiff={onToggleDiff} + <div className="flex min-h-0 min-w-0 flex-1 overflow-hidden bg-background"> + {isElectron && activeThreadRef ? ( + <PreviewAutomationOwner threadRef={activeThreadRef} visible={previewPanelOpen} /> + ) : null} + <div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-x-hidden"> + {/* Top bar */} + <header + className={cn( + "border-b border-border", + isElectron + ? cn( + "drag-region flex h-[52px] items-center px-3 sm:px-5 wco:h-[env(titlebar-area-height)]", + reserveTitleBarControlInset && + "wco:pr-[calc(100vw-env(titlebar-area-width)-env(titlebar-area-x)+1em)]", + ) + : "pb-2 pl-[calc(env(safe-area-inset-left)+0.75rem)] pr-[calc(env(safe-area-inset-right)+0.75rem)] pt-2 sm:pb-3 sm:pl-[calc(env(safe-area-inset-left)+1.25rem)] sm:pr-[calc(env(safe-area-inset-right)+1.25rem)] sm:pt-3", + )} + > + <ChatHeader + activeThreadEnvironmentId={activeThread.environmentId} + activeThreadId={activeThread.id} + {...(routeKind === "draft" && draftId ? { draftId } : {})} + activeThreadTitle={activeThread.title} + activeProjectName={activeProject?.name} + openInCwd={gitCwd} + activeProjectScripts={activeProject?.scripts} + preferredScriptId={ + activeProject ? (lastInvokedScriptByProjectId[activeProject.id] ?? null) : null + } + keybindings={keybindings} + availableEditors={availableEditors} + rightPanelAvailable={isPreviewSupportedInRuntime() || isGitRepo} + rightPanelOpen={rightPanelOpen} + gitCwd={gitCwd} + onRunProjectScript={runProjectScript} + onAddProjectScript={saveProjectScript} + onUpdateProjectScript={updateProjectScript} + onDeleteProjectScript={deleteProjectScript} + onToggleRightPanel={toggleRightPanel} + /> + </header> + + {/* Error banner */} + <ProviderStatusBanner status={activeProviderStatus} /> + <ThreadErrorBanner + error={activeThread.error} + onDismiss={() => setThreadError(activeThread.id, null)} /> - </header> + {/* Main content area with optional plan sidebar */} + <div className="flex min-h-0 min-w-0 flex-1"> + {/* Chat column */} + <div className="flex min-h-0 min-w-0 flex-1 flex-col"> + {/* Messages Wrapper */} + <div className="relative flex min-h-0 flex-1 flex-col"> + {/* Messages — LegendList handles virtualization and scrolling internally */} + <MessagesTimeline + key={activeThread.id} + isWorking={isWorking} + activeTurnInProgress={isWorking || !latestTurnSettled} + activeTurnStartedAt={activeWorkStartedAt} + listRef={legendListRef} + timelineEntries={timelineEntries} + latestTurn={activeLatestTurn} + turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId} + activeThreadEnvironmentId={activeThread.environmentId} + routeThreadKey={routeThreadKey} + onOpenTurnDiff={onOpenTurnDiff} + revertTurnCountByUserMessageId={revertTurnCountByUserMessageId} + onRevertUserMessage={onRevertUserMessage} + isRevertingCheckpoint={isRevertingCheckpoint} + onImageExpand={onExpandTimelineImage} + markdownCwd={gitCwd ?? undefined} + resolvedTheme={resolvedTheme} + timestampFormat={timestampFormat} + workspaceRoot={activeWorkspaceRoot} + skills={activeProviderStatus?.skills ?? EMPTY_PROVIDER_SKILLS} + onIsAtEndChange={onIsAtEndChange} + /> - {/* Error banner */} - <ProviderStatusBanner status={activeProviderStatus} /> - <ThreadErrorBanner - error={activeThread.error} - onDismiss={() => setThreadError(activeThread.id, null)} - /> - {/* Main content area with optional plan sidebar */} - <div className="flex min-h-0 min-w-0 flex-1"> - {/* Chat column */} - <div className="flex min-h-0 min-w-0 flex-1 flex-col"> - {/* Messages Wrapper */} - <div className="relative flex min-h-0 flex-1 flex-col"> - {/* Messages — LegendList handles virtualization and scrolling internally */} - <MessagesTimeline - key={activeThread.id} - isWorking={isWorking} - activeTurnInProgress={isWorking || !latestTurnSettled} - activeTurnStartedAt={activeWorkStartedAt} - listRef={legendListRef} - timelineEntries={timelineEntries} - latestTurn={activeLatestTurn} - turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId} - activeThreadEnvironmentId={activeThread.environmentId} - routeThreadKey={routeThreadKey} - onOpenTurnDiff={onOpenTurnDiff} - revertTurnCountByUserMessageId={revertTurnCountByUserMessageId} - onRevertUserMessage={onRevertUserMessage} - isRevertingCheckpoint={isRevertingCheckpoint} - onImageExpand={onExpandTimelineImage} - markdownCwd={gitCwd ?? undefined} - resolvedTheme={resolvedTheme} - timestampFormat={timestampFormat} - workspaceRoot={activeWorkspaceRoot} - skills={activeProviderStatus?.skills ?? EMPTY_PROVIDER_SKILLS} - onIsAtEndChange={onIsAtEndChange} - /> - - {/* scroll to bottom pill — shown when user has scrolled away from the bottom */} - {showScrollToBottom && ( - <div className="pointer-events-none absolute bottom-1 left-1/2 z-30 flex -translate-x-1/2 justify-center py-1.5"> - <button - type="button" - onClick={() => scrollToEnd(true)} - className="pointer-events-auto flex items-center gap-1.5 rounded-full border border-border/60 bg-card px-3 py-1 text-muted-foreground text-xs shadow-sm transition-colors hover:border-border hover:text-foreground hover:cursor-pointer" - > - <ChevronDownIcon className="size-3.5" /> - Scroll to bottom - </button> - </div> - )} - </div> + {/* scroll to bottom pill — shown when user has scrolled away from the bottom */} + {showScrollToBottom && ( + <div className="pointer-events-none absolute bottom-1 left-1/2 z-30 flex -translate-x-1/2 justify-center py-1.5"> + <button + type="button" + onClick={() => scrollToEnd(true)} + className="pointer-events-auto flex items-center gap-1.5 rounded-full border border-border/60 bg-card px-3 py-1 text-muted-foreground text-xs shadow-sm transition-colors hover:border-border hover:text-foreground hover:cursor-pointer" + > + <ChevronDownIcon className="size-3.5" /> + Scroll to bottom + </button> + </div> + )} + </div> - {/* Input bar */} - <div - className={cn( - "pl-[calc(env(safe-area-inset-left)+0.75rem)] pr-[calc(env(safe-area-inset-right)+0.75rem)] pt-1.5 sm:pl-[calc(env(safe-area-inset-left)+1.25rem)] sm:pr-[calc(env(safe-area-inset-right)+1.25rem)] sm:pt-2", - isGitRepo - ? "pb-[calc(env(safe-area-inset-bottom)+0.25rem)]" - : "pb-[calc(env(safe-area-inset-bottom)+0.75rem)] sm:pb-[calc(env(safe-area-inset-bottom)+1rem)]", - )} - > - <div className="relative isolate"> - <ComposerBannerStack className="relative z-0" items={composerBannerItems} /> - <div className="relative z-10"> - <ChatComposer - composerRef={composerRef} - composerDraftTarget={composerDraftTarget} - environmentId={environmentId} - routeKind={routeKind} - routeThreadRef={routeThreadRef} - draftId={draftId} - activeThreadId={activeThreadId} - activeThreadEnvironmentId={activeThread?.environmentId} - activeThread={activeThread} - isServerThread={isServerThread} - isLocalDraftThread={isLocalDraftThread} - phase={phase} - isConnecting={isConnecting} - isSendBusy={isSendBusy} - isPreparingWorktree={isPreparingWorktree} - environmentUnavailable={activeEnvironmentUnavailableState} - activePendingApproval={activePendingApproval} - pendingApprovals={pendingApprovals} - pendingUserInputs={pendingUserInputs} - activePendingProgress={activePendingProgress} - activePendingResolvedAnswers={activePendingResolvedAnswers} - activePendingIsResponding={activePendingIsResponding} - activePendingDraftAnswers={activePendingDraftAnswers} - activePendingQuestionIndex={activePendingQuestionIndex} - respondingRequestIds={respondingRequestIds} - showPlanFollowUpPrompt={showPlanFollowUpPrompt} - activeProposedPlan={activeProposedPlan} - activePlan={activePlan as { turnId?: TurnId } | null} - sidebarProposedPlan={sidebarProposedPlan as { turnId?: TurnId } | null} - planSidebarLabel={planSidebarLabel} - planSidebarOpen={planSidebarOpen} - runtimeMode={runtimeMode} - interactionMode={interactionMode} - lockedProvider={lockedProvider} - providerStatuses={providerStatuses as ServerProvider[]} - activeProjectDefaultModelSelection={activeProject?.defaultModelSelection} - activeThreadModelSelection={activeThread?.modelSelection} - activeThreadActivities={activeThread?.activities} - resolvedTheme={resolvedTheme} - settings={settings} - keybindings={keybindings} - terminalOpen={Boolean(terminalUiState.terminalOpen)} - gitCwd={gitCwd} - promptRef={promptRef} - composerImagesRef={composerImagesRef} - composerTerminalContextsRef={composerTerminalContextsRef} - composerElementContextsRef={composerElementContextsRef} - shouldAutoScrollRef={isAtEndRef} - scheduleStickToBottom={scrollToEnd} - onSend={onSend} - onInterrupt={onInterrupt} - onImplementPlanInNewThread={onImplementPlanInNewThread} - onRespondToApproval={onRespondToApproval} - onSelectActivePendingUserInputOption={onSelectActivePendingUserInputOption} - onAdvanceActivePendingUserInput={onAdvanceActivePendingUserInput} - onPreviousActivePendingUserInputQuestion={ - onPreviousActivePendingUserInputQuestion - } - onChangeActivePendingUserInputCustomAnswer={ - onChangeActivePendingUserInputCustomAnswer - } - onProviderModelSelect={onProviderModelSelect} - getModelDisabledReason={getModelDisabledReason} - toggleInteractionMode={toggleInteractionMode} - handleRuntimeModeChange={handleRuntimeModeChange} - handleInteractionModeChange={handleInteractionModeChange} - togglePlanSidebar={togglePlanSidebar} - focusComposer={focusComposer} - scheduleComposerFocus={scheduleComposerFocus} - setThreadError={setThreadError} - onExpandImage={onExpandTimelineImage} - /> + {/* Input bar */} + <div + className={cn( + "pl-[calc(env(safe-area-inset-left)+0.75rem)] pr-[calc(env(safe-area-inset-right)+0.75rem)] pt-1.5 sm:pl-[calc(env(safe-area-inset-left)+1.25rem)] sm:pr-[calc(env(safe-area-inset-right)+1.25rem)] sm:pt-2", + isGitRepo + ? "pb-[calc(env(safe-area-inset-bottom)+0.25rem)]" + : "pb-[calc(env(safe-area-inset-bottom)+0.75rem)] sm:pb-[calc(env(safe-area-inset-bottom)+1rem)]", + )} + > + <div className="relative isolate"> + <ComposerBannerStack className="relative z-0" items={composerBannerItems} /> + <div className="relative z-10"> + <ChatComposer + composerRef={composerRef} + composerDraftTarget={composerDraftTarget} + environmentId={environmentId} + routeKind={routeKind} + routeThreadRef={routeThreadRef} + draftId={draftId} + activeThreadId={activeThreadId} + activeThreadEnvironmentId={activeThread?.environmentId} + activeThread={activeThread} + isServerThread={isServerThread} + isLocalDraftThread={isLocalDraftThread} + phase={phase} + isConnecting={isConnecting} + isSendBusy={isSendBusy} + isPreparingWorktree={isPreparingWorktree} + environmentUnavailable={activeEnvironmentUnavailableState} + activePendingApproval={activePendingApproval} + pendingApprovals={pendingApprovals} + pendingUserInputs={pendingUserInputs} + activePendingProgress={activePendingProgress} + activePendingResolvedAnswers={activePendingResolvedAnswers} + activePendingIsResponding={activePendingIsResponding} + activePendingDraftAnswers={activePendingDraftAnswers} + activePendingQuestionIndex={activePendingQuestionIndex} + respondingRequestIds={respondingRequestIds} + showPlanFollowUpPrompt={showPlanFollowUpPrompt} + activeProposedPlan={activeProposedPlan} + activePlan={activePlan as { turnId?: TurnId } | null} + sidebarProposedPlan={sidebarProposedPlan as { turnId?: TurnId } | null} + planSidebarLabel={planSidebarLabel} + planSidebarOpen={planSidebarOpen} + runtimeMode={runtimeMode} + interactionMode={interactionMode} + lockedProvider={lockedProvider} + providerStatuses={providerStatuses as ServerProvider[]} + activeProjectDefaultModelSelection={activeProject?.defaultModelSelection} + activeThreadModelSelection={activeThread?.modelSelection} + activeThreadActivities={activeThread?.activities} + resolvedTheme={resolvedTheme} + settings={settings} + keybindings={keybindings} + terminalOpen={Boolean(terminalUiState.terminalOpen)} + gitCwd={gitCwd} + promptRef={promptRef} + composerImagesRef={composerImagesRef} + composerTerminalContextsRef={composerTerminalContextsRef} + composerElementContextsRef={composerElementContextsRef} + shouldAutoScrollRef={isAtEndRef} + scheduleStickToBottom={scrollToEnd} + onSend={onSend} + onInterrupt={onInterrupt} + onImplementPlanInNewThread={onImplementPlanInNewThread} + onRespondToApproval={onRespondToApproval} + onSelectActivePendingUserInputOption={onSelectActivePendingUserInputOption} + onAdvanceActivePendingUserInput={onAdvanceActivePendingUserInput} + onPreviousActivePendingUserInputQuestion={ + onPreviousActivePendingUserInputQuestion + } + onChangeActivePendingUserInputCustomAnswer={ + onChangeActivePendingUserInputCustomAnswer + } + onProviderModelSelect={onProviderModelSelect} + getModelDisabledReason={getModelDisabledReason} + toggleInteractionMode={toggleInteractionMode} + handleRuntimeModeChange={handleRuntimeModeChange} + handleInteractionModeChange={handleInteractionModeChange} + togglePlanSidebar={togglePlanSidebar} + focusComposer={focusComposer} + scheduleComposerFocus={scheduleComposerFocus} + setThreadError={setThreadError} + onExpandImage={onExpandTimelineImage} + /> + </div> </div> + {isGitRepo && ( + <BranchToolbar + environmentId={activeThread.environmentId} + threadId={activeThread.id} + {...(routeKind === "draft" && draftId ? { draftId } : {})} + onEnvModeChange={onEnvModeChange} + {...(canOverrideServerThreadEnvMode ? { effectiveEnvModeOverride: envMode } : {})} + {...(canOverrideServerThreadEnvMode + ? { + activeThreadBranchOverride: activeThreadBranch, + onActiveThreadBranchOverrideChange: setPendingServerThreadBranch, + } + : {})} + envLocked={envLocked} + onComposerFocusRequest={scheduleComposerFocus} + {...(canCheckoutPullRequestIntoThread + ? { onCheckoutPullRequestRequest: openPullRequestDialog } + : {})} + {...(hasMultipleEnvironments ? { onEnvironmentChange } : {})} + availableEnvironments={logicalProjectEnvironments} + /> + )} </div> - {isGitRepo && ( - <BranchToolbar + + {pullRequestDialogState ? ( + <PullRequestThreadDialog + key={pullRequestDialogState.key} + open environmentId={activeThread.environmentId} threadId={activeThread.id} - {...(routeKind === "draft" && draftId ? { draftId } : {})} - onEnvModeChange={onEnvModeChange} - {...(canOverrideServerThreadEnvMode ? { effectiveEnvModeOverride: envMode } : {})} - {...(canOverrideServerThreadEnvMode - ? { - activeThreadBranchOverride: activeThreadBranch, - onActiveThreadBranchOverrideChange: setPendingServerThreadBranch, - } - : {})} - envLocked={envLocked} - onComposerFocusRequest={scheduleComposerFocus} - {...(canCheckoutPullRequestIntoThread - ? { onCheckoutPullRequestRequest: openPullRequestDialog } - : {})} - {...(hasMultipleEnvironments ? { onEnvironmentChange } : {})} - availableEnvironments={logicalProjectEnvironments} + cwd={activeProject?.cwd ?? null} + initialReference={pullRequestDialogState.initialReference} + onOpenChange={(open) => { + if (!open) { + closePullRequestDialog(); + } + }} + onPrepared={handlePreparedPullRequestThread} /> - )} + ) : null} </div> - - {pullRequestDialogState ? ( - <PullRequestThreadDialog - key={pullRequestDialogState.key} - open - environmentId={activeThread.environmentId} - threadId={activeThread.id} - cwd={activeProject?.cwd ?? null} - initialReference={pullRequestDialogState.initialReference} - onOpenChange={(open) => { - if (!open) { - closePullRequestDialog(); - } - }} - onPrepared={handlePreparedPullRequestThread} - /> - ) : null} + {/* end chat column */} </div> - {/* end chat column */} - - {planSidebarOpen && !shouldUsePlanSidebarSheet ? ( - <PlanSidebar - activePlan={activePlan} - activeProposedPlan={sidebarProposedPlan} - label={planSidebarLabel} - environmentId={environmentId} - markdownCwd={gitCwd ?? undefined} - workspaceRoot={activeWorkspaceRoot} - timestampFormat={timestampFormat} - mode="sidebar" - onClose={closePlanSidebar} - /> - ) : null} - {!shouldUsePlanSidebarSheet - ? mountedPreviewThreadRefs.map( - ({ key: mountedThreadKey, threadRef: mountedThreadRef }) => { - const visible = previewPanelOpen && mountedThreadKey === activeThreadKey; - return ( - <div - key={mountedThreadKey} - className={cn( - visible - ? "contents" - : "pointer-events-none fixed -left-[10000px] top-0 h-px w-px overflow-hidden opacity-0", - )} - aria-hidden={visible ? undefined : true} - > - <Suspense fallback={null}> - <PreviewPanel mode="inline" threadRef={mountedThreadRef} visible={visible} /> - </Suspense> - </div> - ); - }, - ) - : null} + {/* end horizontal flex container */} </div> - {/* end horizontal flex container */} - {activeThreadRef ? ( - <PreviewAutomationOwner threadRef={activeThreadRef} visible={previewPanelOpen} /> + {!shouldUsePlanSidebarSheet && + rightPanelOpen && + activeThreadRef && + activeRightPanelSurface ? ( + <RightPanelTabs + mode="inline" + surfaces={rightPanelState.surfaces} + activeSurfaceId={activeRightPanelSurface.id} + previewSessions={activePreviewState.sessions} + onActivate={activateRightPanelSurface} + onCloseSurface={closeRightPanelSurface} + onAddBrowser={createBrowserSurface} + onAddDiff={addDiffSurface} + diffAvailable={isServerThread && isGitRepo} + > + {activeRightPanelSurface.kind === "preview" ? ( + <Suspense fallback={null}> + <PreviewPanel + mode="embedded" + threadRef={activeThreadRef} + tabId={activeRightPanelSurface.resourceId} + visible + /> + </Suspense> + ) : activeRightPanelSurface.kind === "diff" ? ( + <DiffWorkerPoolProvider> + <Suspense fallback={null}> + <DiffPanel mode="embedded" /> + </Suspense> + </DiffWorkerPoolProvider> + ) : null} + </RightPanelTabs> ) : null} - {mountedTerminalThreadRefs.map(({ key: mountedThreadKey, threadRef: mountedThreadRef }) => ( - <PersistentThreadTerminalDrawer - key={mountedThreadKey} - threadRef={mountedThreadRef} - threadId={mountedThreadRef.threadId} - visible={mountedThreadKey === activeThreadKey && terminalUiState.terminalOpen} - launchContext={ - mountedThreadKey === activeThreadKey ? (activeTerminalLaunchContext ?? null) : null - } - focusRequestId={mountedThreadKey === activeThreadKey ? terminalFocusRequestId : 0} - splitShortcutLabel={splitTerminalShortcutLabel ?? undefined} - newShortcutLabel={newTerminalShortcutLabel ?? undefined} - closeShortcutLabel={closeTerminalShortcutLabel ?? undefined} - keybindings={keybindings} - onAddTerminalContext={addTerminalContextToDraft} - /> - ))} - {shouldUsePlanSidebarSheet && previewPanelOpen && activeThreadRef ? ( + {shouldUsePlanSidebarSheet && rightPanelOpen && activeThreadRef && activeRightPanelSurface ? ( <RightPanelSheet open onClose={closePreviewPanel}> - <Suspense fallback={null}> - <PreviewPanel mode="sheet" threadRef={activeThreadRef} visible /> - </Suspense> - </RightPanelSheet> - ) : null} - {shouldUsePlanSidebarSheet ? ( - <RightPanelSheet open={planSidebarOpen} onClose={closePlanSidebar}> - <PlanSidebar - activePlan={activePlan} - activeProposedPlan={sidebarProposedPlan} - label={planSidebarLabel} - environmentId={environmentId} - markdownCwd={gitCwd ?? undefined} - workspaceRoot={activeWorkspaceRoot} - timestampFormat={timestampFormat} + <RightPanelTabs mode="sheet" - onClose={closePlanSidebar} - /> + surfaces={rightPanelState.surfaces} + activeSurfaceId={activeRightPanelSurface.id} + previewSessions={activePreviewState.sessions} + onActivate={activateRightPanelSurface} + onCloseSurface={closeRightPanelSurface} + onAddBrowser={createBrowserSurface} + onAddDiff={addDiffSurface} + diffAvailable={isServerThread && isGitRepo} + > + {activeRightPanelSurface.kind === "preview" ? ( + <Suspense fallback={null}> + <PreviewPanel + mode="embedded" + threadRef={activeThreadRef} + tabId={activeRightPanelSurface.resourceId} + visible + /> + </Suspense> + ) : activeRightPanelSurface.kind === "terminal" ? ( + <PersistentThreadTerminalDrawer + threadRef={activeThreadRef} + threadId={activeThreadRef.threadId} + visible + mode="panel" + launchContext={activeTerminalLaunchContext ?? null} + focusRequestId={terminalFocusRequestId} + splitShortcutLabel={splitTerminalShortcutLabel ?? undefined} + newShortcutLabel={newTerminalShortcutLabel ?? undefined} + closeShortcutLabel={closeTerminalShortcutLabel ?? undefined} + keybindings={keybindings} + onAddTerminalContext={addTerminalContextToDraft} + /> + ) : activeRightPanelSurface.kind === "diff" ? ( + <DiffWorkerPoolProvider> + <Suspense fallback={null}> + <DiffPanel mode="embedded" /> + </Suspense> + </DiffWorkerPoolProvider> + ) : ( + <PlanSidebar + activePlan={activePlan} + activeProposedPlan={sidebarProposedPlan} + label={planSidebarLabel} + environmentId={environmentId} + markdownCwd={gitCwd ?? undefined} + workspaceRoot={activeWorkspaceRoot} + timestampFormat={timestampFormat} + mode="embedded" + onClose={closePlanSidebar} + /> + )} + </RightPanelTabs> </RightPanelSheet> ) : null} diff --git a/apps/web/src/components/DiffPanelShell.tsx b/apps/web/src/components/DiffPanelShell.tsx index 829ed4159d4..6a2219d610f 100644 --- a/apps/web/src/components/DiffPanelShell.tsx +++ b/apps/web/src/components/DiffPanelShell.tsx @@ -5,10 +5,10 @@ import { cn } from "~/lib/utils"; import { Skeleton } from "./ui/skeleton"; -export type DiffPanelMode = "inline" | "sheet" | "sidebar"; +export type DiffPanelMode = "inline" | "sheet" | "sidebar" | "embedded"; function getDiffPanelHeaderRowClassName(mode: DiffPanelMode) { - const shouldUseDragRegion = isElectron && mode !== "sheet"; + const shouldUseDragRegion = isElectron && mode !== "sheet" && mode !== "embedded"; return cn( "flex items-center justify-between gap-2 px-4", shouldUseDragRegion @@ -22,7 +22,7 @@ export function DiffPanelShell(props: { header: ReactNode; children: ReactNode; }) { - const shouldUseDragRegion = isElectron && props.mode !== "sheet"; + const shouldUseDragRegion = isElectron && props.mode !== "sheet" && props.mode !== "embedded"; return ( <div diff --git a/apps/web/src/components/PlanSidebar.tsx b/apps/web/src/components/PlanSidebar.tsx index 62bd1ba89db..e87361c92d8 100644 --- a/apps/web/src/components/PlanSidebar.tsx +++ b/apps/web/src/components/PlanSidebar.tsx @@ -59,7 +59,7 @@ interface PlanSidebarProps { markdownCwd: string | undefined; workspaceRoot: string | undefined; timestampFormat: TimestampFormat; - mode?: "sheet" | "sidebar"; + mode?: "sheet" | "sidebar" | "embedded"; onClose: () => void; } diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx new file mode 100644 index 00000000000..af2d118e868 --- /dev/null +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -0,0 +1,148 @@ +import type { PreviewSessionSnapshot } from "@t3tools/contracts"; +import { ClipboardList, FileDiff, Globe2, Plus, TerminalSquare, X } from "lucide-react"; +import { type ReactNode, useState } from "react"; + +import type { RightPanelSurface } from "~/rightPanelStore"; +import { cn } from "~/lib/utils"; +import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu"; +import { faviconUrlForOrigin } from "~/lib/favicon"; + +import { PreviewPanelShell, type PreviewPanelMode } from "./preview/PreviewPanelShell"; + +interface RightPanelTabsProps { + mode: PreviewPanelMode; + surfaces: readonly RightPanelSurface[]; + activeSurfaceId: string; + previewSessions: Readonly<Record<string, PreviewSessionSnapshot>>; + onActivate: (surface: RightPanelSurface) => void; + onCloseSurface: (surface: RightPanelSurface) => void; + onAddBrowser: () => void; + onAddDiff: () => void; + diffAvailable: boolean; + children: ReactNode; +} + +function surfaceTitle( + surface: RightPanelSurface, + sessions: Readonly<Record<string, PreviewSessionSnapshot>>, +): string { + switch (surface.kind) { + case "diff": + return "Diff"; + case "terminal": + return "Terminal"; + case "plan": + return "Plan"; + case "preview": { + const snapshot = surface.resourceId ? sessions[surface.resourceId] : null; + if (!snapshot || snapshot.navStatus._tag === "Idle") return "Browser"; + if (snapshot.navStatus.title.trim().length > 0) return snapshot.navStatus.title; + try { + return new URL(snapshot.navStatus.url).host || "Browser"; + } catch { + return "Browser"; + } + } + } +} + +function PreviewFavicon({ url }: { url: string | null }) { + const faviconUrl = faviconUrlForOrigin(url, 32); + const [failedUrl, setFailedUrl] = useState<string | null>(null); + if (!faviconUrl || failedUrl === faviconUrl) return <Globe2 className="size-3.5 shrink-0" />; + return ( + <img + src={faviconUrl} + alt="" + aria-hidden + draggable={false} + className="size-3.5 shrink-0 rounded-sm" + onError={() => setFailedUrl(faviconUrl)} + /> + ); +} + +function SurfaceIcon({ + surface, + sessions, +}: { + surface: RightPanelSurface; + sessions: Readonly<Record<string, PreviewSessionSnapshot>>; +}) { + switch (surface.kind) { + case "preview": { + const snapshot = surface.resourceId ? sessions[surface.resourceId] : null; + const url = !snapshot || snapshot.navStatus._tag === "Idle" ? null : snapshot.navStatus.url; + return <PreviewFavicon url={url} />; + } + case "diff": + return <FileDiff className="size-3.5 shrink-0" />; + case "terminal": + return <TerminalSquare className="size-3.5 shrink-0" />; + case "plan": + return <ClipboardList className="size-3.5 shrink-0" />; + } +} + +export function RightPanelTabs(props: RightPanelTabsProps) { + return ( + <PreviewPanelShell mode={props.mode}> + <div className="flex h-10 shrink-0 items-center px-2"> + <div className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto"> + {props.surfaces.map((surface) => { + const active = surface.id === props.activeSurfaceId; + const title = surfaceTitle(surface, props.previewSessions); + return ( + <div + key={surface.id} + className={cn( + "group flex h-7 min-w-0 max-w-52 items-center gap-1.5 rounded-md px-2 text-sm", + active + ? "bg-accent text-foreground" + : "text-muted-foreground hover:bg-accent/60 hover:text-foreground", + )} + > + <button + type="button" + className="flex min-w-0 flex-1 items-center gap-1.5" + onClick={() => props.onActivate(surface)} + title={title} + > + <SurfaceIcon surface={surface} sessions={props.previewSessions} /> + <span className="truncate">{title}</span> + </button> + <button + type="button" + className="rounded p-0.5 opacity-0 hover:bg-muted group-hover:opacity-100 focus:opacity-100" + aria-label={`Close ${title}`} + onClick={() => props.onCloseSurface(surface)} + > + <X className="size-3" /> + </button> + </div> + ); + })} + </div> + <Menu> + <MenuTrigger + className="relative ml-1 inline-flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground" + aria-label="Add panel surface" + > + <Plus className="size-4" /> + </MenuTrigger> + <MenuPopup align="start" side="bottom" sideOffset={6} className="min-w-44"> + <MenuItem onClick={props.onAddBrowser}> + <Globe2 /> + Browser + </MenuItem> + <MenuItem onClick={props.onAddDiff} disabled={!props.diffAvailable}> + <FileDiff /> + Diff + </MenuItem> + </MenuPopup> + </Menu> + </div> + <div className="flex min-h-0 flex-1 flex-col">{props.children}</div> + </PreviewPanelShell> + ); +} diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 0213aa0b08b..c4757cb3455 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -20,6 +20,7 @@ import { useState, } from "react"; import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; +import { cn } from "~/lib/utils"; import { type TerminalContextSelection } from "~/lib/terminalContext"; import { openInPreferredEditor } from "../editorPreferences"; import { @@ -791,6 +792,7 @@ export function TerminalViewport({ } interface ThreadTerminalDrawerProps { + mode?: "drawer" | "panel"; threadRef: ScopedThreadRef; threadId: ThreadId; cwd: string; @@ -849,6 +851,7 @@ function TerminalActionButton({ label, className, onClick, children }: TerminalA } export default function ThreadTerminalDrawer({ + mode = "drawer", threadRef, threadId, cwd, @@ -874,6 +877,7 @@ export default function ThreadTerminalDrawer({ terminalLabelsById, terminalLaunchLocationsById, }: ThreadTerminalDrawerProps) { + const isPanel = mode === "panel"; const [drawerHeight, setDrawerHeight] = useState(() => clampDrawerHeight(height)); const [resizeEpoch, setResizeEpoch] = useState(0); const drawerHeightRef = useRef(drawerHeight); @@ -1139,16 +1143,21 @@ export default function ThreadTerminalDrawer({ if (normalizedTerminalIds.length === 0) { return ( <aside - className="thread-terminal-drawer relative flex min-w-0 shrink-0 flex-col overflow-hidden border-t border-border/80 bg-background" - style={{ height: `${drawerHeight}px` }} + className={cn( + "thread-terminal-drawer relative flex min-w-0 flex-col overflow-hidden bg-background", + isPanel ? "h-full flex-1" : "shrink-0 border-t border-border/80", + )} + style={isPanel ? undefined : { height: `${drawerHeight}px` }} > - <div - className="absolute inset-x-0 top-0 z-20 h-1.5 cursor-row-resize" - onPointerDown={handleResizePointerDown} - onPointerMove={handleResizePointerMove} - onPointerUp={handleResizePointerEnd} - onPointerCancel={handleResizePointerEnd} - /> + {!isPanel ? ( + <div + className="absolute inset-x-0 top-0 z-20 h-1.5 cursor-row-resize" + onPointerDown={handleResizePointerDown} + onPointerMove={handleResizePointerMove} + onPointerUp={handleResizePointerEnd} + onPointerCancel={handleResizePointerEnd} + /> + ) : null} <div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-3 px-4 py-6 text-center text-sm text-muted-foreground"> <p>No terminal sessions for this thread yet.</p> <button @@ -1167,16 +1176,21 @@ export default function ThreadTerminalDrawer({ return ( <aside - className="thread-terminal-drawer relative flex min-w-0 shrink-0 flex-col overflow-hidden border-t border-border/80 bg-background" - style={{ height: `${drawerHeight}px` }} + className={cn( + "thread-terminal-drawer relative flex min-w-0 flex-col overflow-hidden bg-background", + isPanel ? "h-full flex-1" : "shrink-0 border-t border-border/80", + )} + style={isPanel ? undefined : { height: `${drawerHeight}px` }} > - <div - className="absolute inset-x-0 top-0 z-20 h-1.5 cursor-row-resize" - onPointerDown={handleResizePointerDown} - onPointerMove={handleResizePointerMove} - onPointerUp={handleResizePointerEnd} - onPointerCancel={handleResizePointerEnd} - /> + {!isPanel ? ( + <div + className="absolute inset-x-0 top-0 z-20 h-1.5 cursor-row-resize" + onPointerDown={handleResizePointerDown} + onPointerMove={handleResizePointerMove} + onPointerUp={handleResizePointerEnd} + onPointerCancel={handleResizePointerEnd} + /> + ) : null} {!hasTerminalSidebar && ( <div className="pointer-events-none absolute right-2 top-2 z-20"> diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 2110b53d604..c035b2d771f 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -9,7 +9,7 @@ import { scopeThreadRef } from "@t3tools/client-runtime"; import { memo } from "react"; import GitActionsControl from "../GitActionsControl"; import { type DraftId } from "~/composerDraftStore"; -import { DiffIcon, Globe, TerminalSquareIcon } from "lucide-react"; +import { PanelRightIcon } from "lucide-react"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import ProjectScriptsControl, { type NewProjectScriptInput } from "../ProjectScriptsControl"; import { Toggle } from "../ui/toggle"; @@ -23,29 +23,19 @@ interface ChatHeaderProps { draftId?: DraftId; activeThreadTitle: string; activeProjectName: string | undefined; - isGitRepo: boolean; openInCwd: string | null; activeProjectScripts: ProjectScript[] | undefined; preferredScriptId: string | null; keybindings: ResolvedKeybindingsConfig; availableEditors: ReadonlyArray<EditorId>; - terminalAvailable: boolean; - terminalOpen: boolean; - terminalToggleShortcutLabel: string | null; - diffToggleShortcutLabel: string | null; - /** False on the web build; true only when the desktop preview bridge is present. */ - previewAvailable: boolean; - previewOpen: boolean; - previewToggleShortcutLabel: string | null; + rightPanelAvailable: boolean; + rightPanelOpen: boolean; gitCwd: string | null; - diffOpen: boolean; onRunProjectScript: (script: ProjectScript) => void; onAddProjectScript: (input: NewProjectScriptInput) => Promise<void>; onUpdateProjectScript: (scriptId: string, input: NewProjectScriptInput) => Promise<void>; onDeleteProjectScript: (scriptId: string) => Promise<void>; - onToggleTerminal: () => void; - onTogglePreview: () => void; - onToggleDiff: () => void; + onToggleRightPanel: () => void; } export function shouldShowOpenInPicker(input: { @@ -66,28 +56,19 @@ export const ChatHeader = memo(function ChatHeader({ draftId, activeThreadTitle, activeProjectName, - isGitRepo, openInCwd, activeProjectScripts, preferredScriptId, keybindings, availableEditors, - terminalAvailable, - terminalOpen, - terminalToggleShortcutLabel, - diffToggleShortcutLabel, - previewAvailable, - previewOpen, - previewToggleShortcutLabel, + rightPanelAvailable, + rightPanelOpen, gitCwd, - diffOpen, onRunProjectScript, onAddProjectScript, onUpdateProjectScript, onDeleteProjectScript, - onToggleTerminal, - onTogglePreview, - onToggleDiff, + onToggleRightPanel, }: ChatHeaderProps) { const primaryEnvironmentId = usePrimaryEnvironmentId(); const showOpenInPicker = shouldShowOpenInPicker({ @@ -145,70 +126,19 @@ export const ChatHeader = memo(function ChatHeader({ render={ <Toggle className="shrink-0" - pressed={terminalOpen} - onPressedChange={onToggleTerminal} - aria-label="Toggle terminal drawer" + pressed={rightPanelOpen} + onPressedChange={onToggleRightPanel} + aria-label="Toggle right panel" variant="ghost" size="xs" - disabled={!terminalAvailable} + disabled={!rightPanelAvailable} > - <TerminalSquareIcon className="size-3" /> + <PanelRightIcon className="size-3.5" /> </Toggle> } /> <TooltipPopup side="bottom"> - {!terminalAvailable - ? "Terminal is unavailable until this thread has an active project." - : terminalToggleShortcutLabel - ? `Toggle terminal drawer (${terminalToggleShortcutLabel})` - : "Toggle terminal drawer"} - </TooltipPopup> - </Tooltip> - {previewAvailable ? ( - <Tooltip> - <TooltipTrigger - render={ - <Toggle - className="shrink-0" - pressed={previewOpen} - onPressedChange={onTogglePreview} - aria-label="Toggle preview browser" - variant="outline" - size="xs" - > - <Globe className="size-3" /> - </Toggle> - } - /> - <TooltipPopup side="bottom"> - {previewToggleShortcutLabel - ? `Toggle preview browser (${previewToggleShortcutLabel})` - : "Toggle preview browser"} - </TooltipPopup> - </Tooltip> - ) : null} - <Tooltip> - <TooltipTrigger - render={ - <Toggle - className="shrink-0" - pressed={diffOpen} - onPressedChange={onToggleDiff} - aria-label="Toggle diff panel" - variant="ghost" - size="xs" - disabled={!isGitRepo && !diffOpen} - > - <DiffIcon className="size-3" /> - </Toggle> - } - /> - <TooltipPopup side="bottom"> - {!isGitRepo && !diffOpen - ? "Diff panel is unavailable because this project is not a git repository." - : diffToggleShortcutLabel - ? `Toggle diff panel (${diffToggleShortcutLabel})` - : "Toggle diff panel"} + {rightPanelAvailable ? "Toggle right panel" : "Right panel is unavailable"} </TooltipPopup> </Tooltip> </div> diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 6629e036866..a56330c5923 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1377,23 +1377,28 @@ function buildToolCallExpandedBody( workEntry: TimelineWorkEntry, workspaceRoot: string | undefined, ): string | null { + const blocks: string[] = []; + if (workEntry.itemType === "mcp_tool_call" && workEntry.toolData !== undefined) { + blocks.push(`MCP call\n${JSON.stringify(workEntry.toolData, null, 2)}`); + } const raw = workEntryRawCommand(workEntry); if (raw?.trim()) { - return raw.trim(); - } - if (workEntry.command?.trim()) { - return workEntry.command.trim(); + blocks.push(raw.trim()); + } else if (workEntry.command?.trim()) { + blocks.push(workEntry.command.trim()); } if (workEntry.detail?.trim()) { - return workEntry.detail.trim(); + blocks.push(workEntry.detail.trim()); } const changedFiles = workEntry.changedFiles ?? []; if (changedFiles.length > 0) { - return changedFiles - .map((filePath) => formatWorkspaceRelativePath(filePath, workspaceRoot)) - .join("\n"); + blocks.push( + changedFiles + .map((filePath) => formatWorkspaceRelativePath(filePath, workspaceRoot)) + .join("\n"), + ); } - return null; + return blocks.length > 0 ? blocks.join("\n\n") : null; } function workEntryIconName(workEntry: TimelineWorkEntry): WorkEntryIconName { diff --git a/apps/web/src/components/preview/PreviewAutomationOwner.tsx b/apps/web/src/components/preview/PreviewAutomationOwner.tsx index f9dabb58759..fa8d1e3f715 100644 --- a/apps/web/src/components/preview/PreviewAutomationOwner.tsx +++ b/apps/web/src/components/preview/PreviewAutomationOwner.tsx @@ -10,15 +10,21 @@ import type { PreviewAutomationStatus, ScopedThreadRef, } from "@t3tools/contracts"; -import { useCallback, useEffect, useRef } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { ensureEnvironmentApi } from "~/environmentApi"; import { selectThreadPreviewState, usePreviewStateStore } from "~/previewStateStore"; import { useRightPanelStore } from "~/rightPanelStore"; +import { resolveBrowserNavigationTarget } from "~/browser/browserTargetResolver"; +import { + startBrowserRecording, + stopBrowserRecording, + useBrowserRecordingStore, +} from "~/browser/browserRecording"; import { previewBridge } from "./previewBridge"; -const automationClientId = +const newAutomationClientId = (): string => typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `preview-${Math.random().toString(36).slice(2)}`; @@ -112,11 +118,14 @@ export function PreviewAutomationOwner(props: { readonly visible: boolean; }) { const { threadRef, visible } = props; + const [automationClientId] = useState(newAutomationClientId); const ownerStateRef = useRef({ threadRef, visible }); const handlerRef = useRef<(request: PreviewAutomationRequest) => Promise<unknown>>( async () => undefined, ); - ownerStateRef.current = { threadRef, visible }; + useEffect(() => { + ownerStateRef.current = { threadRef, visible }; + }, [threadRef, visible]); const handleRequest = useCallback( async (request: PreviewAutomationRequest): Promise<unknown> => { @@ -136,9 +145,6 @@ export function PreviewAutomationOwner(props: { return currentStatus(threadRef, visible); case "open": { const input = request.input as PreviewAutomationOpenInput; - if (input.show ?? true) { - useRightPanelStore.getState().open(threadRef, "preview"); - } let activeTabId = (input.reuseExistingTab ?? true) ? (state.snapshot?.tabId ?? null) : null; if (!activeTabId) { @@ -151,13 +157,20 @@ export function PreviewAutomationOwner(props: { } else if (input.url && previewBridge) { await previewBridge.navigate(activeTabId, input.url); } + if (input.show ?? true) { + useRightPanelStore.getState().openBrowser(threadRef, activeTabId); + } await waitForDesktopOverlay(threadRef, request.timeoutMs); return currentStatus(threadRef, input.show ?? true); } case "navigate": { if (!previewBridge || !tabId) throw new Error("Preview tab is not initialized."); const input = request.input as PreviewAutomationNavigateInput; - await previewBridge.navigate(tabId, input.url); + const resolution = resolveBrowserNavigationTarget( + threadRef.environmentId, + input.target ?? { kind: "url", url: input.url! }, + ); + await previewBridge.navigate(tabId, resolution.resolvedUrl); await waitForNavigationReadiness( tabId, input.readiness ?? "load", @@ -204,11 +217,28 @@ export function PreviewAutomationOwner(props: { tabId, request.input as Parameters<typeof previewBridge.automation.waitFor>[1], ); + case "recordingStart": { + if (!tabId) throw new Error("Preview tab is not initialized."); + await startBrowserRecording(tabId); + return { + tabId, + recording: true, + startedAt: useBrowserRecordingStore.getState().startedAt, + }; + } + case "recordingStop": { + if (!tabId) throw new Error("Preview tab is not initialized."); + const artifact = await stopBrowserRecording(tabId); + if (!artifact) throw new Error("No active recording exists for this preview tab."); + return artifact; + } } }, [threadRef, visible], ); - handlerRef.current = handleRequest; + useEffect(() => { + handlerRef.current = handleRequest; + }, [handleRequest]); useEffect(() => { const api = ensureEnvironmentApi(threadRef.environmentId); @@ -249,7 +279,7 @@ export function PreviewAutomationOwner(props: { }, }, ); - }, [threadRef.environmentId]); + }, [automationClientId, threadRef.environmentId]); useEffect(() => { const api = ensureEnvironmentApi(threadRef.environmentId); @@ -281,7 +311,7 @@ export function PreviewAutomationOwner(props: { unsubscribe(); void api.preview.automation.clearOwner({ clientId: automationClientId }); }; - }, [threadRef, visible]); + }, [automationClientId, threadRef, visible]); return null; } diff --git a/apps/web/src/components/preview/PreviewChromeRow.tsx b/apps/web/src/components/preview/PreviewChromeRow.tsx index c84d27345a8..9064dc71d2a 100644 --- a/apps/web/src/components/preview/PreviewChromeRow.tsx +++ b/apps/web/src/components/preview/PreviewChromeRow.tsx @@ -1,6 +1,7 @@ import { ArrowLeft, ArrowRight, + Camera, ExternalLink, Globe, MousePointerClick, @@ -36,6 +37,9 @@ interface Props { onSubmit: (url: string) => void; /** When provided, renders an "Open in browser" affordance to the right. */ onOpenInBrowser?: (() => void) | undefined; + onCapture?: ((record: boolean) => void) | undefined; + captureDisabled?: boolean | undefined; + recording?: boolean | undefined; /** * When provided, renders an annotation-mode toggle button to the right of * the URL input. Pressed while annotation mode is active (button shows in `pressed` @@ -69,6 +73,9 @@ export function PreviewChromeRow({ onRefresh, onSubmit, onOpenInBrowser, + onCapture, + captureDisabled, + recording, onPickElement, pickActive, pickDisabled, @@ -97,13 +104,14 @@ export function PreviewChromeRow({ const next = draft.trim(); if (next.length === 0) return; onSubmit(next); + inputRef.current?.blur(); }; return ( <div className="relative"> <form onSubmit={submit} - className="flex items-center gap-2 border-b border-border bg-background px-2 py-1.5" + className="flex h-10 items-center gap-1 border-b border-border/70 bg-background px-2" > <div className="flex items-center gap-0.5" role="group" aria-label="Navigation"> <Tooltip> @@ -159,7 +167,7 @@ export function PreviewChromeRow({ </Tooltip> </div> - <InputGroup className="flex-1"> + <InputGroup className="h-7 flex-1 rounded-md"> <InputGroupAddon align="inline-start"> <Globe className="size-3.5 text-muted-foreground" aria-hidden /> </InputGroupAddon> @@ -181,6 +189,26 @@ export function PreviewChromeRow({ data-preview-url-input size="sm" /> + {onOpenInBrowser ? ( + <InputGroupAddon align="inline-end"> + <Tooltip> + <TooltipTrigger + render={ + <Button + variant="ghost" + size="icon-xs" + onClick={onOpenInBrowser} + aria-label="Open in system browser" + type="button" + /> + } + > + <ExternalLink /> + </TooltipTrigger> + <TooltipPopup>Open in system browser</TooltipPopup> + </Tooltip> + </InputGroupAddon> + ) : null} </InputGroup> {onPickElement ? ( @@ -209,22 +237,29 @@ export function PreviewChromeRow({ </TooltipPopup> </Tooltip> ) : null} - {onOpenInBrowser ? ( + {onCapture ? ( <Tooltip> <TooltipTrigger render={ <Button - variant="ghost" + variant={recording ? "secondary" : "ghost"} size="icon-xs" - onClick={onOpenInBrowser} - aria-label="Open in system browser" + onClick={(event) => onCapture(event.shiftKey)} + aria-label={recording ? "Stop recording" : "Capture screenshot"} type="button" + className="relative" + disabled={captureDisabled} /> } > - <ExternalLink /> + <Camera className={cn(recording && "text-destructive")} /> + {recording ? ( + <span className="absolute right-0.5 top-0.5 size-1.5 animate-pulse rounded-full bg-destructive" /> + ) : null} </TooltipTrigger> - <TooltipPopup>Open in system browser</TooltipPopup> + <TooltipPopup> + {recording ? "Stop recording" : "Screenshot · Shift-click to record"} + </TooltipPopup> </Tooltip> ) : null} {trailingActions} diff --git a/apps/web/src/components/preview/PreviewEmptyState.tsx b/apps/web/src/components/preview/PreviewEmptyState.tsx index 653fb93610c..12126c66408 100644 --- a/apps/web/src/components/preview/PreviewEmptyState.tsx +++ b/apps/web/src/components/preview/PreviewEmptyState.tsx @@ -1,5 +1,5 @@ import type { EnvironmentId } from "@t3tools/contracts"; -import { Globe } from "lucide-react"; +import { Globe, RadioTower } from "lucide-react"; import { Empty, EmptyDescription, EmptyMedia, EmptyTitle } from "~/components/ui/empty"; @@ -41,18 +41,24 @@ export function PreviewEmptyState({ } return ( - <div className="flex h-full min-h-0 flex-col overflow-y-auto"> - <h2 className="px-4 pt-4 pb-1.5 text-[11px] font-medium uppercase tracking-wide text-muted-foreground/80"> - Local - </h2> - <div className="flex flex-col gap-1.5 px-3 pb-4"> - {servers.map((server) => ( - <PreviewLocalServerCard - key={`${server.host}:${server.port}`} - server={server} - onOpen={() => onOpenUrl(server.url)} - /> - ))} + <div className="flex h-full min-h-0 overflow-y-auto px-5 py-8"> + <div className="m-auto flex w-full max-w-xl flex-col gap-3"> + <div className="flex items-center gap-2 text-sm text-muted-foreground"> + <RadioTower className="size-4 shrink-0" /> + <h2 className="font-medium">Local servers</h2> + </div> + <div className="flex flex-col divide-y divide-border/60 overflow-hidden rounded-xl border border-border/70 bg-background"> + {servers.map((server) => ( + <PreviewLocalServerCard + key={`${server.host}:${server.port}`} + server={server} + onOpen={() => onOpenUrl(server.url)} + /> + ))} + </div> + <p className="px-1 text-xs text-muted-foreground"> + Select a listening port to open it in this browser tab. + </p> </div> </div> ); diff --git a/apps/web/src/components/preview/PreviewLocalServerCard.tsx b/apps/web/src/components/preview/PreviewLocalServerCard.tsx index c2e3018d365..54a020cbf65 100644 --- a/apps/web/src/components/preview/PreviewLocalServerCard.tsx +++ b/apps/web/src/components/preview/PreviewLocalServerCard.tsx @@ -1,5 +1,3 @@ -import { cn } from "~/lib/utils"; - import { BrowserMockup } from "./BrowserMockup"; import type { PreviewableServer } from "./useDiscoveredLocalServers"; @@ -14,18 +12,14 @@ export function PreviewLocalServerCard({ server, onOpen }: Props) { <button type="button" onClick={onOpen} - className={cn( - "group flex w-full items-center gap-3 rounded-xl border border-border/70 bg-card px-3 py-2.5 text-left transition-colors", - "hover:border-border hover:bg-accent/40", - "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background", - )} + className="group flex w-full items-center gap-3 px-3 py-3 text-left hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring" > <BrowserMockup className="size-7 shrink-0" /> <div className="flex min-w-0 flex-1 flex-col"> - <span className="truncate text-sm font-medium text-foreground"> + <span className="truncate text-sm font-medium text-foreground">{subtitle}</span> + <span className="truncate text-xs text-muted-foreground"> {server.host}:{server.port} </span> - <span className="truncate text-xs text-muted-foreground">{subtitle}</span> </div> {server.listening ? <PulsingDot /> : <DimDot />} </button> diff --git a/apps/web/src/components/preview/PreviewMoreMenu.tsx b/apps/web/src/components/preview/PreviewMoreMenu.tsx index 748a4a27019..f11ff4d2d30 100644 --- a/apps/web/src/components/preview/PreviewMoreMenu.tsx +++ b/apps/web/src/components/preview/PreviewMoreMenu.tsx @@ -36,7 +36,6 @@ export function PreviewMoreMenu({ tabId, hasWebContents, zoomFactor }: Props) { }; const zoomLabel = `${Math.round(zoomFactor * 100)}%`; - return ( <Menu> <Tooltip> @@ -60,7 +59,6 @@ export function PreviewMoreMenu({ tabId, hasWebContents, zoomFactor }: Props) { <MenuItem onClick={callTab(bridge.openDevTools)} disabled={tabDisabled}> Open DevTools </MenuItem> - <MenuSeparator /> {/* Zoom row: label + inline control cluster. `closeOnClick=false` keeps the menu open while the user clicks the +/− buttons. diff --git a/apps/web/src/components/preview/PreviewPanel.tsx b/apps/web/src/components/preview/PreviewPanel.tsx index edd19a31c47..92db1c2cd9b 100644 --- a/apps/web/src/components/preview/PreviewPanel.tsx +++ b/apps/web/src/components/preview/PreviewPanel.tsx @@ -10,15 +10,16 @@ import { PreviewView } from "./PreviewView"; interface Props { mode: PreviewPanelMode; threadRef: ScopedThreadRef; + tabId?: string | null; configuredUrls?: ReadonlyArray<string> | undefined; visible: boolean; } -export function PreviewPanel({ mode, threadRef, configuredUrls, visible }: Props) { +export function PreviewPanel({ mode, threadRef, tabId, configuredUrls, visible }: Props) { if (!isPreviewSupportedInRuntime()) { return ( <PreviewPanelShell mode={mode}> - <div className="flex h-full flex-col items-center justify-center gap-3 p-8 text-center"> + <div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-3 p-8 text-center"> <p className="max-w-sm text-sm text-muted-foreground"> Preview is only available in the T3 Code desktop app. </p> @@ -29,7 +30,12 @@ export function PreviewPanel({ mode, threadRef, configuredUrls, visible }: Props return ( <PreviewPanelShell mode={mode}> - <PreviewView threadRef={threadRef} configuredUrls={configuredUrls} visible={visible} /> + <PreviewView + threadRef={threadRef} + {...(tabId !== undefined ? { tabId } : {})} + configuredUrls={configuredUrls} + visible={visible} + /> </PreviewPanelShell> ); } diff --git a/apps/web/src/components/preview/PreviewPanelShell.tsx b/apps/web/src/components/preview/PreviewPanelShell.tsx index 71e8dd56107..7243a4df42a 100644 --- a/apps/web/src/components/preview/PreviewPanelShell.tsx +++ b/apps/web/src/components/preview/PreviewPanelShell.tsx @@ -6,7 +6,7 @@ import { cn } from "~/lib/utils"; import { RightPanelResizeHandle } from "./RightPanelResizeHandle"; -export type PreviewPanelMode = "inline" | "sheet" | "sidebar"; +export type PreviewPanelMode = "inline" | "sheet" | "sidebar" | "embedded"; const PREVIEW_PANEL_WIDTH_STORAGE_KEY = "t3code:preview-panel-width"; const PREVIEW_PANEL_MIN_WIDTH = 360; @@ -22,7 +22,7 @@ const PREVIEW_PANEL_DEFAULT_WIDTH = 540; * sheet/sidebar modes the parent owns the size. */ export function PreviewPanelShell(props: { mode: PreviewPanelMode; children: ReactNode }) { - const useDragRegion = isElectron && props.mode !== "sheet"; + const useDragRegion = isElectron && props.mode !== "sheet" && props.mode !== "embedded"; const isInline = props.mode === "inline"; const maxWidth = useViewportClampedMaxWidth(); const { width, handlers } = useResizableWidth({ @@ -36,7 +36,7 @@ export function PreviewPanelShell(props: { mode: PreviewPanelMode; children: Rea return ( <div className={cn( - "relative flex h-full min-w-0 flex-col bg-background", + "relative flex h-full min-h-0 min-w-0 flex-col self-stretch bg-background", isInline ? "shrink-0 border-l border-border" : "w-full", )} style={isInline ? { width: `${width}px` } : undefined} diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index 0f4e6651458..0bec9d5d44b 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -13,6 +13,7 @@ import { } from "~/lib/previewAnnotation"; import { ensureLocalApi } from "~/localApi"; import { selectThreadPreviewState, usePreviewStateStore } from "~/previewStateStore"; +import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver"; import { previewBridge } from "./previewBridge"; import { subscribePreviewAction } from "./previewActionBus"; @@ -21,13 +22,20 @@ import { PreviewChromeRow } from "./PreviewChromeRow"; import { PreviewEmptyState } from "./PreviewEmptyState"; import { PreviewMoreMenu } from "./PreviewMoreMenu"; import { PreviewUnreachable } from "./PreviewUnreachable"; -import { PreviewWebview } from "./PreviewWebview"; +import { BrowserSurfaceSlot } from "~/browser/BrowserSurfaceSlot"; import { useLoadingProgress } from "./useLoadingProgress"; import { usePreviewSession } from "./usePreviewSession"; import { ZoomIndicator } from "./ZoomIndicator"; +import { + startBrowserRecording, + stopBrowserRecording, + useBrowserRecordingStore, +} from "~/browser/browserRecording"; +import { toastManager } from "~/components/ui/toast"; interface Props { threadRef: ScopedThreadRef; + tabId?: string | null; configuredUrls?: ReadonlyArray<string> | undefined; visible: boolean; } @@ -38,9 +46,10 @@ const localApi = typeof window === "undefined" ? null : ensureLocalApi(); * Single-tab preview surface: chrome row on top, one webview below, empty * state when no session exists for the thread. */ -export function PreviewView({ threadRef, configuredUrls, visible }: Props) { +export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, visible }: Props) { const [focusUrlNonce, setFocusUrlNonce] = useState(0); const [pickActive, setPickActive] = useState(false); + const activeRecordingTabId = useBrowserRecordingStore((state) => state.activeTabId); const pickActiveRef = useRef(false); const isMountedRef = useRef(true); const previewState = usePreviewStateStore((state) => @@ -62,8 +71,9 @@ export function PreviewView({ threadRef, configuredUrls, visible }: Props) { }; }, []); - const { snapshot, desktopOverlay } = previewState; - const tabId = snapshot?.tabId ?? null; + const tabId = requestedTabId ?? previewState.activeTabId; + const snapshot = tabId ? (previewState.sessions[tabId] ?? null) : null; + const desktopOverlay = tabId ? (previewState.desktopByTabId[tabId] ?? null) : null; const navStatus = snapshot?.navStatus ?? { _tag: "Idle" as const }; const url = navStatus._tag === "Idle" ? "" : navStatus.url; const loading = desktopOverlay?.loading ?? navStatus._tag === "Loading"; @@ -71,22 +81,24 @@ export function PreviewView({ threadRef, configuredUrls, visible }: Props) { const canGoForward = desktopOverlay?.canGoForward ?? snapshot?.canGoForward ?? false; const refreshDisabled = navStatus._tag === "Idle"; const isUnreachable = navStatus._tag === "LoadFailed"; + const controller = desktopOverlay?.controller ?? "none"; const loadProgress = useLoadingProgress(loading); const handleSubmitUrl = useCallback( async (next: string) => { const api = ensureEnvironmentApi(threadRef.environmentId); try { + const resolvedUrl = resolveDiscoveredServerUrl(threadRef.environmentId, next); if (tabId && previewBridge) { // Drive the webview imperatively; `usePreviewBridge` mirrors the // resolved URL back to the server so other clients stay in sync. - await previewBridge.navigate(tabId, next); - rememberUrl(threadRef, next); + await previewBridge.navigate(tabId, resolvedUrl); + rememberUrl(threadRef, resolvedUrl); } else { await openPreviewSession({ previewApi: api.preview, threadRef, - url: next, + url: resolvedUrl, applyServerSnapshot, rememberUrl, }); @@ -127,6 +139,68 @@ export function PreviewView({ threadRef, configuredUrls, visible }: Props) { void localApi.shell.openExternal(url).catch(() => undefined); }, [url]); + const handleCapture = useCallback( + (record: boolean) => { + if (!previewBridge || !tabId) return; + const recordingThisTab = activeRecordingTabId === tabId; + if (recordingThisTab) { + void stopBrowserRecording(tabId).then( + (artifact) => { + if (!artifact) return; + toastManager.add({ + type: "success", + title: "Recording saved", + description: artifact.path, + }); + }, + (error) => { + toastManager.add({ + type: "error", + title: "Unable to stop recording", + description: error instanceof Error ? error.message : "An error occurred.", + }); + }, + ); + return; + } + if (record) { + if (activeRecordingTabId !== null) { + toastManager.add({ + type: "warning", + title: "Another preview is recording", + description: "Stop the active recording before starting a new one.", + }); + return; + } + void startBrowserRecording(tabId).catch((error) => { + toastManager.add({ + type: "error", + title: "Unable to start recording", + description: error instanceof Error ? error.message : "An error occurred.", + }); + }); + return; + } + void previewBridge.captureScreenshot(tabId).then( + (artifact) => { + toastManager.add({ + type: "success", + title: "Screenshot saved", + description: artifact.path, + }); + }, + (error) => { + toastManager.add({ + type: "error", + title: "Unable to capture screenshot", + description: error instanceof Error ? error.message : "An error occurred.", + }); + }, + ); + }, + [activeRecordingTabId, tabId], + ); + const handlePickElement = useCallback(() => { if (!previewBridge || !tabId) return; if (pickActiveRef.current) { @@ -232,7 +306,7 @@ export function PreviewView({ threadRef, configuredUrls, visible }: Props) { return ( <div - className="flex h-full min-h-0 flex-col bg-background" + className="flex min-h-0 flex-1 flex-col bg-background" data-thread-key={scopedThreadKey(threadRef)} > <PreviewChromeRow @@ -248,6 +322,9 @@ export function PreviewView({ threadRef, configuredUrls, visible }: Props) { onRefresh={handleRefresh} onSubmit={(next) => void handleSubmitUrl(next)} onOpenInBrowser={tabId ? handleOpenInBrowser : undefined} + onCapture={previewBridge && tabId ? handleCapture : undefined} + captureDisabled={!desktopOverlay || isUnreachable} + recording={tabId !== null && activeRecordingTabId === tabId} onPickElement={previewBridge && tabId ? handlePickElement : undefined} pickActive={pickActive} // Disable when there's no tab (nothing to pick on) OR the page @@ -268,13 +345,12 @@ export function PreviewView({ threadRef, configuredUrls, visible }: Props) { } /> - <div className="relative flex-1 overflow-hidden"> + <div className="relative min-h-0 flex-1 overflow-hidden"> {tabId && snapshot ? ( - <PreviewWebview + <BrowserSurfaceSlot key={tabId} - threadRef={threadRef} tabId={tabId} - initialUrl={url || null} + visible={visible && !isUnreachable} className="absolute inset-0 h-full w-full" /> ) : ( @@ -288,6 +364,11 @@ export function PreviewView({ threadRef, configuredUrls, visible }: Props) { {snapshot && desktopOverlay ? ( <ZoomIndicator zoomFactor={desktopOverlay.zoomFactor} /> ) : null} + {controller !== "none" ? ( + <div className="pointer-events-none absolute left-3 top-3 z-40 rounded-full border border-border/70 bg-background/90 px-2.5 py-1 text-[11px] font-medium shadow-sm backdrop-blur"> + {controller === "agent" ? "Agent controlling browser" : "Human control"} + </div> + ) : null} {navStatus._tag === "LoadFailed" ? ( <div className="absolute inset-0 z-10 bg-background"> <PreviewUnreachable diff --git a/apps/web/src/components/preview/PreviewWebview.tsx b/apps/web/src/components/preview/PreviewWebview.tsx deleted file mode 100644 index 9c4afb414da..00000000000 --- a/apps/web/src/components/preview/PreviewWebview.tsx +++ /dev/null @@ -1,142 +0,0 @@ -"use client"; - -import type { DesktopPreviewWebviewConfig, ScopedThreadRef } from "@t3tools/contracts"; -import { useCallback, useEffect, useRef, useState } from "react"; - -import { isElectron } from "~/env"; - -import { previewBridge } from "./previewBridge"; -import { usePreviewBridge } from "./usePreviewBridge"; - -interface Props { - threadRef: ScopedThreadRef; - tabId: string; - /** - * URL to load on first mount. Subsequent prop changes are ignored — once - * the webview is live, navigation flows exclusively through the bridge - * (`previewBridge.navigate`, `goBack`, `goForward`, `refresh`). Otherwise, - * a snapshot URL update from the server would re-set `<webview src>` and - * race with the `loadURL` we already issued via the bridge. - */ - initialUrl: string | null; - className?: string; -} - -interface ElectronWebview extends HTMLElement { - src: string; - partition: string; - preload?: string; - /** - * Comma-separated `key=value` web-preferences override. We use it to drop - * `contextIsolation` so the picker preload can see the guest page's - * `__REACT_DEVTOOLS_GLOBAL_HOOK__` and resolve component names. - */ - webpreferences?: string; - reload: () => void; - getWebContentsId: () => number; -} - -declare global { - interface HTMLElementTagNameMap { - webview: ElectronWebview; - } -} - -/** - * Hosts the Electron `<webview>` for a single preview tab. Returns null on - * web builds. The two-step handshake (createTab → wait for `dom-ready` → - * registerWebview) is necessary because `getWebContentsId()` only returns a - * valid id once the embedded contents have parsed; calling it synchronously - * after mount throws. - */ -export function PreviewWebview({ threadRef, tabId, initialUrl, className }: Props) { - const [config, setConfig] = useState<DesktopPreviewWebviewConfig | null>(null); - const webviewRef = useRef<ElectronWebview | null>(null); - // Capture once at mount; never re-derived from `initialUrl` props later. - const initialSrcRef = useRef<string>(initialUrl ?? "about:blank"); - const bridge = previewBridge; - - // Per-tab desktop lifecycle: createTab on mount, closeTab on unmount, - // mirror state into the store, and reflect navigation back to the server. - usePreviewBridge({ threadRef, tabId }); - - useEffect(() => { - if (!bridge) return; - let cancelled = false; - bridge - .getPreviewConfig() - .then((next) => { - if (cancelled) return; - setConfig(next); - }) - .catch(() => undefined); - return () => { - cancelled = true; - }; - }, [bridge]); - - // Stable callback ref so React doesn't re-invoke the closure on every - // commit (otherwise we'd be re-asserting `allowpopups` and re-storing the - // ref pointer per render, which is harmless but wasteful). - const setWebviewRef = useCallback((node: HTMLElement | null) => { - webviewRef.current = node as ElectronWebview | null; - if (node && !node.hasAttribute("allowpopups")) { - // React's @types declare `allowpopups` as `boolean`, but the runtime - // doesn't list <webview> in its boolean-attribute allowlist, so - // passing it via JSX triggers a "received true for a non-boolean - // attribute" warning. Setting it imperatively bypasses React's prop - // normalization entirely. - node.setAttribute("allowpopups", "true"); - } - }, []); - - useEffect(() => { - if (!bridge || !config) return; - const webview = webviewRef.current; - if (!webview) return; - - const onDomReady = () => { - try { - const id = webview.getWebContentsId(); - if (Number.isFinite(id)) { - void bridge.registerWebview(tabId, id); - } - } catch { - // The next dom-ready (e.g. cross-document navigation) will retry. - } - }; - webview.addEventListener("dom-ready", onDomReady as EventListener); - // Defensive: dom-ready may have fired between React commit and this - // effect running. Failures fall through to the listener above. - try { - const existing = webview.getWebContentsId(); - if (Number.isFinite(existing)) { - void bridge.registerWebview(tabId, existing); - } - } catch { - // covered by dom-ready listener - } - - return () => { - webview.removeEventListener("dom-ready", onDomReady as EventListener); - }; - }, [bridge, config, tabId]); - - if (!isElectron || !bridge || !config) return null; - - // `preload` and `webpreferences` are HTML attributes Electron only reads - // at element-attach time — a state update later in the lifecycle would - // have no effect. The renderer-attached values are also enforced by the - // main-process `will-attach-webview` validator (defense in depth). - return ( - <webview - ref={setWebviewRef} - src={initialSrcRef.current} - partition={config.partition} - webpreferences={config.webPreferences} - {...(config.preloadUrl ? { preload: config.preloadUrl } : {})} - data-preview-tab={tabId} - className={className} - /> - ); -} diff --git a/apps/web/src/components/preview/useDiscoveredLocalServers.ts b/apps/web/src/components/preview/useDiscoveredLocalServers.ts index 9eab8592d9e..370dd4cba67 100644 --- a/apps/web/src/components/preview/useDiscoveredLocalServers.ts +++ b/apps/web/src/components/preview/useDiscoveredLocalServers.ts @@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from "react"; import { ensureEnvironmentApi } from "~/environmentApi"; import type { EnvironmentId } from "@t3tools/contracts"; +import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver"; export interface PreviewableServer extends DiscoveredLocalServer { source: "scanner" | "configured" | "recent"; @@ -42,11 +43,14 @@ export function useDiscoveredLocalServers( return useMemo( () => mergeServers({ - scanner: scannerSnapshot, + scanner: scannerSnapshot.map((server) => ({ + ...server, + url: resolveDiscoveredServerUrl(input.environmentId, server.url), + })), configuredUrls: input.configuredUrls ?? [], recentlySeenUrls: input.recentlySeenUrls ?? [], }), - [scannerSnapshot, input.configuredUrls, input.recentlySeenUrls], + [input.environmentId, scannerSnapshot, input.configuredUrls, input.recentlySeenUrls], ); } diff --git a/apps/web/src/components/preview/usePreviewBridge.ts b/apps/web/src/components/preview/usePreviewBridge.ts index 6a80fb82001..9eb6eebbbe3 100644 --- a/apps/web/src/components/preview/usePreviewBridge.ts +++ b/apps/web/src/components/preview/usePreviewBridge.ts @@ -14,26 +14,14 @@ import { type DesktopPreviewOverlay, usePreviewStateStore } from "~/previewState import { previewBridge } from "./previewBridge"; /** - * Owns the desktop tab lifecycle, mirrors low-latency button state into the - * store, and reflects bridge navigation events back to the server. - * - * Tab create/close is anchored to the panel mount, NOT to the snapshot, so - * snapshot transitions (`opened` → `closed` → `opened`) cannot tear down and - * recreate the tab in the wrong order relative to in-flight desktop IPCs. + * Mirrors low-latency desktop state into the store and reflects navigation + * events back to the server. Webview lifetime is owned by ElectronBrowserHost. */ export function usePreviewBridge(input: { threadRef: ScopedThreadRef; tabId: string }): void { const { threadRef, tabId } = input; const applyDesktopState = usePreviewStateStore((state) => state.applyDesktopState); const bridge = previewBridge; - useEffect(() => { - if (!bridge) return; - void bridge.createTab(tabId); - return () => { - void bridge.closeTab(tabId); - }; - }, [bridge, tabId]); - // One bridge subscription does both jobs (mirror state + forward to // server) so the desktop bridge keeps a single listener entry per tab. const lastReportedUrl = useRef<string | null>(null); @@ -45,7 +33,7 @@ export function usePreviewBridge(input: { threadRef: ScopedThreadRef; tabId: str lastReportedKind.current = null; const unsubscribe = bridge.onStateChange((changedTabId, state) => { if (changedTabId !== tabId) return; - applyDesktopState(threadRef, projectDesktopState(state)); + applyDesktopState(threadRef, tabId, projectDesktopState(state)); const reported = buildReportInput({ threadId: threadRef.threadId, tabId, @@ -68,6 +56,7 @@ function projectDesktopState(state: DesktopPreviewTabState): DesktopPreviewOverl canGoForward: state.canGoForward, loading: state.navStatus.kind === "Loading", zoomFactor: state.zoomFactor, + controller: state.controller, }; } diff --git a/apps/web/src/components/preview/usePreviewSession.ts b/apps/web/src/components/preview/usePreviewSession.ts index 152765213bf..1a4446bd330 100644 --- a/apps/web/src/components/preview/usePreviewSession.ts +++ b/apps/web/src/components/preview/usePreviewSession.ts @@ -33,7 +33,7 @@ export function usePreviewSession(threadRef: ScopedThreadRef): void { // `updatedAt` ascending, so the last one is freshest. const serverSnapshot = result.sessions.at(-1) ?? null; if (serverSnapshot) { - applyServerSnapshot(threadRef, serverSnapshot); + for (const snapshot of result.sessions) applyServerSnapshot(threadRef, snapshot); return; } // Server has no sessions — try to recover what the renderer diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index e92cad9aa3d..8d56b687738 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -17,6 +17,7 @@ import { hasCloudPublicConfig } from "./cloud/publicConfig"; import { getRouter } from "./router"; import { APP_DISPLAY_NAME } from "./branding"; import { syncDocumentWindowControlsOverlayClass } from "./lib/windowControlsOverlay"; +import { ElectronBrowserHost } from "./browser/ElectronBrowserHost"; // Electron loads the app from a file-backed shell, so hash history avoids path resolution issues. const history = isElectron ? createHashHistory() : createBrowserHistory(); @@ -31,7 +32,12 @@ document.title = APP_DISPLAY_NAME; const clerkPublishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY as string | undefined; -const app = <RouterProvider router={router} />; +const app = ( + <> + <RouterProvider router={router} /> + <ElectronBrowserHost /> + </> +); ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( <React.StrictMode> diff --git a/apps/web/src/previewStateStore.test.ts b/apps/web/src/previewStateStore.test.ts index eb7c998109b..9a1722380f8 100644 --- a/apps/web/src/previewStateStore.test.ts +++ b/apps/web/src/previewStateStore.test.ts @@ -184,17 +184,39 @@ describe("previewStateStore (single-tab)", () => { createdAt: snapshot.updatedAt, snapshot, }); - store.applyDesktopState(ref, { + store.applyDesktopState(ref, snapshot.tabId, { canGoBack: true, canGoForward: false, loading: false, zoomFactor: 1, + controller: "none", }); const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); expect(state.desktopOverlay?.canGoBack).toBe(true); expect(state.snapshot?.canGoBack).toBe(false); }); + it("retains multiple tabs and switches active desktop state", () => { + const first = makeSnapshot(); + const second = { ...makeSnapshot(), tabId: "tab_2", updatedAt: "2026-01-02T00:00:00.000Z" }; + const store = usePreviewStateStore.getState(); + store.applyServerSnapshot(ref, first); + store.applyServerSnapshot(ref, second); + store.applyDesktopState(ref, first.tabId, { + canGoBack: true, + canGoForward: false, + loading: false, + zoomFactor: 1, + controller: "none", + }); + store.setActiveTab(ref, first.tabId); + + const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); + expect(Object.keys(state.sessions)).toEqual([first.tabId, second.tabId]); + expect(state.snapshot?.tabId).toBe(first.tabId); + expect(state.desktopOverlay?.canGoBack).toBe(true); + }); + it("applyServerSnapshot null clears snapshot for a thread that had one", () => { const snapshot = makeSnapshot(); const store = usePreviewStateStore.getState(); diff --git a/apps/web/src/previewStateStore.ts b/apps/web/src/previewStateStore.ts index 10f5cf58504..e9a9fc695b0 100644 --- a/apps/web/src/previewStateStore.ts +++ b/apps/web/src/previewStateStore.ts @@ -26,19 +26,26 @@ export interface DesktopPreviewOverlay { canGoForward: boolean; loading: boolean; zoomFactor: number; + controller: "human" | "agent" | "none"; } export interface ThreadPreviewState { snapshot: PreviewSessionSnapshot | null; + sessions: Record<string, PreviewSessionSnapshot>; + activeTabId: string | null; /** Bridge state takes precedence over `snapshot` for nav button enablement. */ desktopOverlay: DesktopPreviewOverlay | null; + desktopByTabId: Record<string, DesktopPreviewOverlay>; /** Recently-visited URLs surfaced in the empty state. */ recentlySeenUrls: string[]; } const EMPTY_THREAD_PREVIEW_STATE: ThreadPreviewState = Object.freeze({ snapshot: null, + sessions: {}, + activeTabId: null, desktopOverlay: null, + desktopByTabId: {}, recentlySeenUrls: [] as string[], }); @@ -46,7 +53,12 @@ export interface PreviewStateStoreState { byThreadKey: Record<string, ThreadPreviewState>; applyServerEvent: (ref: ScopedThreadRef, event: PreviewEvent) => void; applyServerSnapshot: (ref: ScopedThreadRef, snapshot: PreviewSessionSnapshot | null) => void; - applyDesktopState: (ref: ScopedThreadRef, overlay: DesktopPreviewOverlay | null) => void; + applyDesktopState: ( + ref: ScopedThreadRef, + tabId: string, + overlay: DesktopPreviewOverlay | null, + ) => void; + setActiveTab: (ref: ScopedThreadRef, tabId: string) => void; rememberUrl: (ref: ScopedThreadRef, url: string) => void; removeThread: (ref: ScopedThreadRef) => void; } @@ -96,34 +108,64 @@ export const usePreviewStateStore = create<PreviewStateStoreState>()((set) => ({ snapshot.navStatus._tag === "Idle" ? current.recentlySeenUrls : dedupeRecentUrls(current.recentlySeenUrls, snapshot.navStatus.url); - return { ...current, snapshot, recentlySeenUrls }; + const sessions = { ...current.sessions, [snapshot.tabId]: snapshot }; + const activeTabId = event.type === "opened" ? snapshot.tabId : current.activeTabId; + const activeSnapshot = sessions[activeTabId ?? snapshot.tabId] ?? snapshot; + return { + ...current, + sessions, + activeTabId: activeTabId ?? snapshot.tabId, + snapshot: activeSnapshot, + desktopOverlay: current.desktopByTabId[activeSnapshot.tabId] ?? null, + recentlySeenUrls, + }; }); break; case "failed": nextByThread = updateThread(state, threadKey, (current) => { - if (!current.snapshot || current.snapshot.tabId !== event.tabId) return current; + const existing = current.sessions[event.tabId]; + if (!existing) return current; + const failedSnapshot = { + ...existing, + navStatus: { + _tag: "LoadFailed" as const, + url: event.url, + title: event.title, + code: event.code, + description: event.description, + }, + updatedAt: event.createdAt, + }; + const sessions = { ...current.sessions, [event.tabId]: failedSnapshot }; return { ...current, - snapshot: { - ...current.snapshot, - navStatus: { - _tag: "LoadFailed", - url: event.url, - title: event.title, - code: event.code, - description: event.description, - }, - updatedAt: event.createdAt, - }, + sessions, + snapshot: current.activeTabId === event.tabId ? failedSnapshot : current.snapshot, }; }); break; case "closed": nextByThread = updateThread(state, threadKey, (current) => { - // Only clear if the closed tab is the one we were tracking; the - // server may have multiple tabs per thread but we only render one. - if (current.snapshot && current.snapshot.tabId !== event.tabId) return current; - return { ...current, snapshot: null, desktopOverlay: null }; + if (!current.sessions[event.tabId]) return current; + const { [event.tabId]: _closed, ...sessions } = current.sessions; + const { [event.tabId]: _desktop, ...desktopByTabId } = current.desktopByTabId; + const nextSnapshot = + Object.values(sessions) + .toSorted((a, b) => a.updatedAt.localeCompare(b.updatedAt)) + .at(-1) ?? null; + const activeTabId = + current.activeTabId === event.tabId + ? (nextSnapshot?.tabId ?? null) + : current.activeTabId; + const snapshot = activeTabId ? (sessions[activeTabId] ?? nextSnapshot) : nextSnapshot; + return { + ...current, + sessions, + desktopByTabId, + activeTabId: snapshot?.tabId ?? null, + snapshot, + desktopOverlay: snapshot ? (desktopByTabId[snapshot.tabId] ?? null) : null, + }; }); break; } @@ -134,21 +176,59 @@ export const usePreviewStateStore = create<PreviewStateStoreState>()((set) => ({ const threadKey = scopedThreadKey(ref); const nextByThread = updateThread(state, threadKey, (current) => { if (!snapshot && current.snapshot === null) return current; + if (!snapshot) { + return { + ...current, + snapshot: null, + sessions: {}, + activeTabId: null, + desktopOverlay: null, + desktopByTabId: {}, + }; + } const recentlySeenUrls = snapshot && snapshot.navStatus._tag !== "Idle" ? dedupeRecentUrls(current.recentlySeenUrls, snapshot.navStatus.url) : current.recentlySeenUrls; - return { ...current, snapshot, recentlySeenUrls }; + return { + ...current, + snapshot, + sessions: { ...current.sessions, [snapshot.tabId]: snapshot }, + activeTabId: snapshot.tabId, + desktopOverlay: current.desktopByTabId[snapshot.tabId] ?? null, + recentlySeenUrls, + }; }); return { byThreadKey: nextByThread }; }), - applyDesktopState: (ref, overlay) => + applyDesktopState: (ref, tabId, overlay) => set((state) => { const threadKey = scopedThreadKey(ref); - const nextByThread = updateThread(state, threadKey, (current) => ({ - ...current, - desktopOverlay: overlay, - })); + const nextByThread = updateThread(state, threadKey, (current) => { + const desktopByTabId = { ...current.desktopByTabId }; + if (overlay) desktopByTabId[tabId] = overlay; + else delete desktopByTabId[tabId]; + return { + ...current, + desktopByTabId, + desktopOverlay: current.activeTabId === tabId ? overlay : current.desktopOverlay, + }; + }); + return { byThreadKey: nextByThread }; + }), + setActiveTab: (ref, tabId) => + set((state) => { + const threadKey = scopedThreadKey(ref); + const nextByThread = updateThread(state, threadKey, (current) => { + const snapshot = current.sessions[tabId]; + if (!snapshot || current.activeTabId === tabId) return current; + return { + ...current, + activeTabId: tabId, + snapshot, + desktopOverlay: current.desktopByTabId[tabId] ?? null, + }; + }); return { byThreadKey: nextByThread }; }), rememberUrl: (ref, url) => diff --git a/apps/web/src/rightPanelStore.test.ts b/apps/web/src/rightPanelStore.test.ts index 7865c57f8e7..1820898cbab 100644 --- a/apps/web/src/rightPanelStore.test.ts +++ b/apps/web/src/rightPanelStore.test.ts @@ -4,7 +4,9 @@ import { beforeEach, describe, expect, it } from "vite-plus/test"; import { selectActiveRightPanel, + selectActiveRightPanelSurface, selectActiveRightPanelKindWithUrl, + selectThreadRightPanelState, useRightPanelStore, } from "./rightPanelStore"; @@ -22,10 +24,13 @@ describe("rightPanelStore", () => { expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refB)).toBeNull(); }); - it("opening a different kind replaces the previous one", () => { + it("opening a different kind keeps both surfaces and activates the new one", () => { useRightPanelStore.getState().open(refA, "plan"); useRightPanelStore.getState().open(refA, "preview"); expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("preview"); + expect( + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA).surfaces, + ).toHaveLength(2); }); it("close clears the active panel", () => { @@ -67,4 +72,40 @@ describe("rightPanelStore", () => { useRightPanelStore.getState().close(refA); expect(useRightPanelStore.getState().byThreadKey).toEqual({}); }); + + it("tracks one surface per browser session", () => { + useRightPanelStore.getState().openBrowser(refA, "tab-a"); + useRightPanelStore.getState().openBrowser(refA, "tab-b"); + + const state = selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA); + expect(state.surfaces.map((surface) => surface.id)).toEqual(["browser:tab-a", "browser:tab-b"]); + expect(selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, refA)).toEqual({ + id: "browser:tab-b", + kind: "preview", + resourceId: "tab-b", + }); + }); + + it("closing the active surface activates a neighboring surface", () => { + useRightPanelStore.getState().openBrowser(refA, "tab-a"); + useRightPanelStore.getState().open(refA, "terminal"); + useRightPanelStore.getState().closeSurface(refA, "terminal"); + + expect(selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, refA)?.id).toBe( + "browser:tab-a", + ); + }); + + it("reconciles browser surfaces without deleting other surface kinds", () => { + useRightPanelStore.getState().open(refA, "terminal"); + useRightPanelStore.getState().openBrowser(refA, "tab-a"); + useRightPanelStore.getState().openBrowser(refA, "tab-b"); + useRightPanelStore.getState().reconcileBrowserSurfaces(refA, ["tab-b", "tab-c"]); + + expect( + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA).surfaces.map( + (surface) => surface.id, + ), + ).toEqual(["terminal", "browser:tab-b", "browser:tab-c"]); + }); }); diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts index b8bba44fdcc..c5e0b23a60e 100644 --- a/apps/web/src/rightPanelStore.ts +++ b/apps/web/src/rightPanelStore.ts @@ -1,13 +1,10 @@ /** - * Per-thread arbiter for the right-side panel. + * Thread-scoped right-panel surface state. * - * Three tenants share the same slot: the plan sidebar, the diff panel, and - * the preview panel. Only one is open at a time per thread; the choice is - * remembered across thread switches. - * - * The diff panel still uses `?diff=1` as URL truth for deep-linking — when - * that param is present the diff tenant wins regardless of what's persisted - * here. See `selectActiveRightPanelKindWithUrl` for the resolution rule. + * This is intentionally a shallow workspace model: it owns an ordered set of + * surface descriptors and the active surface, while each feature continues to + * own its durable resource state. Browser surfaces point at preview tab ids; + * singleton surfaces bridge the existing terminal, diff, and plan features. */ import { scopedThreadKey } from "@t3tools/client-runtime"; import type { ScopedThreadRef } from "@t3tools/contracts"; @@ -16,35 +13,77 @@ import { createJSONStorage, persist } from "zustand/middleware"; import { resolveStorage } from "./lib/storage"; -export const RIGHT_PANEL_KINDS = ["plan", "diff", "preview"] as const; +export const RIGHT_PANEL_KINDS = ["plan", "diff", "preview", "terminal"] as const; export type RightPanelKind = (typeof RIGHT_PANEL_KINDS)[number]; -const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v1"; +export type RightPanelSurface = + | { id: `browser:${string}`; kind: "preview"; resourceId: string } + | { id: "browser:new"; kind: "preview"; resourceId: null } + | { id: "terminal"; kind: "terminal" } + | { id: "diff"; kind: "diff" } + | { id: "plan"; kind: "plan" }; + +const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v2"; -interface ThreadRightPanelState { - active: RightPanelKind | null; +export interface ThreadRightPanelState { + activeSurfaceId: string | null; + surfaces: RightPanelSurface[]; } interface RightPanelStoreState { byThreadKey: Record<string, ThreadRightPanelState>; open: (ref: ScopedThreadRef, kind: RightPanelKind) => void; + openBrowser: (ref: ScopedThreadRef, tabId: string | null) => void; + activateSurface: (ref: ScopedThreadRef, surfaceId: string) => void; + closeSurface: (ref: ScopedThreadRef, surfaceId: string) => void; + reconcileBrowserSurfaces: (ref: ScopedThreadRef, tabIds: readonly string[]) => void; close: (ref: ScopedThreadRef) => void; toggle: (ref: ScopedThreadRef, kind: RightPanelKind) => void; removeThread: (ref: ScopedThreadRef) => void; } +const EMPTY_THREAD_STATE: ThreadRightPanelState = { activeSurfaceId: null, surfaces: [] }; + +const singletonSurface = (kind: Exclude<RightPanelKind, "preview">): RightPanelSurface => { + switch (kind) { + case "terminal": + return { id: "terminal", kind }; + case "diff": + return { id: "diff", kind }; + case "plan": + return { id: "plan", kind }; + } +}; + +const browserSurface = (tabId: string | null): RightPanelSurface => + tabId + ? { id: `browser:${tabId}`, kind: "preview", resourceId: tabId } + : { id: "browser:new", kind: "preview", resourceId: null }; + +const upsertSurface = ( + current: ThreadRightPanelState, + surface: RightPanelSurface, + activate = true, +): ThreadRightPanelState => ({ + surfaces: current.surfaces.some((entry) => entry.id === surface.id) + ? current.surfaces + : [...current.surfaces, surface], + activeSurfaceId: activate ? surface.id : current.activeSurfaceId, +}); + const updateThread = ( byThreadKey: Record<string, ThreadRightPanelState>, threadKey: string, - next: ThreadRightPanelState, + updater: (current: ThreadRightPanelState) => ThreadRightPanelState, ): Record<string, ThreadRightPanelState> => { - const current = byThreadKey[threadKey]; - if (current && current.active === next.active) return byThreadKey; - if (next.active === null) { - if (!current) return byThreadKey; + const current = byThreadKey[threadKey] ?? EMPTY_THREAD_STATE; + const next = updater(current); + if (next.activeSurfaceId === null && next.surfaces.length === 0) { + if (!(threadKey in byThreadKey)) return byThreadKey; const { [threadKey]: _removed, ...rest } = byThreadKey; return rest; } + if (next === current) return byThreadKey; return { ...byThreadKey, [threadKey]: next }; }; @@ -54,21 +93,92 @@ export const useRightPanelStore = create<RightPanelStoreState>()( byThreadKey: {}, open: (ref, kind) => set((state) => ({ - byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), { active: kind }), + byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => { + if (kind === "preview") { + const existing = current.surfaces.find((surface) => surface.kind === "preview"); + return upsertSurface(current, existing ?? browserSurface(null)); + } + return upsertSurface(current, singletonSurface(kind)); + }), + })), + openBrowser: (ref, tabId) => + set((state) => ({ + byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => { + const surface = browserSurface(tabId); + const withoutPlaceholder = tabId + ? current.surfaces.filter((entry) => entry.id !== "browser:new") + : current.surfaces; + return upsertSurface({ ...current, surfaces: withoutPlaceholder }, surface); + }), + })), + activateSurface: (ref, surfaceId) => + set((state) => ({ + byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => + current.surfaces.some((surface) => surface.id === surfaceId) + ? { ...current, activeSurfaceId: surfaceId } + : current, + ), + })), + closeSurface: (ref, surfaceId) => + set((state) => ({ + byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => { + const index = current.surfaces.findIndex((surface) => surface.id === surfaceId); + if (index < 0) return current; + const surfaces = current.surfaces.filter((surface) => surface.id !== surfaceId); + if (current.activeSurfaceId !== surfaceId) return { ...current, surfaces }; + const fallback = surfaces[Math.min(index, surfaces.length - 1)] ?? null; + return { surfaces, activeSurfaceId: fallback?.id ?? null }; + }), + })), + reconcileBrowserSurfaces: (ref, tabIds) => + set((state) => ({ + byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => { + const validIds = new Set(tabIds.map((tabId) => `browser:${tabId}`)); + const nonBrowser = current.surfaces.filter((surface) => surface.kind !== "preview"); + const existingBrowser = current.surfaces.filter( + (surface): surface is Extract<RightPanelSurface, { kind: "preview" }> => + surface.kind === "preview" && + surface.id !== "browser:new" && + validIds.has(surface.id), + ); + const knownIds = new Set(existingBrowser.map((surface) => surface.id)); + const added = tabIds + .filter((tabId) => !knownIds.has(`browser:${tabId}`)) + .map((tabId) => browserSurface(tabId)); + const surfaces = [...nonBrowser, ...existingBrowser, ...added]; + const activeStillExists = surfaces.some( + (surface) => surface.id === current.activeSurfaceId, + ); + const fallbackBrowser = surfaces.find((surface) => surface.kind === "preview"); + return { + surfaces, + activeSurfaceId: activeStillExists + ? current.activeSurfaceId + : (fallbackBrowser?.id ?? surfaces[0]?.id ?? null), + }; + }), })), close: (ref) => set((state) => ({ - byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), { active: null }), + byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => ({ + ...current, + activeSurfaceId: null, + })), })), toggle: (ref, kind) => - set((state) => { - const threadKey = scopedThreadKey(ref); - const current = state.byThreadKey[threadKey]?.active ?? null; - const next: RightPanelKind | null = current === kind ? null : kind; - return { - byThreadKey: updateThread(state.byThreadKey, threadKey, { active: next }), - }; - }), + set((state) => ({ + byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => { + const active = current.surfaces.find( + (surface) => surface.id === current.activeSurfaceId, + ); + if (active?.kind === kind) return { ...current, activeSurfaceId: null }; + if (kind === "preview") { + const existing = current.surfaces.find((surface) => surface.kind === "preview"); + return upsertSurface(current, existing ?? browserSurface(null)); + } + return upsertSurface(current, singletonSurface(kind)); + }), + })), removeThread: (ref) => set((state) => { const threadKey = scopedThreadKey(ref); @@ -79,7 +189,7 @@ export const useRightPanelStore = create<RightPanelStoreState>()( }), { name: RIGHT_PANEL_STORAGE_KEY, - version: 1, + version: 2, storage: createJSONStorage(() => resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined), ), @@ -88,22 +198,30 @@ export const useRightPanelStore = create<RightPanelStoreState>()( ), ); +export function selectThreadRightPanelState( + byThreadKey: Record<string, ThreadRightPanelState>, + ref: ScopedThreadRef | null | undefined, +): ThreadRightPanelState { + if (!ref) return EMPTY_THREAD_STATE; + return byThreadKey[scopedThreadKey(ref)] ?? EMPTY_THREAD_STATE; +} + export function selectActiveRightPanel( byThreadKey: Record<string, ThreadRightPanelState>, ref: ScopedThreadRef | null | undefined, ): RightPanelKind | null { - if (!ref) return null; - return byThreadKey[scopedThreadKey(ref)]?.active ?? null; + const state = selectThreadRightPanelState(byThreadKey, ref); + return state.surfaces.find((surface) => surface.id === state.activeSurfaceId)?.kind ?? null; +} + +export function selectActiveRightPanelSurface( + byThreadKey: Record<string, ThreadRightPanelState>, + ref: ScopedThreadRef | null | undefined, +): RightPanelSurface | null { + const state = selectThreadRightPanelState(byThreadKey, ref); + return state.surfaces.find((surface) => surface.id === state.activeSurfaceId) ?? null; } -/** - * Resolves the active right panel taking the `?diff=1` URL truth into - * account. When `diff=1` the diff panel always wins. - * - * Prefer using `useSyncDiffSearchToRightPanel` (in ChatView) to mirror the - * URL into the store and consume `selectActiveRightPanel` directly — this - * helper exists for callers that don't want to install the sync effect. - */ export function selectActiveRightPanelKindWithUrl( byThreadKey: Record<string, ThreadRightPanelState>, ref: ScopedThreadRef | null | undefined, diff --git a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx index 1877fee6f7d..13b6f9a5d59 100644 --- a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx +++ b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx @@ -1,149 +1,20 @@ import { createFileRoute, retainSearchParams, useNavigate } from "@tanstack/react-router"; -import { Suspense, lazy, useCallback, useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo } from "react"; import ChatView from "../components/ChatView"; import { threadHasStarted } from "../components/ChatView.logic"; -import { DiffWorkerPoolProvider } from "../components/DiffWorkerPoolProvider"; -import { - DiffPanelHeaderSkeleton, - DiffPanelLoadingState, - DiffPanelShell, - type DiffPanelMode, -} from "../components/DiffPanelShell"; import { finalizePromotedDraftThreadByRef, useComposerDraftStore } from "../composerDraftStore"; -import { - type DiffRouteSearch, - parseDiffRouteSearch, - stripDiffSearchParams, -} from "../diffRouteSearch"; -import { useMediaQuery } from "../hooks/useMediaQuery"; -import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout"; +import { type DiffRouteSearch, parseDiffRouteSearch } from "../diffRouteSearch"; import { selectEnvironmentState, selectThreadExistsByRef, useStore } from "../store"; import { createThreadSelectorByRef } from "../storeSelectors"; -import { resolveThreadRouteRef, buildThreadRouteParams } from "../threadRoutes"; -import { RightPanelSheet } from "../components/RightPanelSheet"; -import { Sidebar, SidebarInset, SidebarProvider, SidebarRail } from "~/components/ui/sidebar"; - -const DiffPanel = lazy(() => import("../components/DiffPanel")); -const DIFF_INLINE_SIDEBAR_WIDTH_STORAGE_KEY = "chat_diff_sidebar_width"; -const DIFF_INLINE_DEFAULT_WIDTH = "clamp(24rem,34vw,36rem)"; -const DIFF_INLINE_SIDEBAR_MIN_WIDTH = 22 * 16; -const DIFF_INLINE_SIDEBAR_MAX_WIDTH = 256 * 16; -const COMPOSER_COMPACT_MIN_LEFT_CONTROLS_WIDTH_PX = 208; - -const DiffLoadingFallback = (props: { mode: DiffPanelMode }) => { - return ( - <DiffPanelShell mode={props.mode} header={<DiffPanelHeaderSkeleton />}> - <DiffPanelLoadingState label="Loading diff viewer..." /> - </DiffPanelShell> - ); -}; - -const LazyDiffPanel = (props: { mode: DiffPanelMode }) => { - return ( - <DiffWorkerPoolProvider> - <Suspense fallback={<DiffLoadingFallback mode={props.mode} />}> - <DiffPanel mode={props.mode} /> - </Suspense> - </DiffWorkerPoolProvider> - ); -}; - -const DiffPanelInlineSidebar = (props: { - diffOpen: boolean; - onCloseDiff: () => void; - onOpenDiff: () => void; - renderDiffContent: boolean; -}) => { - const { diffOpen, onCloseDiff, onOpenDiff, renderDiffContent } = props; - const onOpenChange = useCallback( - (open: boolean) => { - if (open) { - onOpenDiff(); - return; - } - onCloseDiff(); - }, - [onCloseDiff, onOpenDiff], - ); - const shouldAcceptInlineSidebarWidth = useCallback( - ({ nextWidth, wrapper }: { nextWidth: number; wrapper: HTMLElement }) => { - const composerForm = document.querySelector<HTMLElement>("[data-chat-composer-form='true']"); - if (!composerForm) return true; - const composerViewport = composerForm.parentElement; - if (!composerViewport) return true; - const previousSidebarWidth = wrapper.style.getPropertyValue("--sidebar-width"); - wrapper.style.setProperty("--sidebar-width", `${nextWidth}px`); - - const viewportStyle = window.getComputedStyle(composerViewport); - const viewportPaddingLeft = Number.parseFloat(viewportStyle.paddingLeft) || 0; - const viewportPaddingRight = Number.parseFloat(viewportStyle.paddingRight) || 0; - const viewportContentWidth = Math.max( - 0, - composerViewport.clientWidth - viewportPaddingLeft - viewportPaddingRight, - ); - const formRect = composerForm.getBoundingClientRect(); - const composerFooter = composerForm.querySelector<HTMLElement>( - "[data-chat-composer-footer='true']", - ); - const composerRightActions = composerForm.querySelector<HTMLElement>( - "[data-chat-composer-actions='right']", - ); - const composerRightActionsWidth = composerRightActions?.getBoundingClientRect().width ?? 0; - const composerFooterGap = composerFooter - ? Number.parseFloat(window.getComputedStyle(composerFooter).columnGap) || - Number.parseFloat(window.getComputedStyle(composerFooter).gap) || - 0 - : 0; - const minimumComposerWidth = - COMPOSER_COMPACT_MIN_LEFT_CONTROLS_WIDTH_PX + composerRightActionsWidth + composerFooterGap; - const hasComposerOverflow = composerForm.scrollWidth > composerForm.clientWidth + 0.5; - const overflowsViewport = formRect.width > viewportContentWidth + 0.5; - const violatesMinimumComposerWidth = composerForm.clientWidth + 0.5 < minimumComposerWidth; - - if (previousSidebarWidth.length > 0) { - wrapper.style.setProperty("--sidebar-width", previousSidebarWidth); - } else { - wrapper.style.removeProperty("--sidebar-width"); - } - - return !hasComposerOverflow && !overflowsViewport && !violatesMinimumComposerWidth; - }, - [], - ); - - return ( - <SidebarProvider - defaultOpen={false} - open={diffOpen} - onOpenChange={onOpenChange} - className="w-auto min-h-0 flex-none bg-transparent" - style={{ "--sidebar-width": DIFF_INLINE_DEFAULT_WIDTH } as React.CSSProperties} - > - <Sidebar - side="right" - collapsible="offcanvas" - className="border-l border-border bg-card text-foreground" - resizable={{ - maxWidth: DIFF_INLINE_SIDEBAR_MAX_WIDTH, - minWidth: DIFF_INLINE_SIDEBAR_MIN_WIDTH, - shouldAcceptWidth: shouldAcceptInlineSidebarWidth, - storageKey: DIFF_INLINE_SIDEBAR_WIDTH_STORAGE_KEY, - }} - > - {renderDiffContent ? <LazyDiffPanel mode="sidebar" /> : null} - <SidebarRail /> - </Sidebar> - </SidebarProvider> - ); -}; +import { resolveThreadRouteRef } from "../threadRoutes"; +import { SidebarInset } from "~/components/ui/sidebar"; function ChatThreadRouteView() { const navigate = useNavigate(); const threadRef = Route.useParams({ select: (params) => resolveThreadRouteRef(params), }); - const search = Route.useSearch(); const bootstrapComplete = useStore( (store) => selectEnvironmentState(store, threadRef?.environmentId ?? null).bootstrapComplete, ); @@ -159,120 +30,35 @@ function ChatThreadRouteView() { threadRef ? store.getDraftThreadByRef(threadRef) : null, ); const environmentHasDraftThreads = useComposerDraftStore((store) => { - if (!threadRef) { - return false; - } + if (!threadRef) return false; return store.hasDraftThreadsInEnvironment(threadRef.environmentId); }); const routeThreadExists = threadExists || draftThreadExists; const serverThreadStarted = threadHasStarted(serverThread); const environmentHasAnyThreads = environmentHasServerThreads || environmentHasDraftThreads; - const diffOpen = search.diff === "1"; - const shouldUseDiffSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY); - const currentThreadKey = threadRef ? `${threadRef.environmentId}:${threadRef.threadId}` : null; - const [diffPanelMountState, setDiffPanelMountState] = useState(() => ({ - threadKey: currentThreadKey, - hasOpenedDiff: diffOpen, - })); - const hasOpenedDiff = - diffPanelMountState.threadKey === currentThreadKey - ? diffPanelMountState.hasOpenedDiff - : diffOpen; - const markDiffOpened = useCallback(() => { - setDiffPanelMountState((previous) => { - if (previous.threadKey === currentThreadKey && previous.hasOpenedDiff) { - return previous; - } - return { - threadKey: currentThreadKey, - hasOpenedDiff: true, - }; - }); - }, [currentThreadKey]); - const closeDiff = useCallback(() => { - if (!threadRef) { - return; - } - void navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams(threadRef), - search: { diff: undefined }, - }); - }, [navigate, threadRef]); - const openDiff = useCallback(() => { - if (!threadRef) { - return; - } - markDiffOpened(); - void navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams(threadRef), - search: (previous) => { - const rest = stripDiffSearchParams(previous); - return { ...rest, diff: "1" }; - }, - }); - }, [markDiffOpened, navigate, threadRef]); useEffect(() => { - if (!threadRef || !bootstrapComplete) { - return; - } - + if (!threadRef || !bootstrapComplete) return; if (!routeThreadExists && environmentHasAnyThreads) { void navigate({ to: "/", replace: true }); } }, [bootstrapComplete, environmentHasAnyThreads, navigate, routeThreadExists, threadRef]); useEffect(() => { - if (!threadRef || !serverThreadStarted || !draftThread?.promotedTo) { - return; - } + if (!threadRef || !serverThreadStarted || !draftThread?.promotedTo) return; finalizePromotedDraftThreadByRef(threadRef); }, [draftThread?.promotedTo, serverThreadStarted, threadRef]); - if (!threadRef || !bootstrapComplete || !routeThreadExists) { - return null; - } - - const shouldRenderDiffContent = diffOpen || hasOpenedDiff; - - if (!shouldUseDiffSheet) { - return ( - <> - <SidebarInset className="h-svh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground md:h-dvh"> - <ChatView - environmentId={threadRef.environmentId} - threadId={threadRef.threadId} - onDiffPanelOpen={markDiffOpened} - reserveTitleBarControlInset={!diffOpen} - routeKind="server" - /> - </SidebarInset> - <DiffPanelInlineSidebar - diffOpen={diffOpen} - onCloseDiff={closeDiff} - onOpenDiff={openDiff} - renderDiffContent={shouldRenderDiffContent} - /> - </> - ); - } + if (!threadRef || !bootstrapComplete || !routeThreadExists) return null; return ( - <> - <SidebarInset className="h-svh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground md:h-dvh"> - <ChatView - environmentId={threadRef.environmentId} - threadId={threadRef.threadId} - onDiffPanelOpen={markDiffOpened} - routeKind="server" - /> - </SidebarInset> - <RightPanelSheet open={diffOpen} onClose={closeDiff}> - {shouldRenderDiffContent ? <LazyDiffPanel mode="sheet" /> : null} - </RightPanelSheet> - </> + <SidebarInset className="h-svh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground md:h-dvh"> + <ChatView + environmentId={threadRef.environmentId} + threadId={threadRef.threadId} + routeKind="server" + /> + </SidebarInset> ); } diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 970b1679764..beb40aadff9 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -934,6 +934,67 @@ describe("deriveWorkLogEntries", () => { expect(entry?.toolLifecycleStatus).toBe("completed"); }); + it("preserves MCP server, tool, arguments, and results for expanded display", () => { + const item = { + type: "mcpToolCall", + server: "t3-code", + tool: "preview_status", + arguments: {}, + status: "completed", + result: { content: [{ type: "text", text: "attached" }] }, + }; + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "mcp-tool-done", + kind: "tool.completed", + summary: "t3-code · preview_status", + payload: { + itemType: "mcp_tool_call", + title: "t3-code · preview_status", + data: { item }, + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities); + expect(entry?.toolTitle).toBe("t3-code · preview_status"); + expect(entry?.toolData).toEqual(item); + }); + + it("keeps MCP payloads while collapsing lifecycle updates", () => { + const item = { + type: "mcpToolCall", + server: "t3-code", + tool: "preview_snapshot", + arguments: { interactiveOnly: true }, + status: "completed", + }; + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "mcp-tool-progress", + kind: "tool.updated", + summary: "t3-code · preview_snapshot", + payload: { + itemType: "mcp_tool_call", + toolCallId: "call-1", + data: { item }, + }, + }), + makeActivity({ + id: "mcp-tool-complete", + kind: "tool.completed", + summary: "t3-code · preview_snapshot", + payload: { + itemType: "mcp_tool_call", + toolCallId: "call-1", + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities); + expect(entry?.toolData).toEqual(item); + }); + it("unwraps PowerShell command wrappers for displayed command text", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 95680e98b75..5576ebeffc1 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -71,6 +71,7 @@ export interface WorkLogEntry { changedFiles?: ReadonlyArray<string>; tone: "thinking" | "tool" | "info" | "error"; toolTitle?: string; + toolData?: unknown; itemType?: ToolLifecycleItemType; requestKind?: PendingApproval["requestKind"]; /** From runtime item / task payload `status` when present (e.g. tool.updated). */ @@ -734,6 +735,12 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo if (title) { entry.toolTitle = title; } + if (itemType === "mcp_tool_call") { + const data = asRecord(payload?.data); + if (data?.item !== undefined) { + entry.toolData = data.item; + } + } if (itemType) { entry.itemType = itemType; } @@ -811,6 +818,7 @@ function mergeDerivedWorkLogEntries( const collapseKey = next.collapseKey ?? previous.collapseKey; const toolCallId = next.toolCallId ?? previous.toolCallId; const toolLifecycleStatus = next.toolLifecycleStatus ?? previous.toolLifecycleStatus; + const toolData = next.toolData ?? previous.toolData; return { ...previous, ...next, @@ -824,6 +832,7 @@ function mergeDerivedWorkLogEntries( ...(collapseKey ? { collapseKey } : {}), ...(toolCallId ? { toolCallId } : {}), ...(toolLifecycleStatus !== undefined ? { toolLifecycleStatus } : {}), + ...(toolData !== undefined ? { toolData } : {}), }; } diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index bb0a1bd1eb5..00c5f130c18 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -451,6 +451,7 @@ export interface DesktopPreviewTabState { canGoForward: boolean; /** Current zoom factor (1.0 = 100%). */ zoomFactor: number; + controller: "human" | "agent" | "none"; updatedAt: string; } @@ -477,6 +478,32 @@ export interface DesktopPreviewWebviewConfig { preloadUrl: string | null; } +export interface DesktopPreviewRecordingFrame { + tabId: string; + data: string; + width: number; + height: number; + receivedAt: string; +} + +export interface DesktopPreviewRecordingArtifact { + id: string; + tabId: string; + path: string; + mimeType: string; + sizeBytes: number; + createdAt: string; +} + +export interface DesktopPreviewScreenshotArtifact { + id: string; + tabId: string; + path: string; + mimeType: "image/png"; + sizeBytes: number; + createdAt: string; +} + /** * Single stack frame captured by react-grab's `getElementContext`. We surface * the source file/line so coding agents can jump straight to the JSX that @@ -672,7 +699,7 @@ export interface DesktopPreviewBridge { * `getPickPreloadPath`) so adding a new field here only requires touching * the contract + main, not the renderer's mount logic. */ - getPreviewConfig: () => Promise<DesktopPreviewWebviewConfig>; + getPreviewConfig: (environmentId: EnvironmentId) => Promise<DesktopPreviewWebviewConfig>; /** * Activate the in-page element picker for the given tab. Resolves with * the picked payload, or `null` when the user cancels (Escape / nav). The @@ -681,6 +708,17 @@ export interface DesktopPreviewBridge { pickElement: (tabId: string) => Promise<PreviewAnnotationPayload | null>; /** Cancel an in-flight preview annotation session. */ cancelPickElement: (tabId: string) => Promise<void>; + captureScreenshot: (tabId: string) => Promise<DesktopPreviewScreenshotArtifact>; + recording: { + startScreencast: (tabId: string) => Promise<void>; + stopScreencast: (tabId: string) => Promise<void>; + save: ( + tabId: string, + mimeType: string, + data: Uint8Array, + ) => Promise<DesktopPreviewRecordingArtifact>; + onFrame: (listener: (frame: DesktopPreviewRecordingFrame) => void) => () => void; + }; automation: { status: (tabId: string) => Promise<PreviewAutomationStatus>; snapshot: (tabId: string) => Promise<PreviewAutomationSnapshot>; diff --git a/packages/contracts/src/previewAutomation.ts b/packages/contracts/src/previewAutomation.ts index eaadbcb48c9..791591a7a9b 100644 --- a/packages/contracts/src/previewAutomation.ts +++ b/packages/contracts/src/previewAutomation.ts @@ -3,10 +3,19 @@ import { Schema } from "effect"; import { EnvironmentId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; import { PreviewTabId } from "./preview.ts"; -const BoundedUrl = TrimmedNonEmptyString.check(Schema.isMaxLength(2048)); +const BoundedUrl = Schema.String.check(Schema.isTrimmed()) + .check( + Schema.isNonEmpty({ + description: + "Absolute http(s) URL or a schemeless host such as t3.chat or localhost:5173. Schemeless public hosts use https; loopback hosts use http.", + }), + ) + .check(Schema.isMaxLength(2048)); const OptionalTimeoutMs = Schema.optional( - Schema.Int.check(Schema.isGreaterThan(0)).check(Schema.isLessThanOrEqualTo(60_000)), -); + Schema.Int.check(Schema.isGreaterThan(0)) + .check(Schema.isLessThanOrEqualTo(60_000)) + .annotate({ description: "Maximum wait in milliseconds. Defaults to 15000; maximum 60000." }), +).annotate({ description: "Maximum wait in milliseconds. Defaults to 15000; maximum 60000." }); export const PreviewAutomationOperation = Schema.Literals([ "status", @@ -19,6 +28,8 @@ export const PreviewAutomationOperation = Schema.Literals([ "scroll", "evaluate", "waitFor", + "recordingStart", + "recordingStop", ]); export type PreviewAutomationOperation = typeof PreviewAutomationOperation.Type; @@ -33,66 +44,294 @@ export const PreviewAutomationStatus = Schema.Struct({ export type PreviewAutomationStatus = typeof PreviewAutomationStatus.Type; export const PreviewAutomationOpenInput = Schema.Struct({ - url: Schema.optional(BoundedUrl), - show: Schema.optional(Schema.Boolean), - reuseExistingTab: Schema.optional(Schema.Boolean), + url: Schema.optional(BoundedUrl).annotate({ + description: + "Optional initial page URL, for example https://t3.chat or localhost:5173. Omit to open a blank tab.", + }), + show: Schema.optional( + Schema.Boolean.annotate({ + description: "Whether to reveal the preview panel to the human. Defaults to true.", + }), + ), + reuseExistingTab: Schema.optional( + Schema.Boolean.annotate({ + description: + "Reuse the thread's active browser tab when available. Defaults to true; set false to create a new tab.", + }), + ), +}).annotate({ + description: + "Opens the collaborative browser for the current thread. Use preview_navigate afterward when readiness waiting matters.", }); export type PreviewAutomationOpenInput = typeof PreviewAutomationOpenInput.Type; +export const BrowserNavigationTarget = Schema.Union([ + Schema.Struct({ + kind: Schema.Literal("url").annotate({ + description: "Selects direct URL navigation.", + }), + url: BoundedUrl.annotate({ + description: "Direct website URL.", + }), + }), + Schema.Struct({ + kind: Schema.Literal("environment-port").annotate({ + description: "Selects a dev-server port relative to the current execution environment.", + }), + port: Schema.Int.check(Schema.isGreaterThan(0)) + .check(Schema.isLessThan(65_536)) + .annotate({ description: "Dev-server TCP port inside the current environment." }), + protocol: Schema.optional( + Schema.Literals(["http", "https"]).annotate({ + description: "Dev-server protocol. Defaults to http.", + }), + ), + path: Schema.optional( + Schema.String.annotate({ + description: "Optional path, query, and fragment, for example /settings?tab=account.", + }), + ), + }), +]); +export type BrowserNavigationTarget = typeof BrowserNavigationTarget.Type; + export const PreviewAutomationNavigateInput = Schema.Struct({ - url: BoundedUrl, - readiness: Schema.optional(Schema.Literals(["load", "domContentLoaded", "none"])), + url: Schema.optional(BoundedUrl).annotate({ + description: + "Website URL, for example https://t3.chat. Use this for public pages and directly reachable URLs.", + }), + target: Schema.optional( + BrowserNavigationTarget.annotate({ + description: + "Environment-relative target. Prefer {kind:'environment-port',port:5173} for a dev server in the current environment.", + }), + ).annotate({ + description: + "Environment-relative target. Prefer {kind:'environment-port',port:5173} for a dev server in the current environment.", + }), + readiness: Schema.optional( + Schema.Literals(["load", "domContentLoaded", "none"]).annotate({ + description: + "Readiness milestone before returning. 'load' waits for loading to stop (default), 'domContentLoaded' waits for an interactive document, and 'none' returns immediately.", + }), + ).annotate({ + description: + "Readiness milestone before returning. 'load' is the default; use 'none' only when a later wait call will verify the page.", + }), timeoutMs: OptionalTimeoutMs, -}); +}) + .check( + Schema.makeFilter( + (input) => + Number(input.url !== undefined) + Number(input.target !== undefined) === 1 || + "Provide exactly one of url or target.", + ), + ) + .annotate({ + description: + "Navigates the active browser tab. Provide exactly one of url or target; for most public pages use url.", + }); export type PreviewAutomationNavigateInput = typeof PreviewAutomationNavigateInput.Type; -export const PreviewAutomationClickInput = Schema.Union([ - Schema.Struct({ - selector: TrimmedNonEmptyString, - timeoutMs: OptionalTimeoutMs, +const Locator = TrimmedNonEmptyString.annotate({ + description: + "Playwright selector, preferably role/text based, for example role=button[name='Send'] or text=Continue. Use snapshot first to inspect the page.", +}); + +const LegacySelector = TrimmedNonEmptyString.annotate({ + description: + "Legacy CSS selector such as button[type='submit']. Prefer locator for resilient role/text targeting.", +}); + +export const PreviewAutomationClickInput = Schema.Struct({ + selector: Schema.optional(LegacySelector).annotate({ + description: + "Legacy CSS selector such as button[type='submit']. Prefer locator for resilient role/text targeting.", }), - Schema.Struct({ - x: Schema.Number, - y: Schema.Number, - timeoutMs: OptionalTimeoutMs, + locator: Schema.optional(Locator).annotate({ + description: + "Playwright selector, preferably role/text based, for example role=button[name='Send'] or text=Continue. Use snapshot first to inspect the page.", }), -]); + x: Schema.optional( + Schema.Finite.annotate({ + description: "Viewport-relative X coordinate in CSS pixels. Must be paired with y.", + }), + ), + y: Schema.optional( + Schema.Finite.annotate({ + description: "Viewport-relative Y coordinate in CSS pixels. Must be paired with x.", + }), + ), + timeoutMs: OptionalTimeoutMs, +}) + .check( + Schema.makeFilter((input) => { + const selectorModes = + Number(input.selector !== undefined) + Number(input.locator !== undefined); + const hasX = input.x !== undefined; + const hasY = input.y !== undefined; + if (hasX !== hasY) return "Coordinates require both x and y."; + const coordinateModes = hasX && hasY ? 1 : 0; + return selectorModes + coordinateModes === 1 || "Provide exactly one click target."; + }), + ) + .annotate({ + description: + "Clicks one target. Provide exactly one of locator, selector, or the x/y coordinate pair.", + }); export type PreviewAutomationClickInput = typeof PreviewAutomationClickInput.Type; export const PreviewAutomationTypeInput = Schema.Struct({ - text: Schema.String, - selector: Schema.optional(TrimmedNonEmptyString), - clear: Schema.optional(Schema.Boolean), + text: Schema.String.annotate({ description: "Literal text to insert." }), + selector: Schema.optional(LegacySelector).annotate({ + description: "Legacy CSS selector for the input. Prefer locator.", + }), + locator: Schema.optional(Locator).annotate({ + description: + "Playwright selector for the input, for example role=textbox[name='Message'] or textarea[placeholder*='Message'].", + }), + clear: Schema.optional( + Schema.Boolean.annotate({ + description: "Clear the existing input value before inserting text. Defaults to false.", + }), + ), timeoutMs: OptionalTimeoutMs, -}); +}) + .check( + Schema.makeFilter( + (input) => + !(input.selector !== undefined && input.locator !== undefined) || + "Provide at most one of selector or locator.", + ), + ) + .annotate({ + description: + "Types into locator/selector, or into the currently focused element when neither target is provided.", + }); export type PreviewAutomationTypeInput = typeof PreviewAutomationTypeInput.Type; export const PreviewAutomationPressInput = Schema.Struct({ - key: TrimmedNonEmptyString, - modifiers: Schema.optional(Schema.Array(Schema.Literals(["Alt", "Control", "Meta", "Shift"]))), -}); + key: Schema.String.check(Schema.isTrimmed()) + .check( + Schema.isNonEmpty({ + description: + "Keyboard key name such as Enter, Escape, Tab, ArrowDown, Backspace, or a single character.", + }), + ) + .annotateKey({ + description: + "Keyboard key name such as Enter, Escape, Tab, ArrowDown, Backspace, or a single character.", + }), + modifiers: Schema.optional( + Schema.Array(Schema.Literals(["Alt", "Control", "Meta", "Shift"])).annotate({ + description: "Modifier keys held while pressing key.", + }), + ), +}).annotate({ description: "Presses one keyboard key in the active browser tab." }); export type PreviewAutomationPressInput = typeof PreviewAutomationPressInput.Type; export const PreviewAutomationScrollInput = Schema.Struct({ - deltaX: Schema.optional(Schema.Number), - deltaY: Schema.optional(Schema.Number), - selector: Schema.optional(TrimmedNonEmptyString), -}); + deltaX: Schema.optional( + Schema.Finite.annotate({ + description: "Horizontal scroll delta in CSS pixels. Positive scrolls right. Defaults to 0.", + }), + ), + deltaY: Schema.optional( + Schema.Finite.annotate({ + description: "Vertical scroll delta in CSS pixels. Positive scrolls down. Defaults to 0.", + }), + ), + selector: Schema.optional(LegacySelector).annotate({ + description: "Legacy CSS selector for a scrollable container. Omit to scroll the viewport.", + }), + locator: Schema.optional(Locator).annotate({ + description: "Playwright selector for a scrollable container. Omit to scroll the viewport.", + }), +}) + .check( + Schema.makeFilter((input) => { + if (input.selector !== undefined && input.locator !== undefined) { + return "Provide at most one of selector or locator."; + } + return ( + input.deltaX !== undefined || input.deltaY !== undefined || "Provide deltaX or deltaY." + ); + }), + ) + .annotate({ + description: + "Scrolls the viewport, or a locator/selector container. Provide deltaX, deltaY, or both.", + }); export type PreviewAutomationScrollInput = typeof PreviewAutomationScrollInput.Type; export const PreviewAutomationEvaluateInput = Schema.Struct({ - expression: TrimmedNonEmptyString.check(Schema.isMaxLength(64_000)), - awaitPromise: Schema.optional(Schema.Boolean), - returnByValue: Schema.optional(Schema.Boolean), + expression: Schema.String.check(Schema.isTrimmed()) + .check( + Schema.isNonEmpty({ + description: + "JavaScript expression evaluated in the page's main frame, for example document.title or (() => ({href: location.href}))().", + }), + ) + .check(Schema.isMaxLength(64_000)) + .annotateKey({ + description: + "JavaScript expression evaluated in the page's main frame, for example document.title or (() => ({href: location.href}))().", + }), + awaitPromise: Schema.optional( + Schema.Boolean.annotate({ description: "Await a returned Promise. Defaults to true." }), + ), + returnByValue: Schema.optional( + Schema.Boolean.annotate({ + description: + "Serialize and return the value instead of a remote object reference. Defaults to true.", + }), + ), +}).annotate({ + description: + "Evaluates JavaScript in the page. Prefer snapshot and semantic actions; use evaluate for inspection or unsupported interactions.", }); export type PreviewAutomationEvaluateInput = typeof PreviewAutomationEvaluateInput.Type; export const PreviewAutomationWaitForInput = Schema.Struct({ - selector: Schema.optional(TrimmedNonEmptyString), - text: Schema.optional(TrimmedNonEmptyString), - urlIncludes: Schema.optional(TrimmedNonEmptyString), + selector: Schema.optional(LegacySelector).annotate({ + description: "Legacy CSS selector that must match an element. Prefer locator.", + }), + locator: Schema.optional(Locator).annotate({ + description: + "Playwright selector that must match an element, for example role=button[name='Send'].", + }), + text: Schema.optional( + TrimmedNonEmptyString.annotate({ + description: "Case-sensitive substring that must appear in visible document text.", + }), + ).annotate({ + description: "Case-sensitive substring that must appear in visible document text.", + }), + urlIncludes: Schema.optional( + TrimmedNonEmptyString.annotate({ + description: "Substring that must appear in the current absolute URL.", + }), + ).annotate({ description: "Substring that must appear in the current absolute URL." }), timeoutMs: OptionalTimeoutMs, -}); +}) + .check( + Schema.makeFilter((input) => { + if (input.selector !== undefined && input.locator !== undefined) { + return "Provide at most one of selector or locator."; + } + return ( + input.selector !== undefined || + input.locator !== undefined || + input.text !== undefined || + input.urlIncludes !== undefined || + "Provide at least one wait condition." + ); + }), + ) + .annotate({ + description: + "Waits until all provided conditions match. Use after click/type when the page changes asynchronously.", + }); export type PreviewAutomationWaitForInput = typeof PreviewAutomationWaitForInput.Type; export const PreviewAutomationElement = Schema.Struct({ @@ -107,6 +346,34 @@ export const PreviewAutomationElement = Schema.Struct({ }); export type PreviewAutomationElement = typeof PreviewAutomationElement.Type; +export const PreviewAutomationConsoleEntry = Schema.Struct({ + level: Schema.String, + text: Schema.String, + timestamp: Schema.String, + source: Schema.optional(Schema.String), +}); +export type PreviewAutomationConsoleEntry = typeof PreviewAutomationConsoleEntry.Type; + +export const PreviewAutomationNetworkEntry = Schema.Struct({ + url: Schema.String, + method: Schema.String, + status: Schema.NullOr(Schema.Number), + failed: Schema.Boolean, + errorText: Schema.optional(Schema.String), + timestamp: Schema.String, +}); +export type PreviewAutomationNetworkEntry = typeof PreviewAutomationNetworkEntry.Type; + +export const PreviewAutomationActionEvent = Schema.Struct({ + id: Schema.String, + action: Schema.String, + status: Schema.Literals(["running", "succeeded", "failed", "interrupted"]), + startedAt: Schema.String, + completedAt: Schema.optional(Schema.String), + error: Schema.optional(Schema.String), +}); +export type PreviewAutomationActionEvent = typeof PreviewAutomationActionEvent.Type; + export const PreviewAutomationSnapshot = Schema.Struct({ url: Schema.String, title: Schema.String, @@ -114,6 +381,9 @@ export const PreviewAutomationSnapshot = Schema.Struct({ visibleText: Schema.String, interactiveElements: Schema.Array(PreviewAutomationElement), accessibilityTree: Schema.Unknown, + consoleEntries: Schema.Array(PreviewAutomationConsoleEntry), + networkEntries: Schema.Array(PreviewAutomationNetworkEntry), + actionTimeline: Schema.Array(PreviewAutomationActionEvent), screenshot: Schema.Struct({ mimeType: Schema.Literal("image/png"), data: Schema.String, @@ -123,6 +393,23 @@ export const PreviewAutomationSnapshot = Schema.Struct({ }); export type PreviewAutomationSnapshot = typeof PreviewAutomationSnapshot.Type; +export const PreviewAutomationRecordingStatus = Schema.Struct({ + tabId: PreviewTabId, + recording: Schema.Boolean, + startedAt: Schema.NullOr(Schema.String), +}); +export type PreviewAutomationRecordingStatus = typeof PreviewAutomationRecordingStatus.Type; + +export const PreviewAutomationRecordingArtifact = Schema.Struct({ + id: Schema.String, + tabId: PreviewTabId, + path: Schema.String, + mimeType: Schema.String, + sizeBytes: Schema.Int, + createdAt: Schema.String, +}); +export type PreviewAutomationRecordingArtifact = typeof PreviewAutomationRecordingArtifact.Type; + export const PreviewAutomationOwner = Schema.Struct({ clientId: TrimmedNonEmptyString, environmentId: EnvironmentId, @@ -183,6 +470,11 @@ export class PreviewAutomationTimeoutError extends Schema.TaggedErrorClass<Previ { message: Schema.String }, ) {} +export class PreviewAutomationControlInterruptedError extends Schema.TaggedErrorClass<PreviewAutomationControlInterruptedError>()( + "PreviewAutomationControlInterruptedError", + { message: Schema.String }, +) {} + export class PreviewAutomationExecutionError extends Schema.TaggedErrorClass<PreviewAutomationExecutionError>()( "PreviewAutomationExecutionError", { message: Schema.String, detail: Schema.optional(Schema.Unknown) }, @@ -204,6 +496,7 @@ export const PreviewAutomationError = Schema.Union([ PreviewAutomationUnsupportedClientError, PreviewAutomationTabNotFoundError, PreviewAutomationTimeoutError, + PreviewAutomationControlInterruptedError, PreviewAutomationExecutionError, PreviewAutomationInvalidSelectorError, PreviewAutomationResultTooLargeError, @@ -213,7 +506,7 @@ export type PreviewAutomationError = typeof PreviewAutomationError.Type; export const PreviewUrlResolution = Schema.Struct({ requestedUrl: Schema.String, resolvedUrl: Schema.String, - resolutionKind: Schema.Literal("direct"), + resolutionKind: Schema.Literals(["direct", "direct-private-network"]), environmentId: EnvironmentId, }); export type PreviewUrlResolution = typeof PreviewUrlResolution.Type; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9b012818b61..de9a7b2a80e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -128,6 +128,9 @@ importers: electron-updater: specifier: ^6.6.2 version: 6.8.3 + playwright-core: + specifier: 1.60.0 + version: 1.60.0 react-grab: specifier: ^0.1.32 version: 0.1.44(react@19.2.6) From 932bc2d6315a3eab74bd632c245b202d3de55bec Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Fri, 12 Jun 2026 16:40:00 -0700 Subject: [PATCH 11/25] Port browser preview annotations to desktop - Add IPC and runtime plumbing for preview annotation theming - Generate and ship annotation CSS for the desktop overlay - Add pointer and artifact handling for browser preview interactions --- apps/desktop/package.json | 1 + .../scripts/build-preview-annotation-css.mjs | 40 + .../src/app/DesktopEnvironment.test.ts | 2 + apps/desktop/src/app/DesktopEnvironment.ts | 2 + apps/desktop/src/ipc/DesktopIpcHandlers.ts | 4 + apps/desktop/src/ipc/channels.ts | 4 + apps/desktop/src/ipc/methods/preview.ts | 64 ++ apps/desktop/src/preload.ts | 16 + .../preview-annotation-styles.generated.ts | 3 + apps/desktop/src/preview-annotation.css | 68 ++ apps/desktop/src/preview-pick-preload.ts | 415 ++++++---- apps/desktop/src/preview-view-manager.test.ts | 191 ++++- apps/desktop/src/preview-view-manager.ts | 232 +++++- apps/desktop/vite.config.ts | 7 +- apps/server/src/keybindings.test.ts | 2 + .../src/mcp/Layers/McpHttpServer.test.ts | 28 +- apps/server/src/mcp/Layers/McpHttpServer.ts | 7 +- .../src/preview/Layers/PortScanner.test.ts | 75 +- apps/server/src/preview/Layers/PortScanner.ts | 155 +++- .../src/preview/Services/PortScanner.ts | 26 +- apps/server/src/server.test.ts | 6 +- apps/server/src/server.ts | 12 +- .../src/terminal/Layers/Manager.test.ts | 14 +- apps/server/src/terminal/Layers/Manager.ts | 112 ++- apps/server/src/ws.ts | 10 +- apps/web/src/browser/ElectronBrowserHost.tsx | 47 +- apps/web/src/browser/annotationTheme.ts | 28 + .../src/browser/browserPointerStore.test.ts | 68 ++ apps/web/src/browser/browserPointerStore.ts | 25 + apps/web/src/components/ChatView.browser.tsx | 143 +++- apps/web/src/components/ChatView.tsx | 722 ++++++++++++++---- apps/web/src/components/RightPanelTabs.tsx | 111 ++- apps/web/src/components/Sidebar.tsx | 38 + .../src/components/ThreadTerminalDrawer.tsx | 156 +++- apps/web/src/components/chat/ChatHeader.tsx | 6 +- .../components/preview/AgentBrowserCursor.tsx | 52 ++ .../preview/PreviewChromeRow.browser.tsx | 41 + .../src/components/preview/PreviewView.tsx | 232 +++++- .../preview/agentBrowserCursorLogic.test.ts | 19 + .../preview/agentBrowserCursorLogic.ts | 6 + .../preview/fileExplorerLabel.test.ts | 13 + .../components/preview/fileExplorerLabel.ts | 6 + .../components/preview/openDiscoveredPort.ts | 24 + .../components/preview/openPreviewSession.ts | 7 +- .../preview/previewEmptyStateLogic.test.ts | 42 + .../preview/previewEmptyStateLogic.ts | 11 + .../preview/useDiscoveredLocalServers.test.ts | 1 + .../preview/useDiscoveredLocalServers.ts | 23 +- .../components/preview/usePreviewBridge.ts | 20 +- apps/web/src/components/ui/toast.tsx | 22 +- apps/web/src/environments/runtime/service.ts | 18 + apps/web/src/keybindings.test.ts | 35 +- apps/web/src/keybindings.ts | 8 + apps/web/src/lib/terminalFocus.test.ts | 22 +- apps/web/src/lib/terminalFocus.ts | 19 +- apps/web/src/portDiscoveryState.ts | 86 +++ apps/web/src/previewStateStore.test.ts | 36 + apps/web/src/previewStateStore.ts | 54 +- apps/web/src/rightPanelStore.test.ts | 165 +++- apps/web/src/rightPanelStore.ts | 229 +++++- apps/web/src/rpc/requestLatencyState.test.ts | 8 + apps/web/src/rpc/requestLatencyState.ts | 4 +- apps/web/src/terminalUiStateStore.test.ts | 18 + apps/web/src/terminalUiStateStore.ts | 19 +- apps/web/src/types.ts | 1 + packages/contracts/src/ipc.ts | 33 + packages/contracts/src/keybindings.test.ts | 9 +- packages/contracts/src/keybindings.ts | 2 + packages/contracts/src/preview.test.ts | 4 + packages/contracts/src/preview.ts | 6 + packages/shared/src/keybindings.ts | 2 + pnpm-lock.yaml | 3 + 72 files changed, 3633 insertions(+), 507 deletions(-) create mode 100644 apps/desktop/scripts/build-preview-annotation-css.mjs create mode 100644 apps/desktop/src/preview-annotation-styles.generated.ts create mode 100644 apps/desktop/src/preview-annotation.css create mode 100644 apps/web/src/browser/annotationTheme.ts create mode 100644 apps/web/src/browser/browserPointerStore.test.ts create mode 100644 apps/web/src/browser/browserPointerStore.ts create mode 100644 apps/web/src/components/preview/AgentBrowserCursor.tsx create mode 100644 apps/web/src/components/preview/PreviewChromeRow.browser.tsx create mode 100644 apps/web/src/components/preview/agentBrowserCursorLogic.test.ts create mode 100644 apps/web/src/components/preview/agentBrowserCursorLogic.ts create mode 100644 apps/web/src/components/preview/fileExplorerLabel.test.ts create mode 100644 apps/web/src/components/preview/fileExplorerLabel.ts create mode 100644 apps/web/src/components/preview/openDiscoveredPort.ts create mode 100644 apps/web/src/components/preview/previewEmptyStateLogic.test.ts create mode 100644 apps/web/src/components/preview/previewEmptyStateLogic.ts create mode 100644 apps/web/src/portDiscoveryState.ts diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 0d18b4c71f5..bba35c8de8b 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -29,6 +29,7 @@ "@types/node": "catalog:", "cross-env": "^10.1.0", "electron-builder": "26.8.1", + "tailwindcss": "^4.0.0", "vite-plus": "catalog:" }, "productName": "T3 Code (Alpha)" diff --git a/apps/desktop/scripts/build-preview-annotation-css.mjs b/apps/desktop/scripts/build-preview-annotation-css.mjs new file mode 100644 index 00000000000..e9dcff227d7 --- /dev/null +++ b/apps/desktop/scripts/build-preview-annotation-css.mjs @@ -0,0 +1,40 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { compile } from "tailwindcss"; + +const directory = dirname(fileURLToPath(import.meta.url)); +const appRoot = join(directory, ".."); +const sourcePath = join(appRoot, "src", "preview-annotation.css"); +const preloadPath = join(appRoot, "src", "preview-pick-preload.ts"); +const outputPath = join(appRoot, "src", "preview-annotation-styles.generated.ts"); +const require = createRequire(import.meta.url); +const tailwindRoot = dirname(require.resolve("tailwindcss/package.json")); + +const [annotationSource, preloadSource, themeSource, preflightSource] = await Promise.all([ + readFile(sourcePath, "utf8"), + readFile(preloadPath, "utf8"), + readFile(join(tailwindRoot, "theme.css"), "utf8"), + readFile(join(tailwindRoot, "preflight.css"), "utf8"), +]); + +const candidates = new Set( + Array.from(preloadSource.matchAll(/!?-?[A-Za-z0-9_:@/.[\]()%,-]+/g), (match) => match[0]), +); +const compilerInput = [ + themeSource, + preflightSource, + annotationSource.replace('@import "tailwindcss";', "@tailwind utilities;"), +].join("\n"); +const compiler = await compile(compilerInput, { base: appRoot }); +const css = compiler.build([...candidates]); +const encodedCss = `'${css + .replaceAll("\\", "\\\\") + .replaceAll("'", "\\'") + .replaceAll("\r", "\\r") + .replaceAll("\n", "\\n")}'`; +const moduleSource = `// Generated by scripts/build-preview-annotation-css.mjs. Do not edit.\nexport const previewAnnotationStyles =\n ${encodedCss};\n`; + +await writeFile(outputPath, moduleSource); diff --git a/apps/desktop/src/app/DesktopEnvironment.test.ts b/apps/desktop/src/app/DesktopEnvironment.test.ts index ee732bf830c..92da3f887ac 100644 --- a/apps/desktop/src/app/DesktopEnvironment.test.ts +++ b/apps/desktop/src/app/DesktopEnvironment.test.ts @@ -59,6 +59,7 @@ describe("DesktopEnvironment", () => { assert.equal(environment.savedEnvironmentRegistryPath, "/tmp/t3/dev/saved-environments.json"); assert.equal(environment.serverSettingsPath, "/tmp/t3/dev/settings.json"); assert.equal(environment.logDir, "/tmp/t3/dev/logs"); + assert.equal(environment.browserArtifactsDir, "/tmp/t3/dev/browser-artifacts"); assert.equal(environment.rootDir, "/repo"); assert.equal(environment.appRoot, "/repo"); assert.equal(environment.backendEntryPath, "/repo/apps/server/dist/bin.mjs"); @@ -89,6 +90,7 @@ describe("DesktopEnvironment", () => { assert.equal(environment.isDevelopment, false); assert.equal(environment.stateDir, "/tmp/t3/userdata"); assert.equal(environment.logDir, "/tmp/t3/userdata/logs"); + assert.equal(environment.browserArtifactsDir, "/tmp/t3/userdata/browser-artifacts"); assert.equal(environment.serverSettingsPath, "/tmp/t3/userdata/settings.json"); }), ); diff --git a/apps/desktop/src/app/DesktopEnvironment.ts b/apps/desktop/src/app/DesktopEnvironment.ts index 431e0d34d81..5a6be92ac11 100644 --- a/apps/desktop/src/app/DesktopEnvironment.ts +++ b/apps/desktop/src/app/DesktopEnvironment.ts @@ -49,6 +49,7 @@ export interface DesktopEnvironmentShape { readonly savedEnvironmentRegistryPath: string; readonly serverSettingsPath: string; readonly logDir: string; + readonly browserArtifactsDir: string; readonly rootDir: string; readonly appRoot: string; readonly backendEntryPath: string; @@ -183,6 +184,7 @@ const makeDesktopEnvironment = Effect.fn("desktop.environment.make")(function* ( savedEnvironmentRegistryPath: path.join(stateDir, "saved-environments.json"), serverSettingsPath: path.join(stateDir, "settings.json"), logDir: path.join(stateDir, "logs"), + browserArtifactsDir: path.join(stateDir, "browser-artifacts"), rootDir, appRoot, backendEntryPath: path.join(appRoot, "apps/server/dist/bin.mjs"), diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 55b7b60e2da..0cb8976ed3b 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -1,5 +1,7 @@ import * as Effect from "effect/Effect"; +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import { previewViewManager } from "../preview-view-manager.ts"; import * as DesktopIpc from "./DesktopIpc.ts"; import { clearCloudAuthToken, @@ -52,6 +54,8 @@ import { previewMethods } from "./methods/preview.ts"; export const installDesktopIpcHandlers = Effect.gen(function* () { const ipc = yield* DesktopIpc.DesktopIpc; + const environment = yield* DesktopEnvironment.DesktopEnvironment; + previewViewManager.configureArtifactDirectory(environment.browserArtifactsDir); yield* ipc.handleSync(getAppBranding); yield* ipc.handleSync(getLocalEnvironmentBootstrap); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index fe7b5b6f839..c5dabe0930f 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -54,9 +54,12 @@ export const PREVIEW_OPEN_DEVTOOLS_CHANNEL = "desktop:preview-open-devtools"; export const PREVIEW_CLEAR_COOKIES_CHANNEL = "desktop:preview-clear-cookies"; export const PREVIEW_CLEAR_CACHE_CHANNEL = "desktop:preview-clear-cache"; export const PREVIEW_GET_CONFIG_CHANNEL = "desktop:preview-get-config"; +export const PREVIEW_SET_ANNOTATION_THEME_CHANNEL = "desktop:preview-set-annotation-theme"; export const PREVIEW_PICK_ELEMENT_CHANNEL = "desktop:preview-pick-element"; export const PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL = "desktop:preview-cancel-pick-element"; export const PREVIEW_CAPTURE_SCREENSHOT_CHANNEL = "desktop:preview-capture-screenshot"; +export const PREVIEW_REVEAL_ARTIFACT_CHANNEL = "desktop:preview-reveal-artifact"; +export const PREVIEW_COPY_ARTIFACT_CHANNEL = "desktop:preview-copy-artifact"; export const PREVIEW_AUTOMATION_STATUS_CHANNEL = "desktop:preview-automation-status"; export const PREVIEW_AUTOMATION_SNAPSHOT_CHANNEL = "desktop:preview-automation-snapshot"; export const PREVIEW_AUTOMATION_CLICK_CHANNEL = "desktop:preview-automation-click"; @@ -70,3 +73,4 @@ export const PREVIEW_RECORDING_STOP_CHANNEL = "desktop:preview-recording-stop"; export const PREVIEW_RECORDING_SAVE_CHANNEL = "desktop:preview-recording-save"; export const PREVIEW_RECORDING_FRAME_CHANNEL = "desktop:preview-recording-frame"; export const PREVIEW_STATE_CHANGE_CHANNEL = "desktop:preview-state-change"; +export const PREVIEW_POINTER_EVENT_CHANNEL = "desktop:preview-pointer-event"; diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index 8683cd9d0b6..c9b8c58fcbd 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -1,6 +1,7 @@ // @effect-diagnostics nodeBuiltinImport:off import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; +import type { DesktopPreviewAnnotationTheme } from "@t3tools/contracts"; import { BrowserWindow } from "electron"; import { pathToFileURL } from "node:url"; @@ -25,6 +26,14 @@ previewViewManager.onRecordingFrame((frame) => { } }); +previewViewManager.onPointerEvent((event) => { + for (const window of BrowserWindow.getAllWindows()) { + if (!window.isDestroyed()) { + window.webContents.send(IpcChannels.PREVIEW_POINTER_EVENT_CHANNEL, event); + } + } +}); + const tabIdFrom = (raw: unknown): string => { if (typeof raw !== "object" || raw === null || !("tabId" in raw)) { throw new Error("preview tab id is required"); @@ -43,6 +52,44 @@ const inputFrom = (raw: unknown): unknown => { return raw.input; }; +const annotationThemeFrom = (raw: unknown): DesktopPreviewAnnotationTheme => { + if (typeof raw !== "object" || raw === null || !("theme" in raw)) { + throw new Error("preview annotation theme is required"); + } + const theme = raw.theme; + if (typeof theme !== "object" || theme === null) { + throw new Error("preview annotation theme must be an object"); + } + const record = theme as Record<string, unknown>; + const stringKeys = [ + "radius", + "background", + "foreground", + "popover", + "popoverForeground", + "primary", + "primaryForeground", + "muted", + "mutedForeground", + "accent", + "accentForeground", + "border", + "input", + "ring", + "fontSans", + "fontMono", + ] as const; + for (const key of stringKeys) { + if (typeof record[key] !== "string" || record[key].length === 0) { + throw new Error(`preview annotation theme ${key} must be a non-empty string`); + } + } + if (record["colorScheme"] !== "light" && record["colorScheme"] !== "dark") { + throw new Error("preview annotation theme colorScheme must be light or dark"); + } + return record as unknown as DesktopPreviewAnnotationTheme; +}; + class PreviewIpcError extends Data.TaggedError("PreviewIpcError")<{ readonly cause: unknown; }> {} @@ -117,6 +164,9 @@ export const previewMethods = [ preloadUrl: pathToFileURL(preloadPath).href, }; }), + method(IpcChannels.PREVIEW_SET_ANNOTATION_THEME_CHANNEL, (raw) => + previewViewManager.setAnnotationTheme(annotationThemeFrom(raw)), + ), method(IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL, (raw) => previewViewManager.pickElement(tabIdFrom(raw)), ), @@ -126,6 +176,20 @@ export const previewMethods = [ method(IpcChannels.PREVIEW_CAPTURE_SCREENSHOT_CHANNEL, (raw) => previewViewManager.captureScreenshot(tabIdFrom(raw)), ), + method(IpcChannels.PREVIEW_REVEAL_ARTIFACT_CHANNEL, (raw) => { + const path = typeof raw === "object" && raw !== null && "path" in raw ? raw.path : null; + if (typeof path !== "string" || path.trim().length === 0) { + throw new Error("preview artifact path is required"); + } + return previewViewManager.revealArtifact(path); + }), + method(IpcChannels.PREVIEW_COPY_ARTIFACT_CHANNEL, (raw) => { + const path = typeof raw === "object" && raw !== null && "path" in raw ? raw.path : null; + if (typeof path !== "string" || path.trim().length === 0) { + throw new Error("preview artifact path is required"); + } + return previewViewManager.copyArtifactToClipboard(path); + }), method(IpcChannels.PREVIEW_AUTOMATION_STATUS_CHANNEL, (raw) => previewViewManager.automationStatus(tabIdFrom(raw)), ), diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 8326673a862..ce12f19bf72 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -1,5 +1,6 @@ import type { DesktopBridge, + DesktopPreviewPointerEvent, DesktopPreviewRecordingFrame, DesktopPreviewTabState, } from "@t3tools/contracts"; @@ -165,11 +166,17 @@ contextBridge.exposeInMainWorld("desktopBridge", { clearCache: () => ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_CACHE_CHANNEL), getPreviewConfig: (environmentId) => ipcRenderer.invoke(IpcChannels.PREVIEW_GET_CONFIG_CHANNEL, { environmentId }), + setAnnotationTheme: (theme) => + ipcRenderer.invoke(IpcChannels.PREVIEW_SET_ANNOTATION_THEME_CHANNEL, { theme }), pickElement: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL, { tabId }), cancelPickElement: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL, { tabId }), captureScreenshot: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_CAPTURE_SCREENSHOT_CHANNEL, { tabId }), + revealArtifact: (path) => + ipcRenderer.invoke(IpcChannels.PREVIEW_REVEAL_ARTIFACT_CHANNEL, { path }), + copyArtifactToClipboard: (path) => + ipcRenderer.invoke(IpcChannels.PREVIEW_COPY_ARTIFACT_CHANNEL, { path }), recording: { startScreencast: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_RECORDING_START_CHANNEL, { tabId }), @@ -222,5 +229,14 @@ contextBridge.exposeInMainWorld("desktopBridge", { return () => ipcRenderer.removeListener(IpcChannels.PREVIEW_STATE_CHANGE_CHANNEL, wrappedListener); }, + onPointerEvent: (listener) => { + const wrappedListener = (_event: Electron.IpcRendererEvent, pointerEvent: unknown) => { + if (typeof pointerEvent !== "object" || pointerEvent === null) return; + listener(pointerEvent as DesktopPreviewPointerEvent); + }; + ipcRenderer.on(IpcChannels.PREVIEW_POINTER_EVENT_CHANNEL, wrappedListener); + return () => + ipcRenderer.removeListener(IpcChannels.PREVIEW_POINTER_EVENT_CHANNEL, wrappedListener); + }, }, } satisfies DesktopBridge); diff --git a/apps/desktop/src/preview-annotation-styles.generated.ts b/apps/desktop/src/preview-annotation-styles.generated.ts new file mode 100644 index 00000000000..5b6b73c8ba7 --- /dev/null +++ b/apps/desktop/src/preview-annotation-styles.generated.ts @@ -0,0 +1,3 @@ +// Generated by scripts/build-preview-annotation-css.mjs. Do not edit. +export const previewAnnotationStyles = + '/*! tailwindcss v4.3.0 | MIT License | https://tailwindcss.com */\n@layer properties;\n:root, :host {\n --spacing: 0.25rem;\n --text-xs: 0.75rem;\n --text-xs--line-height: calc(1 / 0.75);\n --text-sm: 0.875rem;\n --text-sm--line-height: calc(1.25 / 0.875);\n --text-lg: 1.125rem;\n --text-lg--line-height: calc(1.75 / 1.125);\n --font-weight-medium: 500;\n --font-weight-semibold: 600;\n --font-weight-bold: 700;\n --blur-xl: 24px;\n --default-font-family: var(--t3-font-sans);\n --default-mono-font-family: var(--t3-font-mono);\n}\n*, ::after, ::before, ::backdrop, ::file-selector-button {\n box-sizing: border-box;\n margin: 0;\n padding: 0;\n border: 0 solid;\n}\nhtml, :host {\n line-height: 1.5;\n -webkit-text-size-adjust: 100%;\n tab-size: 4;\n font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, \'Apple Color Emoji\', \'Segoe UI Emoji\', \'Segoe UI Symbol\', \'Noto Color Emoji\');\n font-feature-settings: var(--default-font-feature-settings, normal);\n font-variation-settings: var(--default-font-variation-settings, normal);\n -webkit-tap-highlight-color: transparent;\n}\nhr {\n height: 0;\n color: inherit;\n border-top-width: 1px;\n}\nabbr:where([title]) {\n -webkit-text-decoration: underline dotted;\n text-decoration: underline dotted;\n}\nh1, h2, h3, h4, h5, h6 {\n font-size: inherit;\n font-weight: inherit;\n}\na {\n color: inherit;\n -webkit-text-decoration: inherit;\n text-decoration: inherit;\n}\nb, strong {\n font-weight: bolder;\n}\ncode, kbd, samp, pre {\n font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \'Liberation Mono\', \'Courier New\', monospace);\n font-feature-settings: var(--default-mono-font-feature-settings, normal);\n font-variation-settings: var(--default-mono-font-variation-settings, normal);\n font-size: 1em;\n}\nsmall {\n font-size: 80%;\n}\nsub, sup {\n font-size: 75%;\n line-height: 0;\n position: relative;\n vertical-align: baseline;\n}\nsub {\n bottom: -0.25em;\n}\nsup {\n top: -0.5em;\n}\ntable {\n text-indent: 0;\n border-color: inherit;\n border-collapse: collapse;\n}\n:-moz-focusring {\n outline: auto;\n}\nprogress {\n vertical-align: baseline;\n}\nsummary {\n display: list-item;\n}\nol, ul, menu {\n list-style: none;\n}\nimg, svg, video, canvas, audio, iframe, embed, object {\n display: block;\n vertical-align: middle;\n}\nimg, video {\n max-width: 100%;\n height: auto;\n}\nbutton, input, select, optgroup, textarea, ::file-selector-button {\n font: inherit;\n font-feature-settings: inherit;\n font-variation-settings: inherit;\n letter-spacing: inherit;\n color: inherit;\n border-radius: 0;\n background-color: transparent;\n opacity: 1;\n}\n:where(select:is([multiple], [size])) optgroup {\n font-weight: bolder;\n}\n:where(select:is([multiple], [size])) optgroup option {\n padding-inline-start: 20px;\n}\n::file-selector-button {\n margin-inline-end: 4px;\n}\n::placeholder {\n opacity: 1;\n}\n@supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) {\n ::placeholder {\n color: currentcolor;\n @supports (color: color-mix(in lab, red, red)) {\n color: color-mix(in oklab, currentcolor 50%, transparent);\n }\n }\n}\ntextarea {\n resize: vertical;\n}\n::-webkit-search-decoration {\n -webkit-appearance: none;\n}\n::-webkit-date-and-time-value {\n min-height: 1lh;\n text-align: inherit;\n}\n::-webkit-datetime-edit {\n display: inline-flex;\n}\n::-webkit-datetime-edit-fields-wrapper {\n padding: 0;\n}\n::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field {\n padding-block: 0;\n}\n::-webkit-calendar-picker-indicator {\n line-height: 1;\n}\n:-moz-ui-invalid {\n box-shadow: none;\n}\nbutton, input:where([type=\'button\'], [type=\'reset\'], [type=\'submit\']), ::file-selector-button {\n appearance: button;\n}\n::-webkit-inner-spin-button, ::-webkit-outer-spin-button {\n height: auto;\n}\n[hidden]:where(:not([hidden=\'until-found\'])) {\n display: none !important;\n}\n.pointer-events-auto {\n pointer-events: auto;\n}\n.pointer-events-none {\n pointer-events: none;\n}\n.absolute {\n position: absolute;\n}\n.fixed {\n position: fixed;\n}\n.inset-0 {\n inset: calc(var(--spacing) * 0);\n}\n.top-1\\/2 {\n top: calc(1 / 2 * 100%);\n}\n.top-2\\.5 {\n top: calc(var(--spacing) * 2.5);\n}\n.right-2 {\n right: calc(var(--spacing) * 2);\n}\n.left-1\\/2 {\n left: calc(1 / 2 * 100%);\n}\n.z-1 {\n z-index: 1;\n}\n.block {\n display: block;\n}\n.flex {\n display: flex;\n}\n.grid {\n display: grid;\n}\n.hidden {\n display: none;\n}\n.inline-flex {\n display: inline-flex;\n}\n.h-7 {\n height: calc(var(--spacing) * 7);\n}\n.h-8 {\n height: calc(var(--spacing) * 8);\n}\n.max-h-24 {\n max-height: calc(var(--spacing) * 24);\n}\n.max-h-\\[calc\\(100vh-16px\\)\\] {\n max-height: calc(100vh - 16px);\n}\n.max-h-\\[min\\(176px\\,calc\\(100vh-180px\\)\\)\\] {\n max-height: min(176px, calc(100vh - 180px));\n}\n.min-h-7 {\n min-height: calc(var(--spacing) * 7);\n}\n.min-h-8 {\n min-height: calc(var(--spacing) * 8);\n}\n.w-6 {\n width: calc(var(--spacing) * 6);\n}\n.w-8 {\n width: calc(var(--spacing) * 8);\n}\n.w-\\[min\\(360px\\,calc\\(100vw-16px\\)\\)\\] {\n width: min(360px, calc(100vw - 16px));\n}\n.w-full {\n width: 100%;\n}\n.max-w-70 {\n max-width: calc(var(--spacing) * 70);\n}\n.min-w-0 {\n min-width: calc(var(--spacing) * 0);\n}\n.flex-1 {\n flex: 1;\n}\n.shrink-0 {\n flex-shrink: 0;\n}\n.-translate-x-1\\/2 {\n --tw-translate-x: calc(calc(1 / 2 * 100%) * -1);\n translate: var(--tw-translate-x) var(--tw-translate-y);\n}\n.-translate-y-1\\/2 {\n --tw-translate-y: calc(calc(1 / 2 * 100%) * -1);\n translate: var(--tw-translate-x) var(--tw-translate-y);\n}\n.cursor-grab {\n cursor: grab;\n}\n.cursor-pointer {\n cursor: pointer;\n}\n.resize {\n resize: both;\n}\n.resize-none {\n resize: none;\n}\n.appearance-none {\n appearance: none;\n}\n.grid-cols-\\[22px_minmax\\(0\\,1fr\\)\\] {\n grid-template-columns: 22px minmax(0,1fr);\n}\n.grid-cols-\\[82px_minmax\\(0\\,1fr\\)\\] {\n grid-template-columns: 82px minmax(0,1fr);\n}\n.flex-col {\n flex-direction: column;\n}\n.items-center {\n align-items: center;\n}\n.items-start {\n align-items: flex-start;\n}\n.justify-center {\n justify-content: center;\n}\n.gap-0\\.5 {\n gap: calc(var(--spacing) * 0.5);\n}\n.gap-1 {\n gap: calc(var(--spacing) * 1);\n}\n.gap-2 {\n gap: calc(var(--spacing) * 2);\n}\n.overflow-auto {\n overflow: auto;\n}\n.overflow-hidden {\n overflow: hidden;\n}\n.overflow-y-hidden {\n overflow-y: hidden;\n}\n.rounded-lg {\n border-radius: var(--t3-radius);\n}\n.rounded-md {\n border-radius: calc(var(--t3-radius) - 2px);\n}\n.rounded-xl {\n border-radius: calc(var(--t3-radius) + 4px);\n}\n.border {\n border-style: var(--tw-border-style);\n border-width: 1px;\n}\n.border-0 {\n border-style: var(--tw-border-style);\n border-width: 0px;\n}\n.border-t {\n border-top-style: var(--tw-border-style);\n border-top-width: 1px;\n}\n.border-b {\n border-bottom-style: var(--tw-border-style);\n border-bottom-width: 1px;\n}\n.border-border {\n border-color: var(--t3-border);\n}\n.border-input {\n border-color: var(--t3-input);\n}\n.border-primary {\n border-color: var(--t3-primary);\n}\n.border-transparent {\n border-color: transparent;\n}\n.border-b-transparent {\n border-bottom-color: transparent;\n}\n.bg-background {\n background-color: var(--t3-background);\n}\n.bg-muted {\n background-color: var(--t3-muted);\n}\n.bg-muted\\/40 {\n background-color: var(--t3-muted);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-muted) 40%, transparent);\n }\n}\n.bg-popover\\/95 {\n background-color: var(--t3-popover);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-popover) 95%, transparent);\n }\n}\n.bg-popover\\/96 {\n background-color: var(--t3-popover);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-popover) 96%, transparent);\n }\n}\n.bg-primary {\n background-color: var(--t3-primary);\n}\n.bg-primary\\/10 {\n background-color: var(--t3-primary);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-primary) 10%, transparent);\n }\n}\n.bg-transparent {\n background-color: transparent;\n}\n.p-0 {\n padding: calc(var(--spacing) * 0);\n}\n.p-1 {\n padding: calc(var(--spacing) * 1);\n}\n.p-2 {\n padding: calc(var(--spacing) * 2);\n}\n.px-0 {\n padding-inline: calc(var(--spacing) * 0);\n}\n.px-1 {\n padding-inline: calc(var(--spacing) * 1);\n}\n.px-2 {\n padding-inline: calc(var(--spacing) * 2);\n}\n.px-2\\.5 {\n padding-inline: calc(var(--spacing) * 2.5);\n}\n.px-3 {\n padding-inline: calc(var(--spacing) * 3);\n}\n.py-1 {\n padding-block: calc(var(--spacing) * 1);\n}\n.py-1\\.5 {\n padding-block: calc(var(--spacing) * 1.5);\n}\n.py-2 {\n padding-block: calc(var(--spacing) * 2);\n}\n.font-mono {\n font-family: var(--t3-font-mono);\n}\n.font-sans {\n font-family: var(--t3-font-sans);\n}\n.text-lg {\n font-size: var(--text-lg);\n line-height: var(--tw-leading, var(--text-lg--line-height));\n}\n.text-sm {\n font-size: var(--text-sm);\n line-height: var(--tw-leading, var(--text-sm--line-height));\n}\n.text-xs {\n font-size: var(--text-xs);\n line-height: var(--tw-leading, var(--text-xs--line-height));\n}\n.leading-5 {\n --tw-leading: calc(var(--spacing) * 5);\n line-height: calc(var(--spacing) * 5);\n}\n.font-bold {\n --tw-font-weight: var(--font-weight-bold);\n font-weight: var(--font-weight-bold);\n}\n.font-medium {\n --tw-font-weight: var(--font-weight-medium);\n font-weight: var(--font-weight-medium);\n}\n.font-semibold {\n --tw-font-weight: var(--font-weight-semibold);\n font-weight: var(--font-weight-semibold);\n}\n.text-foreground {\n color: var(--t3-foreground);\n}\n.text-muted-foreground {\n color: var(--t3-muted-foreground);\n}\n.text-popover-foreground {\n color: var(--t3-popover-foreground);\n}\n.text-primary {\n color: var(--t3-primary);\n}\n.text-primary-foreground {\n color: var(--t3-primary-foreground);\n}\n.shadow-2xl {\n --tw-shadow: 0 25px 50px -12px var(--tw-shadow-color, rgb(0 0 0 / 0.25));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-lg {\n --tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-md {\n --tw-shadow: 0 4px 6px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 2px 4px -2px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-sm {\n --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-xs {\n --tw-shadow: 0 1px 2px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.05));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.ring-0 {\n --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.blur {\n --tw-blur: blur(8px);\n filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);\n}\n.backdrop-blur-xl {\n --tw-backdrop-blur: blur(var(--blur-xl));\n -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n}\n.outline-none {\n --tw-outline-style: none;\n outline-style: none;\n}\n.select-none {\n -webkit-user-select: none;\n user-select: none;\n}\n.placeholder\\:text-muted-foreground {\n &::placeholder {\n color: var(--t3-muted-foreground);\n }\n}\n.hover\\:bg-accent {\n &:hover {\n @media (hover: hover) {\n background-color: var(--t3-accent);\n }\n }\n}\n.hover\\:bg-primary\\/90 {\n &:hover {\n @media (hover: hover) {\n background-color: var(--t3-primary);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-primary) 90%, transparent);\n }\n }\n }\n}\n.hover\\:text-accent-foreground {\n &:hover {\n @media (hover: hover) {\n color: var(--t3-accent-foreground);\n }\n }\n}\n.focus\\:border-b-primary {\n &:focus {\n border-bottom-color: var(--t3-primary);\n }\n}\n.focus\\:ring-0 {\n &:focus {\n --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n }\n}\n.focus\\:outline-none {\n &:focus {\n --tw-outline-style: none;\n outline-style: none;\n }\n}\n.disabled\\:pointer-events-none {\n &:disabled {\n pointer-events: none;\n }\n}\n.disabled\\:opacity-60 {\n &:disabled {\n opacity: 60%;\n }\n}\n:host {\n --t3-font-sans: "DM Sans Variable", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui,\n sans-serif;\n --t3-font-mono: "SF Mono", "SFMono-Regular", "JetBrains Mono", Consolas, "Liberation Mono", Menlo, monospace;\n --t3-radius: 0.625rem;\n --t3-background: white;\n --t3-foreground: oklch(0.269 0 0);\n --t3-popover: white;\n --t3-popover-foreground: oklch(0.269 0 0);\n --t3-primary: oklch(0.488 0.217 264);\n --t3-primary-foreground: white;\n --t3-muted: rgb(0 0 0 / 4%);\n --t3-muted-foreground: oklch(0.556 0 0);\n --t3-accent: rgb(0 0 0 / 4%);\n --t3-accent-foreground: oklch(0.269 0 0);\n --t3-border: rgb(0 0 0 / 8%);\n --t3-input: rgb(0 0 0 / 10%);\n --t3-ring: oklch(0.488 0.217 264);\n color: var(--t3-foreground);\n font-family: var(--t3-font-sans);\n}\n* {\n box-sizing: border-box;\n border-color: var(--t3-border);\n}\nbutton, input, select, textarea {\n font: inherit;\n}\nbutton:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible {\n outline: 2px solid var(--t3-ring);\n @supports (color: color-mix(in lab, red, red)) {\n outline: 2px solid color-mix(in srgb, var(--t3-ring) 72%, transparent);\n }\n outline-offset: 1px;\n}\n@property --tw-translate-x {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-translate-y {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-translate-z {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-border-style {\n syntax: "*";\n inherits: false;\n initial-value: solid;\n}\n@property --tw-leading {\n syntax: "*";\n inherits: false;\n}\n@property --tw-font-weight {\n syntax: "*";\n inherits: false;\n}\n@property --tw-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-shadow-alpha {\n syntax: "<percentage>";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-inset-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-inset-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-inset-shadow-alpha {\n syntax: "<percentage>";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-ring-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-ring-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-inset-ring-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-inset-ring-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-ring-inset {\n syntax: "*";\n inherits: false;\n}\n@property --tw-ring-offset-width {\n syntax: "<length>";\n inherits: false;\n initial-value: 0px;\n}\n@property --tw-ring-offset-color {\n syntax: "*";\n inherits: false;\n initial-value: #fff;\n}\n@property --tw-ring-offset-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-blur {\n syntax: "*";\n inherits: false;\n}\n@property --tw-brightness {\n syntax: "*";\n inherits: false;\n}\n@property --tw-contrast {\n syntax: "*";\n inherits: false;\n}\n@property --tw-grayscale {\n syntax: "*";\n inherits: false;\n}\n@property --tw-hue-rotate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-invert {\n syntax: "*";\n inherits: false;\n}\n@property --tw-opacity {\n syntax: "*";\n inherits: false;\n}\n@property --tw-saturate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-sepia {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow-alpha {\n syntax: "<percentage>";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-drop-shadow-size {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-blur {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-brightness {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-contrast {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-grayscale {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-hue-rotate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-invert {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-opacity {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-saturate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-sepia {\n syntax: "*";\n inherits: false;\n}\n@layer properties {\n @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {\n *, ::before, ::after, ::backdrop {\n --tw-translate-x: 0;\n --tw-translate-y: 0;\n --tw-translate-z: 0;\n --tw-border-style: solid;\n --tw-leading: initial;\n --tw-font-weight: initial;\n --tw-shadow: 0 0 #0000;\n --tw-shadow-color: initial;\n --tw-shadow-alpha: 100%;\n --tw-inset-shadow: 0 0 #0000;\n --tw-inset-shadow-color: initial;\n --tw-inset-shadow-alpha: 100%;\n --tw-ring-color: initial;\n --tw-ring-shadow: 0 0 #0000;\n --tw-inset-ring-color: initial;\n --tw-inset-ring-shadow: 0 0 #0000;\n --tw-ring-inset: initial;\n --tw-ring-offset-width: 0px;\n --tw-ring-offset-color: #fff;\n --tw-ring-offset-shadow: 0 0 #0000;\n --tw-blur: initial;\n --tw-brightness: initial;\n --tw-contrast: initial;\n --tw-grayscale: initial;\n --tw-hue-rotate: initial;\n --tw-invert: initial;\n --tw-opacity: initial;\n --tw-saturate: initial;\n --tw-sepia: initial;\n --tw-drop-shadow: initial;\n --tw-drop-shadow-color: initial;\n --tw-drop-shadow-alpha: 100%;\n --tw-drop-shadow-size: initial;\n --tw-backdrop-blur: initial;\n --tw-backdrop-brightness: initial;\n --tw-backdrop-contrast: initial;\n --tw-backdrop-grayscale: initial;\n --tw-backdrop-hue-rotate: initial;\n --tw-backdrop-invert: initial;\n --tw-backdrop-opacity: initial;\n --tw-backdrop-saturate: initial;\n --tw-backdrop-sepia: initial;\n }\n }\n}\n'; diff --git a/apps/desktop/src/preview-annotation.css b/apps/desktop/src/preview-annotation.css new file mode 100644 index 00000000000..89676a22d58 --- /dev/null +++ b/apps/desktop/src/preview-annotation.css @@ -0,0 +1,68 @@ +@import "tailwindcss"; + +@theme inline { + --font-sans: var(--t3-font-sans); + --font-mono: var(--t3-font-mono); + --color-background: var(--t3-background); + --color-foreground: var(--t3-foreground); + --color-popover: var(--t3-popover); + --color-popover-foreground: var(--t3-popover-foreground); + --color-primary: var(--t3-primary); + --color-primary-foreground: var(--t3-primary-foreground); + --color-muted: var(--t3-muted); + --color-muted-foreground: var(--t3-muted-foreground); + --color-accent: var(--t3-accent); + --color-accent-foreground: var(--t3-accent-foreground); + --color-border: var(--t3-border); + --color-input: var(--t3-input); + --color-ring: var(--t3-ring); + --radius-sm: calc(var(--t3-radius) - 4px); + --radius-md: calc(var(--t3-radius) - 2px); + --radius-lg: var(--t3-radius); + --radius-xl: calc(var(--t3-radius) + 4px); + --radius-2xl: calc(var(--t3-radius) + 8px); +} + +:host { + --t3-font-sans: + "DM Sans Variable", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, + sans-serif; + --t3-font-mono: + "SF Mono", "SFMono-Regular", "JetBrains Mono", Consolas, "Liberation Mono", Menlo, monospace; + --t3-radius: 0.625rem; + --t3-background: white; + --t3-foreground: oklch(0.269 0 0); + --t3-popover: white; + --t3-popover-foreground: oklch(0.269 0 0); + --t3-primary: oklch(0.488 0.217 264); + --t3-primary-foreground: white; + --t3-muted: rgb(0 0 0 / 4%); + --t3-muted-foreground: oklch(0.556 0 0); + --t3-accent: rgb(0 0 0 / 4%); + --t3-accent-foreground: oklch(0.269 0 0); + --t3-border: rgb(0 0 0 / 8%); + --t3-input: rgb(0 0 0 / 10%); + --t3-ring: oklch(0.488 0.217 264); + color: var(--t3-foreground); + font-family: var(--t3-font-sans); +} + +* { + box-sizing: border-box; + border-color: var(--t3-border); +} + +button, +input, +select, +textarea { + font: inherit; +} + +button:focus-visible, +input:focus-visible, +select:focus-visible, +textarea:focus-visible { + outline: 2px solid color-mix(in srgb, var(--t3-ring) 72%, transparent); + outline-offset: 1px; +} diff --git a/apps/desktop/src/preview-pick-preload.ts b/apps/desktop/src/preview-pick-preload.ts index 7ca1da41b59..2fda1abd720 100644 --- a/apps/desktop/src/preview-pick-preload.ts +++ b/apps/desktop/src/preview-pick-preload.ts @@ -2,6 +2,7 @@ import { ipcRenderer } from "electron"; import { getElementContext } from "react-grab/primitives"; import type { + DesktopPreviewAnnotationTheme, PickedElementPayload, PickedElementStackFrame, PreviewAnnotationPayload, @@ -12,16 +13,18 @@ import type { PreviewAnnotationStyleChange, } from "@t3tools/contracts"; +import { previewAnnotationStyles } from "./preview-annotation-styles.generated.ts"; + const START_PICK_CHANNEL = "preview:start-pick"; const CANCEL_PICK_CHANNEL = "preview:cancel-pick"; const ELEMENT_PICKED_CHANNEL = "preview:element-picked"; const ANNOTATION_CAPTURED_CHANNEL = "preview:annotation-captured"; +const ANNOTATION_THEME_CHANNEL = "preview:annotation-theme"; const HUMAN_INPUT_CHANNEL = "preview:human-input"; const OVERLAY_ATTRIBUTE = "data-t3code-annotation-ui"; const Z_INDEX_OVERLAY = 2147483646; -const ACCENT = "#7c3aed"; -const ACCENT_FILL = "rgba(124,58,237,0.12)"; -const BLUE = "#2563eb"; +const PRIMARY = "var(--t3-primary)"; +const PRIMARY_FILL = "color-mix(in srgb, var(--t3-primary) 10%, transparent)"; const MAX_MARQUEE_ELEMENTS = 20; const CONTENT_LAYER_Z_INDEX = 1; const CHROME_LAYER_Z_INDEX = 10; @@ -38,17 +41,63 @@ interface SelectedElement { interface AnnotationSession { teardown: (notifyMain: boolean) => void; + applyTheme: (theme: DesktopPreviewAnnotationTheme) => void; } let activeSession: AnnotationSession | null = null; let idSequence = 0; +let annotationTheme: DesktopPreviewAnnotationTheme | null = null; + +const applyAnnotationTheme = ( + host: HTMLElement, + theme: DesktopPreviewAnnotationTheme | null, +): void => { + if (!theme) return; + host.style.colorScheme = theme.colorScheme; + const variables = { + "--t3-radius": theme.radius, + "--t3-background": theme.background, + "--t3-foreground": theme.foreground, + "--t3-popover": theme.popover, + "--t3-popover-foreground": theme.popoverForeground, + "--t3-primary": theme.primary, + "--t3-primary-foreground": theme.primaryForeground, + "--t3-muted": theme.muted, + "--t3-muted-foreground": theme.mutedForeground, + "--t3-accent": theme.accent, + "--t3-accent-foreground": theme.accentForeground, + "--t3-border": theme.border, + "--t3-input": theme.input, + "--t3-ring": theme.ring, + "--t3-font-sans": theme.fontSans, + "--t3-font-mono": theme.fontMono, + }; + for (const [name, value] of Object.entries(variables)) { + host.style.setProperty(name, value); + } +}; + +const reportHumanPointerInput = (event: PointerEvent): void => { + if (!event.isTrusted) return; + ipcRenderer.send(HUMAN_INPUT_CHANNEL, { + kind: "pointer", + x: event.clientX, + y: event.clientY, + button: event.button, + }); +}; -const reportHumanInput = (event: Event): void => { - if (event.isTrusted) ipcRenderer.send(HUMAN_INPUT_CHANNEL); +const reportHumanKeyInput = (event: KeyboardEvent): void => { + if (!event.isTrusted) return; + ipcRenderer.send(HUMAN_INPUT_CHANNEL, { + kind: "key", + key: event.key, + code: event.code, + }); }; -window.addEventListener("pointerdown", reportHumanInput, true); -window.addEventListener("keydown", reportHumanInput, true); +window.addEventListener("pointerdown", reportHumanPointerInput, true); +window.addEventListener("keydown", reportHumanKeyInput, true); const nextId = (prefix: string): string => { idSequence += 1; @@ -76,13 +125,15 @@ const normalizeRect = ( const isUsableRect = (rect: PreviewAnnotationRect): boolean => rect.width >= 3 && rect.height >= 3; -function unionRects(rects: ReadonlyArray<PreviewAnnotationRect>): PreviewAnnotationRect | null { +function unionRects( + rects: ReadonlyArray<PreviewAnnotationRect>, + padding = 20, +): PreviewAnnotationRect | null { if (rects.length === 0) return null; const left = Math.min(...rects.map((rect) => rect.x)); const top = Math.min(...rects.map((rect) => rect.y)); const right = Math.max(...rects.map((rect) => rect.x + rect.width)); const bottom = Math.max(...rects.map((rect) => rect.y + rect.height)); - const padding = 20; const x = Math.max(0, left - padding); const y = Math.max(0, top - padding); const maxWidth = Math.max(1, window.innerWidth - x); @@ -151,19 +202,13 @@ function positionBox(node: HTMLElement, rect: PreviewAnnotationRect): void { function createLabel(): HTMLDivElement { const label = document.createElement("div"); label.setAttribute(OVERLAY_ATTRIBUTE, ""); + label.className = + "fixed z-1 max-w-70 overflow-hidden rounded-md bg-primary px-2 py-1 font-sans text-xs font-semibold text-primary-foreground shadow-md"; label.style.cssText = [ "position:fixed", "pointer-events:none", - `background:${ACCENT}`, - "color:white", - "font:600 11px/1.2 ui-sans-serif,system-ui,-apple-system,sans-serif", - "padding:2px 6px", - "border-radius:4px", "white-space:nowrap", - "max-width:280px", - "overflow:hidden", "text-overflow:ellipsis", - "box-shadow:0 1px 3px rgba(0,0,0,.3)", `z-index:${CONTENT_LAYER_Z_INDEX}`, ].join(";"); return label; @@ -222,22 +267,15 @@ function createButton(label: string, title: string): HTMLButtonElement { button.type = "button"; button.textContent = label; button.title = title; - button.style.cssText = [ - "border:0", - "border-radius:7px", - "padding:6px 9px", - "font:600 12px/1 ui-sans-serif,system-ui,-apple-system,sans-serif", - "background:transparent", - "color:#27272a", - "cursor:pointer", - ].join(";"); + button.className = + "inline-flex h-7 cursor-pointer items-center justify-center rounded-md border border-transparent px-2 font-sans text-xs font-medium text-foreground outline-none hover:bg-accent disabled:pointer-events-none disabled:opacity-60"; return button; } function styleControl(input: HTMLInputElement | HTMLSelectElement): void { input.setAttribute("aria-label", input.getAttribute("aria-label") ?? "Style value"); - input.style.cssText += - ";min-width:0;width:100%;height:26px;border:0;border-radius:7px;background:rgba(255,255,255,.78);color:#27272a;padding:3px 8px;box-sizing:border-box;font:500 11px/1 ui-monospace,SFMono-Regular,Menlo,monospace;outline:none;box-shadow:inset 0 0 0 1px rgba(0,0,0,.09);appearance:none"; + input.className = + "h-7 min-w-0 w-full appearance-none rounded-md border border-input bg-background px-2 font-mono text-xs text-foreground shadow-xs outline-none"; } function createUnitControl(input: HTMLInputElement): HTMLElement { @@ -245,8 +283,8 @@ function createUnitControl(input: HTMLInputElement): HTMLElement { wrapper.style.cssText = "position:relative;min-width:0"; const unit = document.createElement("span"); unit.textContent = input.dataset.unit ?? ""; - unit.style.cssText = - "position:absolute;right:8px;top:50%;transform:translateY(-50%);pointer-events:none;color:#a1a1aa;font:500 10px/1 ui-monospace,SFMono-Regular,Menlo,monospace"; + unit.className = + "pointer-events-none absolute top-1/2 right-2 -translate-y-1/2 font-mono text-xs text-muted-foreground"; wrapper.append(input, unit); return wrapper; } @@ -256,8 +294,8 @@ function createField( input: HTMLInputElement | HTMLSelectElement, ): HTMLLabelElement { const label = document.createElement("label"); - label.style.cssText = - "display:grid;grid-template-columns:82px minmax(0,1fr);align-items:center;gap:8px;min-height:28px;font:500 11px/1.2 ui-sans-serif,system-ui;color:#52525b"; + label.className = + "grid min-h-7 grid-cols-[82px_minmax(0,1fr)] items-center gap-2 font-sans text-xs font-medium text-muted-foreground"; const text = document.createElement("span"); text.textContent = labelText; styleControl(input); @@ -270,7 +308,7 @@ function createField( function createStyleSection(): HTMLElement { const section = document.createElement("section"); - section.style.cssText = "display:grid;gap:3px;padding:7px 0;border-top:1px solid rgba(0,0,0,.07)"; + section.className = "grid gap-1 border-t border-border py-2"; return section; } @@ -314,16 +352,27 @@ function strokeBounds( function startAnnotation(): void { activeSession?.teardown(false); let finished = false; + const host = document.createElement("div"); + host.setAttribute(OVERLAY_ATTRIBUTE, ""); + host.style.cssText = `position:fixed;inset:0;z-index:${Z_INDEX_OVERLAY};pointer-events:none`; + applyAnnotationTheme(host, annotationTheme); + const shadowRoot = host.attachShadow({ mode: "closed" }); + const themeStyle = document.createElement("style"); + themeStyle.textContent = previewAnnotationStyles; + shadowRoot.appendChild(themeStyle); + const root = document.createElement("div"); root.setAttribute(OVERLAY_ATTRIBUTE, ""); - root.style.cssText = `position:fixed;inset:0;z-index:${Z_INDEX_OVERLAY};pointer-events:none;font-family:ui-sans-serif,system-ui,-apple-system,sans-serif`; + root.className = "fixed inset-0 font-sans text-foreground"; + root.style.cssText = "pointer-events:none"; const cursorStyle = document.createElement("style"); cursorStyle.setAttribute(OVERLAY_ATTRIBUTE, ""); cursorStyle.textContent = `html[data-t3code-annotation-tool] body, html[data-t3code-annotation-tool] body * { cursor: crosshair !important; } [${OVERLAY_ATTRIBUTE}], [${OVERLAY_ATTRIBUTE}] * { cursor: default !important; } [${OVERLAY_ATTRIBUTE}] input[type=number]::-webkit-inner-spin-button, [${OVERLAY_ATTRIBUTE}] input[type=number]::-webkit-outer-spin-button { appearance:none; margin:0; }`; - root.appendChild(cursorStyle); + document.documentElement.appendChild(cursorStyle); + shadowRoot.appendChild(root); - const hoverOutline = createBox(BLUE, "rgba(37,99,235,.10)"); - const marqueeBox = createBox(ACCENT, ACCENT_FILL); + const hoverOutline = createBox(PRIMARY, PRIMARY_FILL); + const marqueeBox = createBox(PRIMARY, PRIMARY_FILL); root.append(hoverOutline, marqueeBox); const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); @@ -337,57 +386,55 @@ function startAnnotation(): void { const toolbar = document.createElement("div"); toolbar.setAttribute(OVERLAY_ATTRIBUTE, ""); - toolbar.style.cssText = `position:fixed;top:10px;left:50%;transform:translateX(-50%);z-index:${CHROME_LAYER_Z_INDEX};pointer-events:auto;display:flex;gap:2px;padding:3px;border:1px solid rgba(0,0,0,.10);border-radius:9px;background:rgba(255,255,255,.94);box-shadow:0 5px 16px rgba(0,0,0,.12);backdrop-filter:blur(12px)`; + toolbar.className = + "pointer-events-auto fixed top-2.5 left-1/2 flex -translate-x-1/2 gap-0.5 rounded-lg border border-border bg-popover/95 p-1 text-popover-foreground shadow-lg backdrop-blur-xl"; + toolbar.style.zIndex = String(CHROME_LAYER_Z_INDEX); root.appendChild(toolbar); const editor = document.createElement("div"); editor.setAttribute(OVERLAY_ATTRIBUTE, ""); - editor.style.cssText = `position:fixed;right:12px;bottom:12px;z-index:${CHROME_LAYER_Z_INDEX};width:min(306px,calc(100vw - 24px));max-height:calc(100vh - 32px);overflow:hidden;pointer-events:auto;display:none;flex-direction:column;border:1px solid rgba(0,0,0,.09);border-radius:14px;background:rgba(255,255,255,.96);box-shadow:0 14px 36px rgba(0,0,0,.18);color:#18181b;box-sizing:border-box;backdrop-filter:blur(18px)`; + editor.className = + "pointer-events-auto fixed hidden max-h-[calc(100vh-16px)] w-[min(360px,calc(100vw-16px))] flex-col overflow-hidden rounded-xl border border-border bg-popover/96 text-popover-foreground shadow-2xl backdrop-blur-xl"; + editor.style.zIndex = String(CHROME_LAYER_Z_INDEX); root.appendChild(editor); - const editorHeader = document.createElement("div"); - editorHeader.style.cssText = - "display:flex;align-items:center;gap:7px;padding:7px 8px;border-bottom:1px solid rgba(0,0,0,.06);cursor:grab;user-select:none"; - const adjustIcon = document.createElement("div"); - adjustIcon.innerHTML = - '<svg viewBox="0 0 20 20" width="16" height="16" aria-hidden="true"><path d="M3 6h8M15 6h2M3 14h2M9 14h8M11 3v6M7 11v6" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/><circle cx="13" cy="6" r="2" fill="white" stroke="currentColor" stroke-width="1.5"/><circle cx="7" cy="14" r="2" fill="white" stroke="currentColor" stroke-width="1.5"/></svg>'; - adjustIcon.style.cssText = - "display:grid;place-items:center;width:24px;height:24px;border-radius:999px;background:#f4f4f5;color:#52525b;font:700 14px/1 ui-sans-serif"; - editorHeader.appendChild(adjustIcon); - - const status = document.createElement("div"); - status.style.cssText = - "min-width:0;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font:600 11px/1.2 ui-sans-serif,system-ui;color:#71717a"; - editorHeader.appendChild(status); - const dragHandle = document.createElement("div"); - dragHandle.textContent = "⠿"; - dragHandle.title = "Drag annotation editor"; - dragHandle.style.cssText = "color:#a1a1aa;font:700 18px/1 ui-sans-serif"; - editorHeader.appendChild(dragHandle); - editor.appendChild(editorHeader); + const composerRow = document.createElement("div"); + composerRow.className = "flex items-start gap-2 p-2"; + + const adjust = createButton("", "Expand annotation editor"); + adjust.setAttribute("aria-label", "Expand annotation editor"); + adjust.setAttribute("aria-expanded", "false"); + adjust.className += + " h-8 w-8 shrink-0 bg-muted p-0 text-muted-foreground hover:bg-accent hover:text-accent-foreground"; + adjust.innerHTML = + '<svg viewBox="0 0 20 20" width="15" height="15" aria-hidden="true"><path d="M4 5h12M4 10h12M4 15h12M7 3v4M13 8v4M9 13v4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>'; + composerRow.appendChild(adjust); const comment = document.createElement("textarea"); comment.placeholder = "Describe the change…"; - comment.rows = 2; - comment.style.cssText = - "width:100%;resize:none;min-height:48px;border:0;padding:10px 12px;font:500 12px/1.35 ui-sans-serif,system-ui;color:#18181b;box-sizing:border-box;outline:none;background:transparent"; - editor.appendChild(comment); + comment.rows = 1; + comment.className = + "min-h-8 max-h-24 min-w-0 flex-1 resize-none overflow-y-hidden border-0 border-b border-b-transparent bg-transparent px-0 py-1.5 font-sans text-sm leading-5 text-foreground outline-none ring-0 placeholder:text-muted-foreground focus:border-b-primary focus:outline-none focus:ring-0"; + composerRow.appendChild(comment); - const stylePanel = document.createElement("div"); - stylePanel.style.cssText = - "display:none;max-height:min(380px,calc(100vh - 180px));overflow:auto;padding:0 10px;background:rgba(250,250,250,.62);border-top:1px solid rgba(0,0,0,.06)"; - editor.appendChild(stylePanel); + const dragHandle = document.createElement("button"); + dragHandle.type = "button"; + dragHandle.textContent = "⠿"; + dragHandle.title = "Drag annotation editor"; + dragHandle.className = + "hidden h-8 w-6 shrink-0 cursor-grab select-none border-0 bg-transparent p-0 font-sans text-lg font-bold leading-5 text-muted-foreground"; + composerRow.appendChild(dragHandle); - const footer = document.createElement("div"); - footer.style.cssText = - "display:flex;align-items:center;justify-content:space-between;gap:8px;padding:7px 8px;border-top:1px solid rgba(0,0,0,.06);background:rgba(255,255,255,.92)"; - const adjust = createButton("Adjust", "Show style controls for selected elements"); - adjust.style.cssText += - ";display:none;padding:5px 7px;color:#52525b;background:#f4f4f5;font-size:11px"; const submit = createButton("Attach", "Attach annotation and screenshot"); - submit.style.cssText += `;background:${ACCENT};color:white;padding:6px 9px;font-size:11px`; - footer.append(adjust, submit); - editor.appendChild(footer); + submit.className += + " h-8 shrink-0 border-primary bg-primary px-3 text-primary-foreground shadow-sm hover:bg-primary/90"; + composerRow.appendChild(submit); + editor.appendChild(composerRow); + + const stylePanel = document.createElement("div"); + stylePanel.className = + "hidden max-h-[min(176px,calc(100vh-180px))] overflow-auto border-t border-border bg-muted/40 px-3"; + editor.appendChild(stylePanel); const selected = new Map<Element, SelectedElement>(); const regions: PreviewAnnotationRegionTarget[] = []; @@ -398,28 +445,30 @@ function startAnnotation(): void { let dragStart: PreviewAnnotationPoint | null = null; let activeStroke: { target: PreviewAnnotationStrokeTarget; path: SVGPathElement } | null = null; let pendingCapture = false; - let stylesExpanded = false; + let editorExpanded = false; let editorWasShown = false; let editorPosition: { left: number; top: number } | null = null; let editorDrag: { pointerId: number; offsetX: number; offsetY: number } | null = null; + let editorLayoutFrame: number | null = null; + + const resizeComment = (): void => { + const maxHeight = 96; + comment.style.height = "auto"; + const nextHeight = Math.min(comment.scrollHeight, maxHeight); + comment.style.height = `${nextHeight}px`; + comment.style.overflowY = comment.scrollHeight > maxHeight ? "auto" : "hidden"; + queueEditorLayout(); + }; + comment.addEventListener("input", resizeComment); const updateStatus = (): void => { - const parts = [ - selected.size > 0 ? `${selected.size} element${selected.size === 1 ? "" : "s"}` : "", - regions.length > 0 ? `${regions.length} region${regions.length === 1 ? "" : "s"}` : "", - strokes.length > 0 ? `${strokes.length} drawing${strokes.length === 1 ? "" : "s"}` : "", - ].filter(Boolean); - const hasTargets = parts.length > 0; - status.textContent = parts.join(" · "); + const hasTargets = selected.size > 0 || regions.length > 0 || strokes.length > 0; editor.style.display = hasTargets ? "flex" : "none"; submit.disabled = !hasTargets; submit.style.opacity = hasTargets ? "1" : "0.45"; - adjust.style.display = selected.size > 0 ? "block" : "none"; - if (selected.size === 0 && stylesExpanded) { - stylesExpanded = false; - stylePanel.style.display = "none"; - adjust.textContent = "Adjust"; - } + adjust.disabled = !hasTargets; + stylePanel.style.display = editorExpanded && selected.size > 0 ? "grid" : "none"; + queueEditorLayout(); if (hasTargets && !editorWasShown) { editorWasShown = true; window.setTimeout(() => comment.focus({ preventScroll: true }), 0); @@ -429,8 +478,9 @@ function startAnnotation(): void { const refreshToolButtons = (): void => { for (const [candidate, button] of toolButtons) { const active = candidate === tool; - button.style.background = active ? ACCENT_FILL : "transparent"; - button.style.color = active ? ACCENT : "#27272a"; + button.classList.toggle("bg-primary/10", active); + button.classList.toggle("text-primary", active); + button.classList.toggle("text-foreground", !active); } if (tool !== "select") hoverOutline.style.display = "none"; if (tool !== "marquee") marqueeBox.style.display = "none"; @@ -458,7 +508,7 @@ function startAnnotation(): void { const target: SelectedElement = { id: nextId("element"), element, - outline: createBox(ACCENT, ACCENT_FILL), + outline: createBox(PRIMARY, PRIMARY_FILL), label: createLabel(), baselineStyles: new Map(), }; @@ -466,7 +516,10 @@ function startAnnotation(): void { root.append(target.outline, target.label); updateSelectedVisual(target); updateStatus(); - if (stylesExpanded) syncStyleControls(); + if (editorExpanded) { + stylePanel.style.display = "grid"; + syncStyleControls(); + } }; const toggleSelected = (element: Element, additive: boolean): void => { @@ -552,13 +605,13 @@ function startAnnotation(): void { section: HTMLElement, ): { row: HTMLLabelElement; color: HTMLInputElement; text: HTMLInputElement } => { const row = document.createElement("label"); - row.style.cssText = - "display:grid;grid-template-columns:82px minmax(0,1fr);align-items:center;gap:8px;min-height:28px;font:500 11px/1.2 ui-sans-serif,system-ui;color:#52525b"; + row.className = + "grid min-h-7 grid-cols-[82px_minmax(0,1fr)] items-center gap-2 font-sans text-xs font-medium text-muted-foreground"; const label = document.createElement("span"); label.textContent = labelText; const control = document.createElement("div"); - control.style.cssText = - "display:grid;grid-template-columns:22px minmax(0,1fr);align-items:center;gap:5px;height:26px;padding:2px 5px;border-radius:7px;background:rgba(255,255,255,.78);box-shadow:inset 0 0 0 1px rgba(0,0,0,.09)"; + control.className = + "grid h-7 grid-cols-[22px_minmax(0,1fr)] items-center gap-1 rounded-md border border-input bg-background px-1 shadow-xs"; const color = document.createElement("input"); color.type = "color"; color.setAttribute("aria-label", labelText); @@ -567,8 +620,8 @@ function startAnnotation(): void { const text = document.createElement("input"); text.type = "text"; text.setAttribute("aria-label", `${labelText} value`); - text.style.cssText = - "min-width:0;width:100%;border:0;background:transparent;color:#52525b;font:500 10px/1 ui-monospace,SFMono-Regular,Menlo,monospace;outline:none"; + text.className = + "min-w-0 w-full border-0 bg-transparent font-mono text-xs text-foreground outline-none"; color.addEventListener("input", () => { text.value = color.value; setStyleForSelected(property, color.value); @@ -594,7 +647,7 @@ function startAnnotation(): void { opacity.max = "1"; opacity.step = "0.05"; opacity.value = "1"; - opacity.style.accentColor = ACCENT; + opacity.style.accentColor = PRIMARY; opacity.addEventListener("input", () => setStyleForSelected("opacity", opacity.value)); colorsSection.appendChild(createField("Opacity", opacity)); @@ -623,8 +676,7 @@ function startAnnotation(): void { dimensions.style.cssText = "display:grid;grid-template-columns:82px minmax(0,1fr);gap:8px;align-items:center"; const dimensionLabel = document.createElement("div"); - dimensionLabel.style.cssText = - "display:grid;gap:9px;font:500 11px/1.2 ui-sans-serif,system-ui;color:#52525b"; + dimensionLabel.className = "grid gap-2 font-sans text-xs font-medium text-muted-foreground"; dimensionLabel.innerHTML = "<span>Width</span><span>Height</span>"; const dimensionControls = document.createElement("div"); dimensionControls.style.cssText = "position:relative;display:grid;gap:3px;padding-left:22px"; @@ -635,7 +687,8 @@ function startAnnotation(): void { const aspectLock = createButton("", "Lock aspect ratio"); aspectLock.setAttribute("aria-pressed", "true"); aspectLock.style.cssText += - ";position:absolute;left:0;top:50%;transform:translateY(-50%);width:18px;height:38px;padding:0;border-radius:6px;background:#ede9fe;color:#6d28d9"; + ";position:absolute;left:0;top:50%;transform:translateY(-50%);width:18px;height:38px;padding:0"; + aspectLock.className += " bg-primary/10 text-primary"; dimensionControls.append( createUnitControl(widthInput), createUnitControl(heightInput), @@ -651,8 +704,10 @@ function startAnnotation(): void { ? '<svg viewBox="0 0 20 20" width="14" height="14" aria-hidden="true"><path d="M8 6.5 9.5 5A3.5 3.5 0 0 1 14.5 10l-1.5 1.5M12 13.5 10.5 15A3.5 3.5 0 0 1 5.5 10L7 8.5M7.5 12.5l5-5" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>' : '<svg viewBox="0 0 20 20" width="14" height="14" aria-hidden="true"><path d="m6 6 8 8M8 6.5 9.5 5A3.5 3.5 0 0 1 14 9M12 13.5 10.5 15A3.5 3.5 0 0 1 6 11" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>'; aspectLock.setAttribute("aria-pressed", String(aspectLocked)); - aspectLock.style.background = aspectLocked ? "#ede9fe" : "#f4f4f5"; - aspectLock.style.color = aspectLocked ? "#6d28d9" : "#71717a"; + aspectLock.classList.toggle("bg-primary/10", aspectLocked); + aspectLock.classList.toggle("text-primary", aspectLocked); + aspectLock.classList.toggle("bg-muted", !aspectLocked); + aspectLock.classList.toggle("text-muted-foreground", !aspectLocked); }; aspectLock.addEventListener("click", () => { aspectLocked = !aspectLocked; @@ -733,7 +788,7 @@ function startAnnotation(): void { ]; for (const [candidate, label, title] of tools) { const button = createButton(label, title); - button.style.cssText += ";padding:5px 7px;font-size:11px;border-radius:6px"; + button.className += " h-8 px-2.5 text-sm"; button.addEventListener("click", () => { tool = candidate; refreshToolButtons(); @@ -742,14 +797,6 @@ function startAnnotation(): void { toolbar.appendChild(button); } - adjust.addEventListener("click", () => { - if (selected.size === 0) return; - stylesExpanded = !stylesExpanded; - stylePanel.style.display = stylesExpanded ? "grid" : "none"; - adjust.textContent = stylesExpanded ? "Hide styles" : "Adjust"; - if (stylesExpanded) syncStyleControls(); - }); - const clampEditorPosition = (left: number, top: number): { left: number; top: number } => { const margin = 8; const rect = editor.getBoundingClientRect(); @@ -766,23 +813,98 @@ function startAnnotation(): void { }; const applyEditorPosition = (position: { left: number; top: number }): void => { - editorPosition = clampEditorPosition(position.left, position.top); - editor.style.left = `${editorPosition.left}px`; - editor.style.top = `${editorPosition.top}px`; + const clamped = clampEditorPosition(position.left, position.top); + editor.style.left = `${clamped.left}px`; + editor.style.top = `${clamped.top}px`; editor.style.right = "auto"; editor.style.bottom = "auto"; + if (editorExpanded) editorPosition = clamped; + }; + + const getAnnotationBounds = (): PreviewAnnotationRect | null => + unionRects( + [ + ...Array.from(selected.values(), (target) => + rectFromDomRect(target.element.getBoundingClientRect()), + ), + ...regions.map((region) => region.rect), + ...strokes.map((stroke) => stroke.bounds), + ], + 0, + ); + + const positionCompactEditor = (): void => { + const bounds = getAnnotationBounds(); + if (!bounds) return; + const editorRect = editor.getBoundingClientRect(); + const gap = 8; + const candidates = [ + { left: bounds.x + bounds.width + gap, top: bounds.y }, + { left: bounds.x - editorRect.width - gap, top: bounds.y }, + { + left: bounds.x + bounds.width - editorRect.width, + top: bounds.y + bounds.height + gap, + }, + { + left: bounds.x + bounds.width - editorRect.width, + top: bounds.y - editorRect.height - gap, + }, + ]; + const overflow = (position: { left: number; top: number }): number => + Math.max(0, -position.left) + + Math.max(0, -position.top) + + Math.max(0, position.left + editorRect.width - window.innerWidth) + + Math.max(0, position.top + editorRect.height - window.innerHeight); + const best = candidates.reduce((current, candidate) => + overflow(candidate) < overflow(current) ? candidate : current, + ); + applyEditorPosition(best); }; + function queueEditorLayout(): void { + if (editorLayoutFrame !== null) window.cancelAnimationFrame(editorLayoutFrame); + editorLayoutFrame = window.requestAnimationFrame(() => { + editorLayoutFrame = null; + if (editor.style.display === "none") return; + if (editorExpanded && editorPosition) applyEditorPosition(editorPosition); + else positionCompactEditor(); + }); + } + + adjust.addEventListener("click", () => { + if (selected.size === 0) return; + if (!editorExpanded) { + const rect = editor.getBoundingClientRect(); + editorExpanded = true; + editorPosition = { left: rect.left, top: rect.top }; + stylePanel.style.display = selected.size > 0 ? "grid" : "none"; + dragHandle.style.display = "block"; + adjust.setAttribute("aria-expanded", "true"); + adjust.title = "Collapse annotation editor"; + adjust.setAttribute("aria-label", "Collapse annotation editor"); + if (selected.size > 0) syncStyleControls(); + } else { + editorExpanded = false; + editorPosition = null; + stylePanel.style.display = "none"; + dragHandle.style.display = "none"; + adjust.setAttribute("aria-expanded", "false"); + adjust.title = "Expand annotation editor"; + adjust.setAttribute("aria-label", "Expand annotation editor"); + } + queueEditorLayout(); + }); + const onEditorPointerDown = (event: PointerEvent): void => { - if (event.button !== 0) return; + if (event.button !== 0 || !editorExpanded) return; const rect = editor.getBoundingClientRect(); editorDrag = { pointerId: event.pointerId, offsetX: event.clientX - rect.left, offsetY: event.clientY - rect.top, }; - editorHeader.setPointerCapture(event.pointerId); - editorHeader.style.cursor = "grabbing"; + dragHandle.setPointerCapture(event.pointerId); + dragHandle.style.cursor = "grabbing"; event.preventDefault(); event.stopPropagation(); }; @@ -800,20 +922,20 @@ function startAnnotation(): void { const onEditorPointerUp = (event: PointerEvent): void => { if (!editorDrag || editorDrag.pointerId !== event.pointerId) return; editorDrag = null; - editorHeader.style.cursor = "grab"; - if (editorHeader.hasPointerCapture(event.pointerId)) - editorHeader.releasePointerCapture(event.pointerId); + dragHandle.style.cursor = "grab"; + if (dragHandle.hasPointerCapture(event.pointerId)) + dragHandle.releasePointerCapture(event.pointerId); event.preventDefault(); event.stopPropagation(); }; - editorHeader.addEventListener("pointerdown", onEditorPointerDown); - editorHeader.addEventListener("pointermove", onEditorPointerMove); - editorHeader.addEventListener("pointerup", onEditorPointerUp); - editorHeader.addEventListener("pointercancel", onEditorPointerUp); + dragHandle.addEventListener("pointerdown", onEditorPointerDown); + dragHandle.addEventListener("pointermove", onEditorPointerMove); + dragHandle.addEventListener("pointerup", onEditorPointerUp); + dragHandle.addEventListener("pointercancel", onEditorPointerUp); const repaint = (): void => { for (const target of selected.values()) updateSelectedVisual(target); - if (editorPosition) applyEditorPosition(editorPosition); + queueEditorLayout(); }; const removeTargetAtPoint = (x: number, y: number): boolean => { @@ -941,7 +1063,7 @@ function startAnnotation(): void { if (tool === "draw") { const stroke: PreviewAnnotationStrokeTarget = { id: nextId("stroke"), - color: ACCENT, + color: annotationTheme?.primary ?? "#2563eb", width: 4, points: [dragStart], bounds: { x: dragStart.x, y: dragStart.y, width: 1, height: 1 }, @@ -971,7 +1093,10 @@ function startAnnotation(): void { if (found === 0) { const region: PreviewAnnotationRegionTarget = { id: nextId("region"), rect }; regions.push(region); - const regionBox = createBox(ACCENT, "rgba(124,58,237,.06)"); + const regionBox = createBox( + PRIMARY, + "color-mix(in srgb, var(--t3-primary) 6%, transparent)", + ); regionBox.setAttribute("data-region-id", region.id); positionBox(regionBox, rect); root.appendChild(regionBox); @@ -1024,14 +1149,16 @@ function startAnnotation(): void { window.removeEventListener("keydown", onKeyDown, true); window.removeEventListener("scroll", repaint, true); window.removeEventListener("resize", repaint); - editorHeader.removeEventListener("pointerdown", onEditorPointerDown); - editorHeader.removeEventListener("pointermove", onEditorPointerMove); - editorHeader.removeEventListener("pointerup", onEditorPointerUp); - editorHeader.removeEventListener("pointercancel", onEditorPointerUp); + dragHandle.removeEventListener("pointerdown", onEditorPointerDown); + dragHandle.removeEventListener("pointermove", onEditorPointerMove); + dragHandle.removeEventListener("pointerup", onEditorPointerUp); + dragHandle.removeEventListener("pointercancel", onEditorPointerUp); + if (editorLayoutFrame !== null) window.cancelAnimationFrame(editorLayoutFrame); ipcRenderer.off(CANCEL_PICK_CHANNEL, onCancel); ipcRenderer.off(ANNOTATION_CAPTURED_CHANNEL, onCaptured); document.documentElement.removeAttribute("data-t3code-annotation-tool"); - root.remove(); + cursorStyle.remove(); + host.remove(); activeSession = null; if (notifyMain) ipcRenderer.send(ELEMENT_PICKED_CHANNEL, null); }; @@ -1115,11 +1242,21 @@ function startAnnotation(): void { window.addEventListener("resize", repaint, { passive: true }); ipcRenderer.on(CANCEL_PICK_CHANNEL, onCancel); ipcRenderer.on(ANNOTATION_CAPTURED_CHANNEL, onCaptured); - document.documentElement.appendChild(root); + document.documentElement.appendChild(host); refreshToolButtons(); updateStatus(); - activeSession = { teardown }; + activeSession = { + teardown, + applyTheme: (theme) => applyAnnotationTheme(host, theme), + }; } -ipcRenderer.on(START_PICK_CHANNEL, () => startAnnotation()); +ipcRenderer.on(START_PICK_CHANNEL, (_event, theme: DesktopPreviewAnnotationTheme | undefined) => { + if (theme) annotationTheme = theme; + startAnnotation(); +}); +ipcRenderer.on(ANNOTATION_THEME_CHANNEL, (_event, theme: DesktopPreviewAnnotationTheme) => { + annotationTheme = theme; + activeSession?.applyTheme(theme); +}); ipcRenderer.on(CANCEL_PICK_CHANNEL, () => activeSession?.teardown(false)); diff --git a/apps/desktop/src/preview-view-manager.test.ts b/apps/desktop/src/preview-view-manager.test.ts index c704ae6630c..a7aeabc0e0d 100644 --- a/apps/desktop/src/preview-view-manager.test.ts +++ b/apps/desktop/src/preview-view-manager.test.ts @@ -3,16 +3,26 @@ import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; const fromId = vi.fn(() => null); const mkdir = vi.fn(async () => undefined); const writeFile = vi.fn(async () => undefined); +const showItemInFolder = vi.fn(); +const writeImage = vi.fn(); +const createFromPath = vi.fn(() => ({ isEmpty: () => false })); +const webviewSend = vi.fn(); vi.mock("node:fs/promises", () => ({ mkdir, writeFile })); vi.mock("electron", () => ({ - app: { - getPath: vi.fn(() => "/tmp/t3-code-test"), + clipboard: { + writeImage, + }, + nativeImage: { + createFromPath, }, session: { fromPartition: vi.fn(), }, + shell: { + showItemInFolder, + }, webContents: { fromId, }, @@ -23,6 +33,10 @@ describe("PreviewViewManager automation status", () => { fromId.mockClear(); mkdir.mockClear(); writeFile.mockClear(); + showItemInFolder.mockClear(); + writeImage.mockClear(); + createFromPath.mockClear(); + webviewSend.mockClear(); }); it("reports an unregistered webview as temporarily unavailable", async () => { @@ -59,7 +73,7 @@ describe("PreviewViewManager automation status", () => { id: 42, isDestroyed: () => false, getType: () => "webview", - getURL: () => "https://example.com/", + getURL: () => "https://example.com:8443/path?query=value", getTitle: () => "Example", isLoading: () => false, getZoomFactor: () => 1, @@ -69,6 +83,7 @@ describe("PreviewViewManager automation status", () => { }), off: vi.fn(), ipc: { on: vi.fn(), off: vi.fn() }, + send: webviewSend, navigationHistory: { canGoBack: () => false, canGoForward: () => false }, setWindowOpenHandler: vi.fn(), debugger: { @@ -82,13 +97,22 @@ describe("PreviewViewManager automation status", () => { } as never); const { PreviewViewManager } = await import("./preview-view-manager.ts"); const manager = new PreviewViewManager(); + manager.configureArtifactDirectory("/tmp/t3/dev/browser-artifacts"); manager.createTab("tab_1"); manager.registerWebview("tab_1", 42); + expect(webviewSend).toHaveBeenCalledWith( + "preview:annotation-theme", + expect.objectContaining({ + colorScheme: "light", + primary: "oklch(0.488 0.217 264)", + }), + ); + const artifact = await manager.captureScreenshot("tab_1"); expect(capturePage).toHaveBeenCalledOnce(); - expect(mkdir).toHaveBeenCalledWith("/tmp/t3-code-test/browser-artifacts", { + expect(mkdir).toHaveBeenCalledWith("/tmp/t3/dev/browser-artifacts", { recursive: true, }); expect(writeFile).toHaveBeenCalledWith(artifact.path, png); @@ -97,6 +121,163 @@ describe("PreviewViewManager automation status", () => { mimeType: "image/png", sizeBytes: png.byteLength, }); - expect(artifact.path).toMatch(/\/browser-artifacts\/browser-screenshot-[^.]+\.png$/); + expect(artifact.path).toMatch( + /\/browser-artifacts\/browser-screenshot-example-com-[^.]+\.png$/, + ); + }); + + it("reveals only files inside the configured browser artifact directory", async () => { + const { PreviewViewManager } = await import("./preview-view-manager.ts"); + const manager = new PreviewViewManager(); + manager.configureArtifactDirectory("/tmp/t3/dev/browser-artifacts"); + + manager.revealArtifact("/tmp/t3/dev/browser-artifacts/browser-screenshot-test.png"); + + expect(showItemInFolder).toHaveBeenCalledWith( + "/tmp/t3/dev/browser-artifacts/browser-screenshot-test.png", + ); + expect(() => manager.revealArtifact("/tmp/t3/dev/settings.json")).toThrow( + "outside the configured artifact directory", + ); + }); + + it("copies screenshot artifacts to the system clipboard", async () => { + const { PreviewViewManager } = await import("./preview-view-manager.ts"); + const manager = new PreviewViewManager(); + manager.configureArtifactDirectory("/tmp/t3/dev/browser-artifacts"); + const artifactPath = "/tmp/t3/dev/browser-artifacts/browser-screenshot-test.png"; + + manager.copyArtifactToClipboard(artifactPath); + + expect(createFromPath).toHaveBeenCalledWith(artifactPath); + expect(writeImage).toHaveBeenCalledOnce(); + expect(() => manager.copyArtifactToClipboard("/tmp/t3/dev/settings.json")).toThrow( + "outside the configured artifact directory", + ); + }); + + it("emits the resolved pointer target before dispatching an automation click", async () => { + let humanInput: ((_event: unknown, signal: unknown) => void) | undefined; + const activity: string[] = []; + const sendCommand = vi.fn(async (method: string, params?: Record<string, unknown>) => { + if (method === "Runtime.evaluate") { + return { + result: { + value: { width: 800, height: 600 }, + }, + }; + } + if (method === "Input.dispatchMouseEvent" && params?.type === "mousePressed") { + activity.push("mousePressed"); + humanInput?.({}, { kind: "pointer", x: params.x, y: params.y, button: 0 }); + } + return undefined; + }); + fromId.mockReturnValue({ + id: 42, + isDestroyed: () => false, + getType: () => "webview", + getURL: () => "https://example.com", + getTitle: () => "Example", + isLoading: () => false, + isDevToolsOpened: () => false, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + on: vi.fn(), + off: vi.fn(), + ipc: { + on: vi.fn((channel: string, listener: typeof humanInput) => { + if (channel === "preview:human-input") humanInput = listener; + }), + off: vi.fn(), + }, + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand, + on: vi.fn(), + off: vi.fn(), + }, + } as never); + const { PreviewViewManager } = await import("./preview-view-manager.ts"); + const manager = new PreviewViewManager(); + manager.onPointerEvent((event) => activity.push(event.phase)); + manager.createTab("tab_1"); + manager.registerWebview("tab_1", 42); + + await manager.automationClick("tab_1", { x: 120, y: 80 }); + + expect(activity).toEqual(["move", "click", "mousePressed"]); + expect(sendCommand).toHaveBeenCalledWith("Input.dispatchMouseEvent", { + type: "mousePressed", + x: 120, + y: 80, + button: "left", + clickCount: 1, + }); + expect(sendCommand).toHaveBeenCalledWith("Input.dispatchMouseEvent", { + type: "mouseReleased", + x: 120, + y: 80, + button: "left", + clickCount: 1, + }); + }); + + it("still interrupts agent control for a different human pointer event", async () => { + let humanInput: ((_event: unknown, signal: unknown) => void) | undefined; + const sendCommand = vi.fn(async (method: string) => { + if (method === "Runtime.evaluate") { + return { + result: { + value: { width: 800, height: 600 }, + }, + }; + } + if (method === "Input.dispatchMouseEvent") { + humanInput?.({}, { kind: "pointer", x: 400, y: 300, button: 0 }); + } + return undefined; + }); + fromId.mockReturnValue({ + id: 42, + isDestroyed: () => false, + getType: () => "webview", + getURL: () => "https://example.com", + getTitle: () => "Example", + isLoading: () => false, + isDevToolsOpened: () => false, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + on: vi.fn(), + off: vi.fn(), + ipc: { + on: vi.fn((channel: string, listener: typeof humanInput) => { + if (channel === "preview:human-input") humanInput = listener; + }), + off: vi.fn(), + }, + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand, + on: vi.fn(), + off: vi.fn(), + }, + } as never); + const { PreviewViewManager } = await import("./preview-view-manager.ts"); + const manager = new PreviewViewManager(); + manager.createTab("tab_1"); + manager.registerWebview("tab_1", 42); + + await expect(manager.automationClick("tab_1", { x: 120, y: 80 })).rejects.toMatchObject({ + name: "PreviewAutomationControlInterruptedError", + }); }); }); diff --git a/apps/desktop/src/preview-view-manager.ts b/apps/desktop/src/preview-view-manager.ts index d740334207c..5406ae731b0 100644 --- a/apps/desktop/src/preview-view-manager.ts +++ b/apps/desktop/src/preview-view-manager.ts @@ -8,6 +8,8 @@ * here). Single layer-scoped browser session partition. */ import type { + DesktopPreviewAnnotationTheme, + DesktopPreviewPointerEvent, PreviewAnnotationPayload, PreviewAnnotationRect, DesktopPreviewRecordingArtifact, @@ -26,9 +28,17 @@ import type { PreviewAutomationWaitForInput, } from "@t3tools/contracts"; import { normalizePreviewUrl } from "@t3tools/shared/preview"; -import { app, type BrowserWindow, type Session, session, webContents } from "electron"; +import { + type BrowserWindow, + type Session, + clipboard, + nativeImage, + session, + shell, + webContents, +} from "electron"; import { mkdir, writeFile } from "node:fs/promises"; -import { join } from "node:path"; +import { isAbsolute, join, relative, resolve, sep } from "node:path"; import { createHash } from "node:crypto"; import { setTimeout as sleep } from "node:timers/promises"; @@ -40,6 +50,7 @@ const START_PICK_CHANNEL = "preview:start-pick"; const CANCEL_PICK_CHANNEL = "preview:cancel-pick"; const ELEMENT_PICKED_CHANNEL = "preview:element-picked"; const ANNOTATION_CAPTURED_CHANNEL = "preview:annotation-captured"; +const ANNOTATION_THEME_CHANNEL = "preview:annotation-theme"; const HUMAN_INPUT_CHANNEL = "preview:human-input"; // Re-export the guest webview security posture from its dedicated module so @@ -83,6 +94,43 @@ const MAX_VISIBLE_TEXT_LENGTH = 20_000; const MAX_INTERACTIVE_ELEMENTS = 200; const MAX_SCREENSHOT_WIDTH = 1280; const DIAGNOSTIC_BUFFER_LIMIT = 200; +const MAX_ARTIFACT_SITE_SLUG_LENGTH = 80; +const AGENT_CURSOR_MOVE_MS = 160; +const AGENT_CURSOR_CLICK_LEAD_MS = 40; +const DEFAULT_ANNOTATION_THEME: DesktopPreviewAnnotationTheme = { + colorScheme: "light", + radius: "0.625rem", + background: "white", + foreground: "oklch(0.269 0 0)", + popover: "white", + popoverForeground: "oklch(0.269 0 0)", + primary: "oklch(0.488 0.217 264)", + primaryForeground: "white", + muted: "rgb(0 0 0 / 4%)", + mutedForeground: "oklch(0.556 0 0)", + accent: "rgb(0 0 0 / 4%)", + accentForeground: "oklch(0.269 0 0)", + border: "rgb(0 0 0 / 8%)", + input: "rgb(0 0 0 / 10%)", + ring: "oklch(0.488 0.217 264)", + fontSans: "system-ui, sans-serif", + fontMono: "ui-monospace, monospace", +}; + +const artifactSiteSlug = (rawUrl: string): string => { + try { + const url = new URL(rawUrl); + const slug = url.hostname + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, MAX_ARTIFACT_SITE_SLUG_LENGTH) + .replace(/-+$/g, ""); + return slug || "site"; + } catch { + return "site"; + } +}; interface CdpEvaluationResult { readonly result?: { @@ -182,10 +230,14 @@ const nextZoomLevel = (current: number, direction: "in" | "out"): number => { type Listener = (tabId: string, state: PreviewTabState) => void; type RecordingFrameListener = (frame: DesktopPreviewRecordingFrame) => void; +type PreviewInputSignal = + | { readonly kind: "pointer"; readonly x: number; readonly y: number; readonly button: number } + | { readonly kind: "key"; readonly key: string; readonly code: string }; + interface ManagedListeners { navigate: () => void; failed: (event: Event, code: number, description: string) => void; - humanInput: () => void; + humanInput: (_event: unknown, signal?: unknown) => void; } interface PickSession { @@ -205,6 +257,13 @@ interface BrowserDiagnostics { readonly requests: Map<string, { url: string; method: string }>; } +type PointerEventListener = (event: DesktopPreviewPointerEvent) => void; + +interface ExpectedAgentInput { + readonly signal: PreviewInputSignal; + readonly expiresAt: number; +} + const APP_FORWARDED_SHORTCUTS: ReadonlyArray<{ key: string; meta: boolean; @@ -221,23 +280,116 @@ const APP_FORWARDED_SHORTCUTS: ReadonlyArray<{ { key: "w", meta: true, shift: false, control: false }, ]); +const isPreviewInputSignal = (value: unknown): value is PreviewInputSignal => { + if (typeof value !== "object" || value === null || !("kind" in value)) return false; + if (value.kind === "pointer") { + return ( + "x" in value && + typeof value.x === "number" && + "y" in value && + typeof value.y === "number" && + "button" in value && + typeof value.button === "number" + ); + } + return ( + value.kind === "key" && + "key" in value && + typeof value.key === "string" && + "code" in value && + typeof value.code === "string" + ); +}; + +const inputSignalsMatch = (left: PreviewInputSignal, right: PreviewInputSignal): boolean => { + if (left.kind !== right.kind) return false; + if (left.kind === "pointer" && right.kind === "pointer") { + return ( + Math.abs(left.x - right.x) <= 1 && + Math.abs(left.y - right.y) <= 1 && + left.button === right.button + ); + } + return ( + left.kind === "key" && + right.kind === "key" && + left.key === right.key && + left.code === right.code + ); +}; + export class PreviewViewManager { + private annotationTheme = DEFAULT_ANNOTATION_THEME; + private artifactDirectory: string | null = null; private mainWindow: BrowserWindow | null = null; private readonly tabs = new Map<string, PreviewTabState>(); private readonly attached = new Map<number, ManagedListeners>(); private readonly browserSessions = new Map<string, Session>(); private readonly listeners = new Set<Listener>(); + private readonly pointerEventListeners = new Set<PointerEventListener>(); private readonly recordingFrameListeners = new Set<RecordingFrameListener>(); /** In-flight preview annotation sessions, keyed by tabId. */ private readonly pickSessions = new Map<string, PickSession>(); /** One long-lived CDP attachment and serialized command queue per guest. */ private readonly controlSessions = new Map<number, BrowserControlSession>(); private readonly diagnostics = new Map<number, BrowserDiagnostics>(); + private readonly expectedAgentInputs = new Map<string, ExpectedAgentInput[]>(); private readonly controlEpoch = new Map<string, number>(); private readonly actionTimeline = new Map<string, PreviewAutomationActionEvent[]>(); private actionSequence = 0; + private pointerSequence = 0; private recordingTabId: string | null = null; + configureArtifactDirectory(directory: string): void { + this.artifactDirectory = resolve(directory); + } + + private requireArtifactDirectory(): string { + if (!this.artifactDirectory) { + throw new Error("Preview artifact directory is not configured."); + } + return this.artifactDirectory; + } + + private resolveArtifactPath(path: string): string { + const directory = this.requireArtifactDirectory(); + const resolvedPath = resolve(path); + const relativePath = relative(directory, resolvedPath); + if ( + relativePath.length === 0 || + relativePath === ".." || + relativePath.startsWith(`..${sep}`) || + isAbsolute(relativePath) + ) { + throw new Error("Preview artifact path is outside the configured artifact directory."); + } + return resolvedPath; + } + + revealArtifact(path: string): void { + const resolvedPath = this.resolveArtifactPath(path); + shell.showItemInFolder(resolvedPath); + } + + copyArtifactToClipboard(path: string): void { + const resolvedPath = this.resolveArtifactPath(path); + const image = nativeImage.createFromPath(resolvedPath); + if (image.isEmpty()) { + throw new Error("Preview artifact could not be loaded as an image."); + } + clipboard.writeImage(image); + } + + setAnnotationTheme(theme: DesktopPreviewAnnotationTheme): void { + this.annotationTheme = theme; + for (const tab of this.tabs.values()) { + if (tab.webContentsId == null) continue; + const wc = webContents.fromId(tab.webContentsId); + if (!wc || wc.isDestroyed()) continue; + wc.send(ANNOTATION_THEME_CHANNEL, theme); + } + } + setMainWindow(window: BrowserWindow): void { this.mainWindow = window; } @@ -338,6 +490,7 @@ export class PreviewViewManager { throw new PreviewWebContentsNotFoundError(tabId, webContentsId); } if (tab.webContentsId === webContentsId && this.attached.has(webContentsId)) { + wc.send(ANNOTATION_THEME_CHANNEL, this.annotationTheme); return; } if (tab.webContentsId != null && tab.webContentsId !== webContentsId) { @@ -366,6 +519,7 @@ export class PreviewViewManager { canGoForward: wc.navigationHistory.canGoForward(), zoomFactor: tab.zoomFactor, }); + wc.send(ANNOTATION_THEME_CHANNEL, this.annotationTheme); } async navigate(tabId: string, rawUrl: string): Promise<void> { @@ -513,7 +667,7 @@ export class PreviewViewManager { // wc may be torn down; the next try/catch settles. } try { - wc.send(START_PICK_CHANNEL); + wc.send(START_PICK_CHANNEL, this.annotationTheme); } catch { settle(null); } @@ -561,8 +715,8 @@ export class PreviewViewManager { async captureScreenshot(tabId: string): Promise<DesktopPreviewScreenshotArtifact> { const wc = this.requireWebContents(tabId); const createdAt = new Date().toISOString(); - const id = `browser-screenshot-${Date.now().toString(36)}`; - const directory = join(app.getPath("userData"), "browser-artifacts"); + const id = `browser-screenshot-${artifactSiteSlug(wc.getURL())}-${Date.now().toString(36)}`; + const directory = this.requireArtifactDirectory(); const path = join(directory, `${id}.png`); const data = (await wc.capturePage()).toPNG(); await mkdir(directory, { recursive: true }); @@ -587,7 +741,7 @@ export class PreviewViewManager { const createdAt = new Date().toISOString(); const id = `browser-recording-${Date.now().toString(36)}`; const extension = mimeType.includes("mp4") ? "mp4" : "webm"; - const directory = join(app.getPath("userData"), "browser-artifacts"); + const directory = this.requireArtifactDirectory(); const path = join(directory, `${id}.${extension}`); await mkdir(directory, { recursive: true }); await writeFile(path, data); @@ -793,6 +947,25 @@ export class PreviewViewManager { `Click coordinates (${x}, ${y}) are outside the preview viewport.`, ); } + this.emitPointerEvent({ + tabId, + phase: "move", + x, + y, + sequence: this.pointerSequence++, + createdAt: new Date().toISOString(), + }); + await sleep(AGENT_CURSOR_MOVE_MS); + this.emitPointerEvent({ + tabId, + phase: "click", + x, + y, + sequence: this.pointerSequence++, + createdAt: new Date().toISOString(), + }); + await sleep(AGENT_CURSOR_CLICK_LEAD_MS); + this.expectAgentInput(tabId, { kind: "pointer", x, y, button: 0 }); await send("Input.dispatchMouseEvent", { type: "mousePressed", x, @@ -886,6 +1059,7 @@ export class PreviewViewManager { modifiers, ...(text ? { text, unmodifiedText: text } : {}), }; + this.expectAgentInput(tabId, { kind: "key", key, code: params.code }); await send("Input.dispatchKeyEvent", { type: "keyDown", ...params }); await send("Input.dispatchKeyEvent", { type: "keyUp", ...params }); }); @@ -1345,11 +1519,50 @@ export class PreviewViewManager { }; } + onPointerEvent(listener: PointerEventListener): () => void { + this.pointerEventListeners.add(listener); + return () => { + this.pointerEventListeners.delete(listener); + }; + } + + private emitPointerEvent(event: DesktopPreviewPointerEvent): void { + for (const listener of this.pointerEventListeners) listener(event); + } + + private expectAgentInput(tabId: string, signal: PreviewInputSignal): void { + const now = Date.now(); + const pending = (this.expectedAgentInputs.get(tabId) ?? []).filter( + (expected) => expected.expiresAt > now, + ); + pending.push({ signal, expiresAt: now + 1_000 }); + this.expectedAgentInputs.set(tabId, pending); + } + + private consumeExpectedAgentInput(tabId: string, signal: PreviewInputSignal): boolean { + const now = Date.now(); + const pending = (this.expectedAgentInputs.get(tabId) ?? []).filter( + (expected) => expected.expiresAt > now, + ); + const index = pending.findIndex((expected) => inputSignalsMatch(expected.signal, signal)); + if (index < 0) { + if (pending.length === 0) this.expectedAgentInputs.delete(tabId); + else this.expectedAgentInputs.set(tabId, pending); + return false; + } + pending.splice(index, 1); + if (pending.length === 0) this.expectedAgentInputs.delete(tabId); + else this.expectedAgentInputs.set(tabId, pending); + return true; + } + destroy(): void { for (const tabId of Array.from(this.tabs.keys())) { this.closeTab(tabId); } this.listeners.clear(); + this.expectedAgentInputs.clear(); + this.pointerEventListeners.clear(); this.recordingFrameListeners.clear(); } @@ -1375,7 +1588,10 @@ export class PreviewViewManager { }, }); }; - const humanInput = (): void => { + const humanInput = (_event: unknown, rawSignal?: unknown): void => { + if (isPreviewInputSignal(rawSignal) && this.consumeExpectedAgentInput(tabId, rawSignal)) { + return; + } this.controlEpoch.set(tabId, (this.controlEpoch.get(tabId) ?? 0) + 1); this.update(tabId, { controller: "human" }); void sleep(750).then(() => { diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index 4c1e9f8c153..dceefc14e9e 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -14,17 +14,18 @@ export default defineConfig({ run: { tasks: { build: { - command: "vp pack", + command: "node scripts/build-preview-annotation-css.mjs && vp pack", dependsOn: ["t3#build"], cache: false, }, dev: { - command: "cross-env T3CODE_DESKTOP_DEV=1 vp pack --watch", + command: + "node scripts/build-preview-annotation-css.mjs && cross-env T3CODE_DESKTOP_DEV=1 vp pack --watch", dependsOn: ["t3#build"], cache: false, }, "dev:bundle": { - command: "vp pack --watch", + command: "node scripts/build-preview-annotation-css.mjs && vp pack --watch", cache: false, }, "dev:electron": { diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index 188a8d32d18..1bfd042d078 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -205,6 +205,8 @@ it.layer(NodeServices.layer)("keybindings", (it) => { assert.equal(defaultsByCommand.get("thread.jump.1"), "mod+1"); assert.equal(defaultsByCommand.get("thread.jump.9"), "mod+9"); assert.equal(defaultsByCommand.get("modelPicker.toggle"), "mod+shift+m"); + assert.equal(defaultsByCommand.get("rightPanel.toggle"), "mod+alt+b"); + assert.equal(defaultsByCommand.get("terminal.splitVertical"), "mod+shift+d"); assert.equal(defaultsByCommand.get("modelPicker.jump.1"), "mod+1"); assert.equal(defaultsByCommand.get("modelPicker.jump.9"), "mod+9"); }), diff --git a/apps/server/src/mcp/Layers/McpHttpServer.test.ts b/apps/server/src/mcp/Layers/McpHttpServer.test.ts index 90843634130..374d147d802 100644 --- a/apps/server/src/mcp/Layers/McpHttpServer.test.ts +++ b/apps/server/src/mcp/Layers/McpHttpServer.test.ts @@ -75,14 +75,16 @@ it.effect("registers annotated tools and preserves authenticated request context height: 5, }, } - : { - available: true, - visible: true, - tabId, - url: "http://example.test/", - title: "Example", - loading: false, - }, + : request.operation === "press" + ? undefined + : { + available: true, + visible: true, + tabId, + url: "http://example.test/", + title: "Example", + loading: false, + }, }), ).pipe(Effect.forkScoped); yield* Effect.yieldNow; @@ -146,6 +148,16 @@ it.effect("registers annotated tools and preserves authenticated request context expect(snapshot.structuredContent).toMatchObject({ screenshot: { mimeType: "image/png", width: 10, height: 5 }, }); + + const press = yield* server + .callTool({ name: "preview_press", arguments: { key: "Enter" } }) + .pipe( + Effect.provideService(McpInvocationContext, invocation), + Effect.provideService(McpSchema.McpServerClient, client), + ); + expect(press.isError).toBe(false); + expect(press.structuredContent).toBeUndefined(); + expect(press.content).toEqual([{ type: "text", text: "null" }]); }), ).pipe(Effect.provide(TestLayer)), ); diff --git a/apps/server/src/mcp/Layers/McpHttpServer.ts b/apps/server/src/mcp/Layers/McpHttpServer.ts index 645a8fdc3c8..356e2eabbef 100644 --- a/apps/server/src/mcp/Layers/McpHttpServer.ts +++ b/apps/server/src/mcp/Layers/McpHttpServer.ts @@ -143,11 +143,14 @@ export const PreviewToolkitRegistrationLive = Layer.effectDiscard( ], }); } + const encodedResultText = JSON.stringify(result.encodedResult) ?? "null"; return new McpSchema.CallToolResult({ isError: false, structuredContent: - typeof result.encodedResult === "object" ? result.encodedResult : undefined, - content: [{ type: "text", text: JSON.stringify(result.encodedResult) }], + result.encodedResult !== null && typeof result.encodedResult === "object" + ? result.encodedResult + : undefined, + content: [{ type: "text", text: encodedResultText }], }); }, }), diff --git a/apps/server/src/preview/Layers/PortScanner.test.ts b/apps/server/src/preview/Layers/PortScanner.test.ts index e0bec8cd4d3..f0e78aeedbc 100644 --- a/apps/server/src/preview/Layers/PortScanner.test.ts +++ b/apps/server/src/preview/Layers/PortScanner.test.ts @@ -1,18 +1,20 @@ import * as net from "node:net"; import { it as effectIt } from "@effect/vitest"; +import { ThreadId } from "@t3tools/contracts"; import { Effect, Layer } from "effect"; import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; -import { COMMON_DEV_PORTS, PreviewPortScanner } from "../Services/PortScanner.ts"; +import { COMMON_DEV_PORTS, PortDiscovery } from "../Services/PortScanner.ts"; import { ProcessRunner } from "../../processRunner.ts"; -import { __testing, PreviewPortScannerLive } from "./PortScanner.ts"; +import { __testing, PortDiscoveryLive } from "./PortScanner.ts"; -const { parseLsofOutput, parsePortFromLsofName, serversEqual } = __testing; +const { parseLsofOutput, parsePortFromLsofName, parseWindowsListenerOutput, serversEqual } = + __testing; const TestProcessRunner = Layer.succeed(ProcessRunner, { run: () => Effect.die("ProcessRunner should not be used by Windows TCP probe tests"), }); -const TestPreviewPortScannerLive = PreviewPortScannerLive.pipe(Layer.provide(TestProcessRunner)); +const TestPortDiscoveryLive = PortDiscoveryLive.pipe(Layer.provide(TestProcessRunner)); describe("parsePortFromLsofName", () => { it("parses *:port", () => { @@ -70,6 +72,7 @@ describe("parseLsofOutput", () => { url: "http://localhost:3000", processName: "next-server", pid: 67890, + terminal: null, }, { host: "localhost", @@ -77,6 +80,7 @@ describe("parseLsofOutput", () => { url: "http://localhost:5173", processName: "node", pid: 12345, + terminal: null, }, { host: "localhost", @@ -84,6 +88,7 @@ describe("parseLsofOutput", () => { url: "http://localhost:9229", processName: "next-server", pid: 67890, + terminal: null, }, ]); }); @@ -98,6 +103,26 @@ describe("parseLsofOutput", () => { expect(servers).toHaveLength(1); expect(servers[0]?.port).toBe(5173); }); + + it("attributes listeners to a registered terminal process", () => { + const servers = parseLsofOutput( + ["p12345", "cnode", "n*:5173"].join("\n"), + new Map([ + [ + 12345, + { + threadId: ThreadId.make("thread-1"), + terminalId: "terminal-1", + }, + ], + ]), + ); + + expect(servers[0]?.terminal).toEqual({ + threadId: "thread-1", + terminalId: "terminal-1", + }); + }); }); describe("serversEqual", () => { @@ -107,6 +132,7 @@ describe("serversEqual", () => { url: "http://localhost:5173", processName: "node", pid: 1, + terminal: null, }; it("returns true for identical lists", () => { expect(serversEqual([a], [{ ...a }])).toBe(true); @@ -119,12 +145,43 @@ describe("serversEqual", () => { }); }); +describe("parseWindowsListenerOutput", () => { + it("parses and attributes PowerShell listener records", () => { + const servers = parseWindowsListenerOutput( + "0.0.0.0|5173|12345|node", + new Map([ + [ + 12345, + { + threadId: ThreadId.make("thread-1"), + terminalId: "terminal-1", + }, + ], + ]), + ); + + expect(servers).toEqual([ + { + host: "localhost", + port: 5173, + url: "http://localhost:5173", + processName: "node", + pid: 12345, + terminal: { + threadId: "thread-1", + terminalId: "terminal-1", + }, + }, + ]); + }); +}); + /** * Integration tests against a real TCP listener. We force the Windows code * path (TCP-probe fallback) by monkey-patching `process.platform` for the * duration of the test so we don't depend on `lsof` being installed. */ -describe("PreviewPortScanner integration (TCP probe path)", () => { +describe("PortDiscovery integration (TCP probe fallback)", () => { let originalPlatform: NodeJS.Platform; let server: net.Server; let port: number; @@ -158,18 +215,18 @@ describe("PreviewPortScanner integration (TCP probe path)", () => { effectIt.effect("scan() returns a server we just opened on a curated dev port", () => Effect.gen(function* () { - const scanner = yield* PreviewPortScanner; + const scanner = yield* PortDiscovery; const result = yield* scanner.scan(); const found = result.find((server) => server.port === port); expect(found).toBeDefined(); expect(found?.host).toBe("localhost"); - }).pipe(Effect.provide(TestPreviewPortScannerLive)), + }).pipe(Effect.provide(TestPortDiscoveryLive)), ); effectIt.effect("retain() drives an immediate broadcast to subscribers", () => { const received: number[] = []; return Effect.gen(function* () { - const scanner = yield* PreviewPortScanner; + const scanner = yield* PortDiscovery; const unsubscribe = yield* scanner.subscribe((servers) => Effect.sync(() => { for (const server of servers) received.push(server.port); @@ -179,6 +236,6 @@ describe("PreviewPortScanner integration (TCP probe path)", () => { unsubscribe(); release(); expect(received).toContain(port); - }).pipe(Effect.provide(TestPreviewPortScannerLive)); + }).pipe(Effect.provide(TestPortDiscoveryLive)); }); }); diff --git a/apps/server/src/preview/Layers/PortScanner.ts b/apps/server/src/preview/Layers/PortScanner.ts index fa0bd2b0520..4e4d3b758a3 100644 --- a/apps/server/src/preview/Layers/PortScanner.ts +++ b/apps/server/src/preview/Layers/PortScanner.ts @@ -13,32 +13,42 @@ */ import * as net from "node:net"; -import type { DiscoveredLocalServer } from "@t3tools/contracts"; +import { ThreadId, type DiscoveredLocalServer } from "@t3tools/contracts"; import { LSOF_LOCAL_HOST_TOKENS } from "@t3tools/shared/preview"; -import { Cause, Data, Duration, Effect, Layer, Ref, Schedule } from "effect"; +import { Cause, Duration, Effect, Layer, Ref, Schedule } from "effect"; -import { ProcessRunner, type ProcessRunnerShape } from "../../processRunner.ts"; +import { ProcessRunner } from "../../processRunner.ts"; import { COMMON_DEV_PORTS, - PreviewPortScanner, - type PreviewPortScannerShape, + PortDiscovery, + type PortDiscoveryShape, } from "../Services/PortScanner.ts"; const POLL_INTERVAL = Duration.seconds(3); const TCP_PROBE_TIMEOUT_MS = 200; const LSOF_TIMEOUT_MS = 5_000; +const WINDOWS_LISTENER_TIMEOUT_MS = 5_000; type Listener = (servers: ReadonlyArray<DiscoveredLocalServer>) => Effect.Effect<void>; -class LsofProbeError extends Data.TaggedError("LsofProbeError")<{ - readonly cause: unknown; -}> {} - interface ScannerState { readonly lastSnapshot: ReadonlyArray<DiscoveredLocalServer>; } -const parseLsofOutput = (raw: string): ReadonlyArray<DiscoveredLocalServer> => { +interface TerminalProcessOwner { + readonly threadId: ThreadId; + readonly terminalId: string; +} + +const terminalOwnerKey = (owner: { + readonly threadId: string; + readonly terminalId: string; +}): string => `${owner.threadId}\u0000${owner.terminalId}`; + +const parseLsofOutput = ( + raw: string, + terminalByProcessId: ReadonlyMap<number, TerminalProcessOwner> = new Map(), +): ReadonlyArray<DiscoveredLocalServer> => { const seen = new Map<string, DiscoveredLocalServer>(); let pid: number | null = null; let processName: string | null = null; @@ -69,6 +79,7 @@ const parseLsofOutput = (raw: string): ReadonlyArray<DiscoveredLocalServer> => { url, processName, pid, + terminal: pid === null ? null : (terminalByProcessId.get(pid) ?? null), }); } } @@ -91,22 +102,31 @@ const parsePortFromLsofName = (name: string): number | null => { return port; }; -const probeLsof = ( - processRunner: ProcessRunnerShape, -): Effect.Effect<ReadonlyArray<DiscoveredLocalServer> | null> => - processRunner - .run({ - command: "lsof", - args: ["-iTCP", "-sTCP:LISTEN", "-P", "-n", "-F", "pcn"], - timeout: Duration.millis(LSOF_TIMEOUT_MS), - maxOutputBytes: 1024 * 1024, - outputMode: "truncate", - }) - .pipe( - Effect.mapError((cause) => new LsofProbeError({ cause })), - Effect.map((result) => parseLsofOutput(result.stdout)), - Effect.orElseSucceed(() => null), - ); +const parseWindowsListenerOutput = ( + raw: string, + terminalByProcessId: ReadonlyMap<number, TerminalProcessOwner> = new Map(), +): ReadonlyArray<DiscoveredLocalServer> => { + const seen = new Map<number, DiscoveredLocalServer>(); + for (const line of raw.split(/\r?\n/g)) { + const [hostRaw, portRaw, pidRaw, processNameRaw] = line.trim().split("|", 4); + const host = hostRaw?.trim() ?? ""; + if (!LSOF_LOCAL_HOST_TOKENS.has(host) && host !== "::") continue; + const port = Number(portRaw); + const pid = Number(pidRaw); + if (!Number.isInteger(port) || port <= 0 || port >= 65536) continue; + const normalizedPid = Number.isInteger(pid) && pid > 0 ? pid : null; + if (seen.has(port)) continue; + seen.set(port, { + host: "localhost", + port, + url: `http://localhost:${port}`, + processName: processNameRaw?.trim() || null, + pid: normalizedPid, + terminal: normalizedPid === null ? null : (terminalByProcessId.get(normalizedPid) ?? null), + }); + } + return [...seen.values()].toSorted((left, right) => left.port - right.port); +}; const probeTcpPort = (port: number): Promise<boolean> => new Promise((resolve) => { @@ -138,6 +158,7 @@ const probeCommonPorts = (): Effect.Effect<ReadonlyArray<DiscoveredLocalServer>> url: `http://localhost:${r.port}`, processName: null, pid: null, + terminal: null, })); }); @@ -155,7 +176,9 @@ const serversEqual = ( a.port !== b.port || a.url !== b.url || a.processName !== b.processName || - a.pid !== b.pid + a.pid !== b.pid || + a.terminal?.threadId !== b.terminal?.threadId || + a.terminal?.terminalId !== b.terminal?.terminalId ) { return false; } @@ -163,12 +186,19 @@ const serversEqual = ( return true; }; -export const makePreviewPortScanner = Effect.gen(function* () { +export const makePortDiscovery = Effect.gen(function* () { const processRunner = yield* ProcessRunner; const stateRef = yield* Ref.make<ScannerState>({ lastSnapshot: [], }); const listeners = new Set<Listener>(); + const terminalProcesses = new Map< + string, + { + readonly owner: TerminalProcessOwner; + readonly processIds: ReadonlySet<number>; + } + >(); // Plain integer because the release callback returned by `retain()` runs // outside any Effect context (the WS subscriber's release path) and must // be a synchronous side-effect-only function. @@ -176,11 +206,43 @@ export const makePreviewPortScanner = Effect.gen(function* () { const scanOnce = (): Effect.Effect<ReadonlyArray<DiscoveredLocalServer>> => Effect.gen(function* () { + const terminalByProcessId = new Map<number, TerminalProcessOwner>(); + for (const registration of terminalProcesses.values()) { + for (const processId of registration.processIds) { + terminalByProcessId.set(processId, registration.owner); + } + } if (process.platform === "win32") { + const command = + 'Get-NetTCPConnection -State Listen -ErrorAction Stop | ForEach-Object { $processName = (Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue).ProcessName; Write-Output "$($_.LocalAddress)|$($_.LocalPort)|$($_.OwningProcess)|$processName" }'; + const listeners = yield* processRunner + .run({ + command: "powershell.exe", + args: ["-NoProfile", "-NonInteractive", "-Command", command], + timeout: Duration.millis(WINDOWS_LISTENER_TIMEOUT_MS), + maxOutputBytes: 1024 * 1024, + outputMode: "truncate", + }) + .pipe( + Effect.map((result) => parseWindowsListenerOutput(result.stdout, terminalByProcessId)), + Effect.catchCause(() => Effect.succeed(null)), + ); + if (listeners !== null) return listeners; return yield* probeCommonPorts(); } - const lsof = yield* probeLsof(processRunner); - if (lsof !== null) return lsof; + const lsofResult = yield* processRunner + .run({ + command: "lsof", + args: ["-iTCP", "-sTCP:LISTEN", "-P", "-n", "-F", "pcn"], + timeout: Duration.millis(LSOF_TIMEOUT_MS), + maxOutputBytes: 1024 * 1024, + outputMode: "truncate", + }) + .pipe( + Effect.map((result) => parseLsofOutput(result.stdout, terminalByProcessId)), + Effect.catchCause(() => Effect.succeed(null)), + ); + if (lsofResult !== null) return lsofResult; return yield* probeCommonPorts(); }); @@ -204,7 +266,7 @@ export const makePreviewPortScanner = Effect.gen(function* () { // currently retained, so the cost is one Ref.get every POLL_INTERVAL. yield* Effect.forkScoped(pollTick.pipe(Effect.repeat(Schedule.spaced(POLL_INTERVAL)))); - const retain: PreviewPortScannerShape["retain"] = () => + const retain: PortDiscoveryShape["retain"] = () => Effect.gen(function* () { const wasIdle = retainCount === 0; retainCount += 1; @@ -221,7 +283,7 @@ export const makePreviewPortScanner = Effect.gen(function* () { }; }); - const subscribe: PreviewPortScannerShape["subscribe"] = (listener) => + const subscribe: PortDiscoveryShape["subscribe"] = (listener) => Effect.sync(() => { listeners.add(listener); return () => { @@ -229,18 +291,43 @@ export const makePreviewPortScanner = Effect.gen(function* () { }; }); + const registerTerminalProcesses: PortDiscoveryShape["registerTerminalProcesses"] = (input) => + Effect.sync(() => { + const owner = { + threadId: ThreadId.make(input.threadId), + terminalId: input.terminalId, + }; + const processIds = new Set( + input.processIds.filter((processId) => Number.isInteger(processId) && processId > 0), + ); + const key = terminalOwnerKey(owner); + if (processIds.size === 0) { + terminalProcesses.delete(key); + return; + } + terminalProcesses.set(key, { owner, processIds }); + }); + + const unregisterTerminal: PortDiscoveryShape["unregisterTerminal"] = (input) => + Effect.sync(() => { + terminalProcesses.delete(terminalOwnerKey(input)); + }); + return { scan: scanOnce, subscribe, retain, - } satisfies PreviewPortScannerShape; + registerTerminalProcesses, + unregisterTerminal, + } satisfies PortDiscoveryShape; }); -export const PreviewPortScannerLive = Layer.effect(PreviewPortScanner, makePreviewPortScanner); +export const PortDiscoveryLive = Layer.effect(PortDiscovery, makePortDiscovery); /** Exposed for tests. */ export const __testing = { parseLsofOutput, parsePortFromLsofName, + parseWindowsListenerOutput, serversEqual, }; diff --git a/apps/server/src/preview/Services/PortScanner.ts b/apps/server/src/preview/Services/PortScanner.ts index 4e8751bb669..af69596ba5a 100644 --- a/apps/server/src/preview/Services/PortScanner.ts +++ b/apps/server/src/preview/Services/PortScanner.ts @@ -1,17 +1,17 @@ /** - * PreviewPortScanner - Discovers listening localhost ports for the preview - * empty-state recommendations. + * PortDiscovery - Discovers listening localhost ports and attributes them to + * registered terminal process families. * * Reference-counted polling: the scanner only runs when at least one client * has called `retain()`. This keeps idle desktops from running `lsof` every * 3 seconds for nothing. * - * @module PreviewPortScanner + * @module PortDiscovery */ import type { DiscoveredLocalServer } from "@t3tools/contracts"; import { Context, type Effect } from "effect"; -export interface PreviewPortScannerShape { +export interface PortDiscoveryShape { /** One-shot snapshot of currently listening localhost ports. */ readonly scan: () => Effect.Effect<ReadonlyArray<DiscoveredLocalServer>>; /** Subscribe to changes. Listener invoked on every diff. Returns unsubscribe fn. */ @@ -23,12 +23,22 @@ export interface PreviewPortScannerShape { * release fn. When release count hits 0, polling stops. */ readonly retain: () => Effect.Effect<() => void>; + /** Associate a terminal with its current process family for port attribution. */ + readonly registerTerminalProcesses: (input: { + readonly threadId: string; + readonly terminalId: string; + readonly processIds: ReadonlyArray<number>; + }) => Effect.Effect<void>; + /** Remove process attribution for a terminal that stopped or closed. */ + readonly unregisterTerminal: (input: { + readonly threadId: string; + readonly terminalId: string; + }) => Effect.Effect<void>; } -export class PreviewPortScanner extends Context.Service< - PreviewPortScanner, - PreviewPortScannerShape ->()("t3/preview/Services/PortScanner/PreviewPortScanner") {} +export class PortDiscovery extends Context.Service<PortDiscovery, PortDiscoveryShape>()( + "t3/preview/Services/PortScanner/PortDiscovery", +) {} /** Curated list of common dev-server ports for the Windows TCP-probe fallback. */ export const COMMON_DEV_PORTS: ReadonlyArray<number> = Object.freeze([ diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index c69771181f1..e6d0f35cea7 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -100,7 +100,7 @@ import { ServerRuntimeStartup, type ServerRuntimeStartupShape } from "./serverRu import { ServerSettingsService, type ServerSettingsShape } from "./serverSettings.ts"; import { TerminalManager, type TerminalManagerShape } from "./terminal/Services/Manager.ts"; import { PreviewManager } from "./preview/Services/Manager.ts"; -import { PreviewPortScanner } from "./preview/Services/PortScanner.ts"; +import { PortDiscovery } from "./preview/Services/PortScanner.ts"; import { BrowserTraceCollector, type BrowserTraceCollectorShape, @@ -681,10 +681,12 @@ const buildAppUnderTest = (options?: { PubSub.subscribe(pubsub), ), }), - Layer.mock(PreviewPortScanner)({ + Layer.mock(PortDiscovery)({ scan: () => Effect.succeed([]), subscribe: () => Effect.succeed(() => {}), retain: () => Effect.succeed(() => {}), + registerTerminalProcesses: () => Effect.void, + unregisterTerminal: () => Effect.void, }), ), ), diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index e14a08b7952..c1a1298a1ae 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -36,7 +36,7 @@ import * as TextGeneration from "./textGeneration/TextGeneration.ts"; import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/ProviderInstanceRegistryHydration.ts"; import { TerminalManagerLive } from "./terminal/Layers/Manager.ts"; import { PreviewManagerLive } from "./preview/Layers/Manager.ts"; -import { PreviewPortScannerLive } from "./preview/Layers/PortScanner.ts"; +import { PortDiscoveryLive } from "./preview/Layers/PortScanner.ts"; import { McpHttpServerLive } from "./mcp/Layers/McpHttpServer.ts"; import { McpSessionRegistryLive } from "./mcp/Layers/McpSessionRegistry.ts"; import * as ProcessRunner from "./processRunner.ts"; @@ -236,13 +236,15 @@ const CheckpointingLayerLive = Layer.empty.pipe( Layer.provideMerge(CheckpointStoreLive.pipe(Layer.provide(VcsDriverRegistryLayerLive))), ); -const TerminalLayerLive = TerminalManagerLive.pipe(Layer.provide(PtyAdapterLive)); +const PortScannerLayerLive = PortDiscoveryLive.pipe(Layer.provide(ProcessRunner.layer)); -const PreviewLayerLive = Layer.mergeAll( - PreviewManagerLive, - PreviewPortScannerLive.pipe(Layer.provide(ProcessRunner.layer)), +const TerminalLayerLive = TerminalManagerLive.pipe( + Layer.provide(PtyAdapterLive), + Layer.provide(PortScannerLayerLive), ); +const PreviewLayerLive = Layer.mergeAll(PreviewManagerLive, PortScannerLayerLive); + const WorkspaceEntriesLayerLive = WorkspaceEntriesLive.pipe( Layer.provide(WorkspacePathsLive), Layer.provideMerge(VcsDriverRegistryLayerLive), diff --git a/apps/server/src/terminal/Layers/Manager.test.ts b/apps/server/src/terminal/Layers/Manager.test.ts index 2ebf8481957..30515ca2c47 100644 --- a/apps/server/src/terminal/Layers/Manager.test.ts +++ b/apps/server/src/terminal/Layers/Manager.test.ts @@ -204,6 +204,7 @@ interface CreateManagerOptions { subprocessInspector?: (terminalPid: number) => Effect.Effect<{ readonly hasRunningSubprocess: boolean; readonly childCommand: string | null; + readonly processIds: ReadonlyArray<number>; }>; subprocessPollIntervalMs?: number; processKillGraceMs?: number; @@ -745,7 +746,8 @@ it.layer( let inspect: { readonly hasRunningSubprocess: boolean; readonly childCommand: string | null; - } = { hasRunningSubprocess: false, childCommand: null }; + readonly processIds: ReadonlyArray<number>; + } = { hasRunningSubprocess: false, childCommand: null, processIds: [] }; const { manager, getEvents } = yield* createManager(5, { subprocessInspector: () => Effect.succeed(inspect), subprocessPollIntervalMs: 20, @@ -754,7 +756,7 @@ it.layer( yield* manager.open(openInput()); expect((yield* getEvents).some((event) => event.type === "activity")).toBe(false); - inspect = { hasRunningSubprocess: true, childCommand: "vim" }; + inspect = { hasRunningSubprocess: true, childCommand: "vim", processIds: [100, 101] }; yield* waitFor( Effect.map(getEvents, (events) => events.some( @@ -767,7 +769,7 @@ it.layer( "1200 millis", ); - inspect = { hasRunningSubprocess: false, childCommand: null }; + inspect = { hasRunningSubprocess: false, childCommand: null, processIds: [] }; yield* waitFor( Effect.map(getEvents, (events) => events.some( @@ -788,7 +790,11 @@ it.layer( const { manager } = yield* createManager(5, { subprocessInspector: () => { checks += 1; - return Effect.succeed({ hasRunningSubprocess: false, childCommand: null }); + return Effect.succeed({ + hasRunningSubprocess: false, + childCommand: null, + processIds: [], + }); }, subprocessPollIntervalMs: 20, }); diff --git a/apps/server/src/terminal/Layers/Manager.ts b/apps/server/src/terminal/Layers/Manager.ts index cd490de1e3f..acde17fc85f 100644 --- a/apps/server/src/terminal/Layers/Manager.ts +++ b/apps/server/src/terminal/Layers/Manager.ts @@ -33,6 +33,7 @@ import { terminalSessionsTotal, } from "../../observability/Metrics.ts"; import * as ProcessRunner from "../../processRunner.ts"; +import { PortDiscovery } from "../../preview/Services/PortScanner.ts"; import { TerminalCwdError, TerminalHistoryError, @@ -82,6 +83,7 @@ class TerminalProcessSignalError extends Schema.TaggedErrorClass<TerminalProcess interface TerminalSubprocessInspectResult { readonly hasRunningSubprocess: boolean; readonly childCommand: string | null; + readonly processIds: ReadonlyArray<number>; } interface TerminalSubprocessInspector { @@ -505,12 +507,8 @@ function windowsInspectSubprocess( TerminalSubprocessCheckError, ProcessRunner.ProcessRunner > { - const command = [ - `$c = Get-CimInstance Win32_Process -Filter "ParentProcessId = ${terminalPid}" -ErrorAction SilentlyContinue | Select-Object -First 1`, - "if ($null -eq $c) { exit 1 }", - "Write-Output $c.Name", - "exit 0", - ].join("; "); + const command = + 'Get-CimInstance Win32_Process -ErrorAction Stop | ForEach-Object { Write-Output "$($_.ProcessId)|$($_.ParentProcessId)|$($_.Name)" }'; return Effect.gen(function* () { const processRunner = yield* ProcessRunner.ProcessRunner; return yield* processRunner.run({ @@ -524,16 +522,41 @@ function windowsInspectSubprocess( }).pipe( Effect.map((result) => { if (result.code !== 0) { - return { hasRunningSubprocess: false, childCommand: null } as const; + return { hasRunningSubprocess: false, childCommand: null, processIds: [] } as const; } - const name = result.stdout.trim().split(/\r?\n/)[0]?.trim() ?? ""; - if (name.length === 0) { - return { hasRunningSubprocess: true, childCommand: null } as const; + const processNameById = new Map<number, string>(); + const childrenByParent = new Map<number, number[]>(); + for (const line of result.stdout.split(/\r?\n/g)) { + const [pidRaw, parentPidRaw, nameRaw] = line.trim().split("|", 3); + const pid = Number(pidRaw); + const parentPid = Number(parentPidRaw); + if (!Number.isInteger(pid) || !Number.isInteger(parentPid)) continue; + processNameById.set(pid, nameRaw?.trim() ?? ""); + const children = childrenByParent.get(parentPid) ?? []; + children.push(pid); + childrenByParent.set(parentPid, children); } - const normalized = normalizeChildCommandName(name, platform); + const directChildren = childrenByParent.get(terminalPid) ?? []; + const childPid = directChildren[0]; + if (childPid === undefined) { + return { hasRunningSubprocess: false, childCommand: null, processIds: [] } as const; + } + const processIds = new Set<number>([terminalPid]); + const pending = [terminalPid]; + while (pending.length > 0) { + const parentPid = pending.pop(); + if (parentPid === undefined) continue; + for (const pid of childrenByParent.get(parentPid) ?? []) { + if (processIds.has(pid)) continue; + processIds.add(pid); + pending.push(pid); + } + } + const normalized = normalizeChildCommandName(processNameById.get(childPid) ?? "", platform); return { hasRunningSubprocess: true, childCommand: normalized ? truncateTerminalWireLabel(normalized) : null, + processIds: [...processIds], } as const; }), Effect.mapError( @@ -606,14 +629,14 @@ const posixInspectSubprocess = Effect.fn("terminal.posixInspectSubprocess")(func if (pgrepResult.value.code === 0) { childPid = parseFirstChildPidFromPgrep(pgrepResult.value.stdout); } else if (pgrepResult.value.code === 1) { - return { hasRunningSubprocess: false, childCommand: null }; + return { hasRunningSubprocess: false, childCommand: null, processIds: [] }; } } if (childPid === null) { const psResult = yield* Effect.exit(runPs); if (psResult._tag === "Failure" || psResult.value.code !== 0) { - return { hasRunningSubprocess: false, childCommand: null }; + return { hasRunningSubprocess: false, childCommand: null, processIds: [] }; } for (const line of psResult.value.stdout.split(/\r?\n/g)) { const [pidRaw, ppidRaw] = line.trim().split(/\s+/g); @@ -628,7 +651,7 @@ const posixInspectSubprocess = Effect.fn("terminal.posixInspectSubprocess")(func } if (childPid === null) { - return { hasRunningSubprocess: false, childCommand: null }; + return { hasRunningSubprocess: false, childCommand: null, processIds: [] }; } const runComm = processRunner.run({ @@ -663,16 +686,43 @@ const posixInspectSubprocess = Effect.fn("terminal.posixInspectSubprocess")(func } const normalized = rawComm ? normalizeChildCommandName(rawComm, platform) : null; + const processIds = new Set<number>([terminalPid]); + const psResult = yield* Effect.exit(runPs); + if (psResult._tag === "Success" && psResult.value.code === 0) { + const childrenByParent = new Map<number, number[]>(); + for (const line of psResult.value.stdout.split(/\r?\n/g)) { + const [pidRaw, ppidRaw] = line.trim().split(/\s+/g); + const pid = Number(pidRaw); + const ppid = Number(ppidRaw); + if (!Number.isInteger(pid) || !Number.isInteger(ppid)) continue; + const children = childrenByParent.get(ppid) ?? []; + children.push(pid); + childrenByParent.set(ppid, children); + } + const pending = [terminalPid]; + while (pending.length > 0) { + const parentPid = pending.pop(); + if (parentPid === undefined) continue; + for (const child of childrenByParent.get(parentPid) ?? []) { + if (processIds.has(child)) continue; + processIds.add(child); + pending.push(child); + } + } + } else { + processIds.add(childPid); + } return { hasRunningSubprocess: true, childCommand: normalized ? truncateTerminalWireLabel(normalized) : null, + processIds: [...processIds], }; }); function defaultSubprocessInspectorForPlatform(platform: NodeJS.Platform) { return Effect.fn("terminal.defaultSubprocessInspector")(function* (terminalPid: number) { if (!Number.isInteger(terminalPid) || terminalPid <= 0) { - return { hasRunningSubprocess: false, childCommand: null }; + return { hasRunningSubprocess: false, childCommand: null, processIds: [] }; } if (platform === "win32") { return yield* windowsInspectSubprocess(terminalPid, platform); @@ -932,14 +982,26 @@ interface TerminalManagerOptions { subprocessPollIntervalMs?: number; processKillGraceMs?: number; maxRetainedInactiveSessions?: number; + registerTerminalProcesses?: (input: { + readonly threadId: string; + readonly terminalId: string; + readonly processIds: ReadonlyArray<number>; + }) => Effect.Effect<void>; + unregisterTerminal?: (input: { + readonly threadId: string; + readonly terminalId: string; + }) => Effect.Effect<void>; } const makeTerminalManager = Effect.fn("makeTerminalManager")(function* () { const { terminalLogsDir } = yield* ServerConfig; const ptyAdapter = yield* PtyAdapter; + const portDiscovery = yield* PortDiscovery; return yield* makeTerminalManagerWithOptions({ logsDir: terminalLogsDir, ptyAdapter, + registerTerminalProcesses: portDiscovery.registerTerminalProcesses, + unregisterTerminal: portDiscovery.unregisterTerminal, }); }); @@ -967,6 +1029,8 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith const processKillGraceMs = options.processKillGraceMs ?? DEFAULT_PROCESS_KILL_GRACE_MS; const maxRetainedInactiveSessions = options.maxRetainedInactiveSessions ?? DEFAULT_MAX_RETAINED_INACTIVE_SESSIONS; + const registerTerminalProcesses = options.registerTerminalProcesses ?? (() => Effect.void); + const unregisterTerminal = options.unregisterTerminal ?? (() => Effect.void); yield* fileSystem.makeDirectory(logsDir, { recursive: true }).pipe(Effect.orDie); @@ -1495,6 +1559,10 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith } yield* clearKillFiber(action.process); + yield* unregisterTerminal({ + threadId: action.threadId, + terminalId: action.terminalId, + }); yield* publishEvent({ type: "exited", threadId: action.threadId, @@ -1531,6 +1599,10 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith }); yield* clearKillFiber(process); + yield* unregisterTerminal({ + threadId: session.threadId, + terminalId: session.terminalId, + }); yield* startKillEscalation(process, session.threadId, session.terminalId); yield* evictInactiveSessionsIfNeeded(); }); @@ -1700,6 +1772,10 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith advanceEventSequence(session); return [undefined, state] as const; }); + yield* unregisterTerminal({ + threadId: session.threadId, + terminalId: session.terminalId, + }); yield* evictInactiveSessionsIfNeeded(); @@ -1731,6 +1807,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith if (Option.isSome(session)) { yield* stopProcess(session.value); + yield* unregisterTerminal({ threadId, terminalId }); yield* persistHistory(threadId, terminalId, session.value.history); } @@ -1791,6 +1868,11 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith } const next = inspectResult.value; + yield* registerTerminalProcesses({ + threadId: session.threadId, + terminalId: session.terminalId, + processIds: next.processIds, + }); const nextChildLabel = next.hasRunningSubprocess ? next.childCommand : null; const event = yield* modifyManagerState((state) => { const liveSession: Option.Option<TerminalSessionState> = Option.fromNullishOr( diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 900e80dfa15..04ac8f5a62b 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -72,7 +72,7 @@ import { ServerRuntimeStartup } from "./serverRuntimeStartup.ts"; import { redactServerSettingsForClient, ServerSettingsService } from "./serverSettings.ts"; import { TerminalManager } from "./terminal/Services/Manager.ts"; import { PreviewManager } from "./preview/Services/Manager.ts"; -import { PreviewPortScanner } from "./preview/Services/PortScanner.ts"; +import { PortDiscovery } from "./preview/Services/PortScanner.ts"; import { previewAutomationBroker } from "./mcp/Layers/PreviewAutomationBroker.ts"; import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries.ts"; import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem.ts"; @@ -257,7 +257,7 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; const terminalManager = yield* TerminalManager; const previewManager = yield* PreviewManager; - const previewPortScanner = yield* PreviewPortScanner; + const portDiscovery = yield* PortDiscovery; const providerRegistry = yield* ProviderRegistry; const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner; const config = yield* ServerConfig; @@ -1426,14 +1426,14 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => Stream.callback<DiscoveredLocalServerList>((queue) => Effect.acquireRelease( Effect.gen(function* () { - const release = yield* previewPortScanner.retain(); - const initial = yield* previewPortScanner.scan(); + const release = yield* portDiscovery.retain(); + const initial = yield* portDiscovery.scan(); const initialScannedAt = DateTime.formatIso(yield* DateTime.now); yield* Queue.offer(queue, { servers: initial, scannedAt: initialScannedAt, }); - const unsubscribe = yield* previewPortScanner.subscribe((servers) => + const unsubscribe = yield* portDiscovery.subscribe((servers) => Effect.gen(function* () { const scannedAt = DateTime.formatIso(yield* DateTime.now); yield* Queue.offer(queue, { servers, scannedAt }); diff --git a/apps/web/src/browser/ElectronBrowserHost.tsx b/apps/web/src/browser/ElectronBrowserHost.tsx index cc611a22993..feac8ed0f22 100644 --- a/apps/web/src/browser/ElectronBrowserHost.tsx +++ b/apps/web/src/browser/ElectronBrowserHost.tsx @@ -1,14 +1,18 @@ "use client"; import { parseScopedThreadKey } from "@t3tools/client-runtime"; -import { useMemo } from "react"; +import { useEffect, useMemo } from "react"; import { isElectron } from "~/env"; +import { useTheme } from "~/hooks/useTheme"; import { usePreviewStateStore } from "~/previewStateStore"; +import { readPreviewAnnotationTheme } from "./annotationTheme"; +import { useBrowserPointerStore } from "./browserPointerStore"; import { HostedBrowserWebview } from "./HostedBrowserWebview"; export function ElectronBrowserHost() { + const { resolvedTheme } = useTheme(); const previewByThreadKey = usePreviewStateStore((state) => state.byThreadKey); const sessions = useMemo( () => @@ -25,6 +29,47 @@ export function ElectronBrowserHost() { [previewByThreadKey], ); + useEffect(() => { + const preview = window.desktopBridge?.preview; + if (!preview) return; + + let lastSerializedTheme = ""; + const syncTheme = () => { + const theme = readPreviewAnnotationTheme(); + const serializedTheme = JSON.stringify(theme); + if (serializedTheme === lastSerializedTheme) return; + lastSerializedTheme = serializedTheme; + void preview.setAnnotationTheme(theme).catch(() => { + lastSerializedTheme = ""; + }); + }; + const frameId = window.requestAnimationFrame(syncTheme); + const observer = new MutationObserver(syncTheme); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class", "style"], + }); + const headObserver = new MutationObserver(syncTheme); + headObserver.observe(document.head, { + childList: true, + subtree: true, + characterData: true, + }); + return () => { + window.cancelAnimationFrame(frameId); + observer.disconnect(); + headObserver.disconnect(); + }; + }, [resolvedTheme]); + + useEffect(() => { + const preview = window.desktopBridge?.preview; + if (!preview) return; + return preview.onPointerEvent((event) => { + useBrowserPointerStore.getState().apply(event); + }); + }, []); + if (!isElectron) return null; return ( <div className="contents" data-electron-browser-host> diff --git a/apps/web/src/browser/annotationTheme.ts b/apps/web/src/browser/annotationTheme.ts new file mode 100644 index 00000000000..e12c667d23d --- /dev/null +++ b/apps/web/src/browser/annotationTheme.ts @@ -0,0 +1,28 @@ +import type { DesktopPreviewAnnotationTheme } from "@t3tools/contracts"; + +const readVariable = (styles: CSSStyleDeclaration, name: string, fallback: string): string => + styles.getPropertyValue(name).trim() || fallback; + +export function readPreviewAnnotationTheme(): DesktopPreviewAnnotationTheme { + const root = document.documentElement; + const styles = getComputedStyle(root); + return { + colorScheme: root.classList.contains("dark") ? "dark" : "light", + radius: readVariable(styles, "--radius", "0.625rem"), + background: readVariable(styles, "--background", "white"), + foreground: readVariable(styles, "--foreground", "oklch(0.269 0 0)"), + popover: readVariable(styles, "--popover", "white"), + popoverForeground: readVariable(styles, "--popover-foreground", "oklch(0.269 0 0)"), + primary: readVariable(styles, "--primary", "oklch(0.488 0.217 264)"), + primaryForeground: readVariable(styles, "--primary-foreground", "white"), + muted: readVariable(styles, "--muted", "rgb(0 0 0 / 4%)"), + mutedForeground: readVariable(styles, "--muted-foreground", "oklch(0.556 0 0)"), + accent: readVariable(styles, "--accent", "rgb(0 0 0 / 4%)"), + accentForeground: readVariable(styles, "--accent-foreground", "oklch(0.269 0 0)"), + border: readVariable(styles, "--border", "rgb(0 0 0 / 8%)"), + input: readVariable(styles, "--input", "rgb(0 0 0 / 10%)"), + ring: readVariable(styles, "--ring", "oklch(0.488 0.217 264)"), + fontSans: readVariable(styles, "--font-sans", styles.fontFamily || "system-ui, sans-serif"), + fontMono: readVariable(styles, "--font-mono", "ui-monospace, monospace"), + }; +} diff --git a/apps/web/src/browser/browserPointerStore.test.ts b/apps/web/src/browser/browserPointerStore.test.ts new file mode 100644 index 00000000000..de9c173dc2d --- /dev/null +++ b/apps/web/src/browser/browserPointerStore.test.ts @@ -0,0 +1,68 @@ +import { beforeEach, describe, expect, it } from "vite-plus/test"; + +import { useBrowserPointerStore } from "./browserPointerStore"; + +beforeEach(() => { + useBrowserPointerStore.setState({ byTabId: {} }); +}); + +describe("browserPointerStore", () => { + it("tracks the latest pointer target independently for each tab", () => { + const store = useBrowserPointerStore.getState(); + store.apply({ + tabId: "tab_a", + phase: "move", + x: 20, + y: 30, + sequence: 0, + createdAt: "2026-06-12T00:00:00.000Z", + }); + store.apply({ + tabId: "tab_b", + phase: "move", + x: 40, + y: 50, + sequence: 1, + createdAt: "2026-06-12T00:00:01.000Z", + }); + store.apply({ + tabId: "tab_a", + phase: "click", + x: 60, + y: 70, + sequence: 2, + createdAt: "2026-06-12T00:00:02.000Z", + }); + + expect(useBrowserPointerStore.getState().byTabId).toMatchObject({ + tab_a: { phase: "click", x: 60, y: 70, sequence: 2 }, + tab_b: { phase: "move", x: 40, y: 50, sequence: 1 }, + }); + }); + + it("clears one tab without affecting the others", () => { + const store = useBrowserPointerStore.getState(); + store.apply({ + tabId: "tab_a", + phase: "move", + x: 20, + y: 30, + sequence: 0, + createdAt: "2026-06-12T00:00:00.000Z", + }); + store.apply({ + tabId: "tab_b", + phase: "move", + x: 40, + y: 50, + sequence: 1, + createdAt: "2026-06-12T00:00:01.000Z", + }); + + store.clear("tab_a"); + + expect(useBrowserPointerStore.getState().byTabId).toEqual({ + tab_b: expect.objectContaining({ x: 40, y: 50 }), + }); + }); +}); diff --git a/apps/web/src/browser/browserPointerStore.ts b/apps/web/src/browser/browserPointerStore.ts new file mode 100644 index 00000000000..f9f905ddc8f --- /dev/null +++ b/apps/web/src/browser/browserPointerStore.ts @@ -0,0 +1,25 @@ +import type { DesktopPreviewPointerEvent } from "@t3tools/contracts"; +import { create } from "zustand"; + +interface BrowserPointerStoreState { + readonly byTabId: Record<string, DesktopPreviewPointerEvent>; + readonly apply: (event: DesktopPreviewPointerEvent) => void; + readonly clear: (tabId: string) => void; +} + +export const useBrowserPointerStore = create<BrowserPointerStoreState>()((set) => ({ + byTabId: {}, + apply: (event) => + set((state) => ({ + byTabId: { + ...state.byTabId, + [event.tabId]: event, + }, + })), + clear: (tabId) => + set((state) => { + if (!(tabId in state.byTabId)) return state; + const { [tabId]: _removed, ...byTabId } = state.byTabId; + return { byTabId }; + }), +})); diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index e1f43bda46f..249434a60d7 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -65,6 +65,7 @@ import { AppAtomRegistryProvider } from "../rpc/atomRegistry"; import { getServerConfig } from "../rpc/serverState"; import { getRouter } from "../router"; import { deriveLogicalProjectKeyFromSettings } from "../logicalProject"; +import { selectThreadRightPanelState, useRightPanelStore } from "../rightPanelStore"; import { selectBootstrapCompleteForActiveEnvironment, useStore } from "../store"; import { terminalSessionManager } from "../terminalSessionState"; import { useTerminalUiStateStore } from "../terminalUiStateStore"; @@ -1801,6 +1802,8 @@ describe("ChatView timeline estimator parity (full app)", () => { useTerminalUiStateStore.setState({ terminalUiStateByThreadKey: {}, }); + useRightPanelStore.persist.clearStorage(); + useRightPanelStore.setState({ byThreadKey: {} }); }); afterEach(() => { @@ -2054,12 +2057,15 @@ describe("ChatView timeline estimator parity (full app)", () => { }); try { - const toggle = await waitForElement( - () => - document.querySelector<HTMLButtonElement>('button[aria-label="Toggle terminal drawer"]'), - "Unable to find terminal drawer toggle.", + window.dispatchEvent( + new KeyboardEvent("keydown", { + key: "j", + metaKey: isMacPlatform(navigator.platform), + ctrlKey: !isMacPlatform(navigator.platform), + bubbles: true, + cancelable: true, + }), ); - toggle.click(); await vi.waitFor( () => { @@ -2072,9 +2078,136 @@ describe("ChatView timeline estimator parity (full app)", () => { terminalId: DEFAULT_TERMINAL_ID, cwd: "/repo/project", }); + expect( + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, THREAD_REF) + .isOpen, + ).toBe(false); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + + it("keeps multiple terminal panel surfaces separate from the bottom drawer", async () => { + const mounted = await mountChatView({ + viewport: WIDE_FOOTER_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-open-inline-terminal-panel" as MessageId, + targetText: "open inline terminal panel", + }), + }); + + try { + window.dispatchEvent( + new KeyboardEvent("keydown", { + key: "b", + altKey: true, + metaKey: isMacPlatform(navigator.platform), + ctrlKey: !isMacPlatform(navigator.platform), + bubbles: true, + cancelable: true, + }), + ); + + const addSurface = await waitForElement( + () => document.querySelector<HTMLButtonElement>('button[aria-label="Add panel surface"]'), + "Unable to find add panel surface button.", + ); + expect(document.body.textContent).toContain("Open a surface"); + expect( + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, THREAD_REF), + ).toEqual({ + isOpen: true, + activeSurfaceId: null, + surfaces: [], + }); + expect(wsRequests.some((request) => request._tag === WS_METHODS.terminalOpen)).toBe(false); + + addSurface.click(); + + const terminalItem = await waitForElement( + () => + Array.from(document.querySelectorAll<HTMLElement>('[role="menuitem"]')).find( + (item) => item.textContent?.trim() === "Terminal", + ) ?? null, + "Unable to find Terminal panel menu item.", + ); + terminalItem.click(); + + await vi.waitFor(() => { + expect( + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, THREAD_REF) + .surfaces.filter((surface) => surface.kind === "terminal") + .map((surface) => surface.resourceId), + ).toEqual(["term-1"]); + }); + + addSurface.click(); + const secondTerminalItem = await waitForElement( + () => + Array.from(document.querySelectorAll<HTMLElement>('[role="menuitem"]')).find( + (item) => item.textContent?.trim() === "Terminal", + ) ?? null, + "Unable to find Terminal panel menu item.", + ); + secondTerminalItem.click(); + + await vi.waitFor( + () => { + expect( + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, THREAD_REF) + .surfaces.filter((surface) => surface.kind === "terminal") + .map((surface) => surface.resourceId), + ).toEqual(["term-1", "term-2"]); + expect( + document.querySelector('[data-preview-panel-mode="inline"] .thread-terminal-drawer'), + ).not.toBeNull(); + expect( + wsRequests + .filter((request) => request._tag === WS_METHODS.terminalOpen) + .map((request) => ("terminalId" in request ? request.terminalId : null)), + ).toEqual(expect.arrayContaining(["term-1", "term-2"])); + const attachRequest = wsRequests.find( + (request) => + request._tag === WS_METHODS.terminalAttach && + "terminalId" in request && + request.terminalId === "term-2", + ); + expect(attachRequest).toMatchObject({ + _tag: WS_METHODS.terminalAttach, + threadId: THREAD_ID, + terminalId: "term-2", + cwd: "/repo/project", + }); }, { timeout: 8_000, interval: 16 }, ); + + const drawerToggle = await waitForElement( + () => + document.querySelector<HTMLButtonElement>('button[aria-label="Toggle terminal drawer"]'), + "Unable to find terminal drawer toggle.", + ); + drawerToggle.click(); + + await vi.waitFor(() => { + expect( + useTerminalUiStateStore.getState().terminalUiStateByThreadKey[THREAD_KEY], + ).toMatchObject({ + terminalOpen: true, + terminalIds: ["term-3"], + }); + expect( + wsRequests.some( + (request) => + request._tag === WS_METHODS.terminalAttach && + "terminalId" in request && + request.terminalId === "term-3", + ), + ).toBe(true); + }); } finally { await mounted.cleanup(); } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 0bda0fb7edd..b2686f59c70 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -103,6 +103,7 @@ import { usePreviewStateStore, } from "../previewStateStore"; import { subscribePreviewAction } from "./preview/previewActionBus"; +import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic"; // Lazy: keeps the entire preview component graph (webview host, favicon // helper, Chromium error icon) out of the web bundle until first open. const PreviewPanel = lazy(() => @@ -129,7 +130,7 @@ import { newCommandId, newDraftId, newMessageId, newThreadId } from "~/lib/utils import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; import { useSettings } from "../hooks/useSettings"; import { resolveAppModelSelectionForInstance } from "../modelSelection"; -import { isTerminalFocused } from "../lib/terminalFocus"; +import { getTerminalFocusOwner } from "../lib/terminalFocus"; import { deriveLogicalProjectKeyFromSettings, selectProjectGroupingSettings, @@ -539,6 +540,7 @@ interface PersistentThreadTerminalDrawerProps { launchContext: PersistentTerminalLaunchContext | null; focusRequestId: number; splitShortcutLabel: string | undefined; + splitVerticalShortcutLabel: string | undefined; newShortcutLabel: string | undefined; closeShortcutLabel: string | undefined; keybindings: ResolvedKeybindingsConfig; @@ -553,6 +555,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra launchContext, focusRequestId, splitShortcutLabel, + splitVerticalShortcutLabel, newShortcutLabel, closeShortcutLabel, keybindings, @@ -573,16 +576,33 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra environmentId: threadRef.environmentId, threadId, }); + const panelSurfaces = useRightPanelStore( + (state) => selectThreadRightPanelState(state.byThreadKey, threadRef).surfaces, + ); + const panelTerminalIds = useMemo( + () => + new Set( + panelSurfaces.flatMap((surface) => + surface.kind === "terminal" ? surface.terminalIds : [], + ), + ), + [panelSurfaces], + ); + const drawerTerminalSessions = useMemo( + () => + knownTerminalSessions.filter((session) => !panelTerminalIds.has(session.target.terminalId)), + [knownTerminalSessions, panelTerminalIds], + ); const terminalLabelsById = useMemo(() => { const next = new Map<string, string>(); - for (const session of knownTerminalSessions) { + for (const session of drawerTerminalSessions) { next.set( session.target.terminalId, resolveTerminalSessionLabel(session.target.terminalId, session.state.summary), ); } return next; - }, [knownTerminalSessions]); + }, [drawerTerminalSessions]); const terminalLaunchLocationsById = useMemo(() => { const next = new Map< string, @@ -596,7 +616,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra return next; } - for (const session of knownTerminalSessions) { + for (const session of drawerTerminalSessions) { const summary = session.state.summary; if (!summary) { continue; @@ -614,13 +634,16 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra } return next; - }, [knownTerminalSessions, launchContext, project]); + }, [drawerTerminalSessions, launchContext, project]); const serverOrderedTerminalIds = useMemo( - () => knownTerminalSessions.map((session) => session.target.terminalId), - [knownTerminalSessions], + () => drawerTerminalSessions.map((session) => session.target.terminalId), + [drawerTerminalSessions], ); const storeSetTerminalHeight = useTerminalUiStateStore((state) => state.setTerminalHeight); const storeSplitTerminal = useTerminalUiStateStore((state) => state.splitTerminal); + const storeSplitTerminalVertical = useTerminalUiStateStore( + (state) => state.splitTerminalVertical, + ); const storeNewTerminal = useTerminalUiStateStore((state) => state.newTerminal); const storeSetActiveTerminal = useTerminalUiStateStore((state) => state.setActiveTerminal); const storeCloseTerminal = useTerminalUiStateStore((state) => state.closeTerminal); @@ -712,6 +735,33 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra threadId, threadRef, ]); + const splitTerminalVertical = useCallback(() => { + const api = readEnvironmentApi(threadRef.environmentId); + if (!api || !cwd) { + return; + } + const terminalId = nextTerminalId(serverOrderedTerminalIds); + storeSplitTerminalVertical(threadRef, terminalId); + bumpFocusRequestId(); + void api.terminal + .open({ + threadId, + terminalId, + cwd, + ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), + env: runtimeEnv, + }) + .catch(() => undefined); + }, [ + bumpFocusRequestId, + cwd, + effectiveWorktreePath, + runtimeEnv, + serverOrderedTerminalIds, + storeSplitTerminalVertical, + threadId, + threadRef, + ]); const createNewTerminal = useCallback(() => { const api = readEnvironmentApi(threadRef.environmentId); @@ -814,8 +864,10 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra activeTerminalGroupId={terminalUiState.activeTerminalGroupId} focusRequestId={focusRequestId + localFocusRequestId + (visible ? 1 : 0)} onSplitTerminal={splitTerminal} + onSplitTerminalVertical={splitTerminalVertical} onNewTerminal={createNewTerminal} splitShortcutLabel={visible ? splitShortcutLabel : undefined} + splitVerticalShortcutLabel={visible ? splitVerticalShortcutLabel : undefined} newShortcutLabel={visible ? newShortcutLabel : undefined} closeShortcutLabel={visible ? closeShortcutLabel : undefined} keybindings={keybindings} @@ -830,6 +882,177 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra ); }); +interface PersistentThreadTerminalPanelProps { + threadRef: { environmentId: EnvironmentId; threadId: ThreadId }; + surface: Extract<RightPanelSurface, { kind: "terminal" }>; + launchContext: PersistentTerminalLaunchContext | null; + focusRequestId: number; + keybindings: ResolvedKeybindingsConfig; + onAddTerminalContext: (selection: TerminalContextSelection) => void; + onSplitTerminal: () => void; + onSplitTerminalVertical: () => void; + onNewTerminal: () => void; + onActiveTerminalChange: (terminalId: string) => void; + onCloseTerminal: (terminalId: string) => void; + splitShortcutLabel?: string | undefined; + splitVerticalShortcutLabel?: string | undefined; + newShortcutLabel?: string | undefined; + closeShortcutLabel?: string | undefined; +} + +const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPanel({ + threadRef, + surface, + launchContext, + focusRequestId, + keybindings, + onAddTerminalContext, + onSplitTerminal, + onSplitTerminalVertical, + onNewTerminal, + onActiveTerminalChange, + onCloseTerminal, + splitShortcutLabel, + splitVerticalShortcutLabel, + newShortcutLabel, + closeShortcutLabel, +}: PersistentThreadTerminalPanelProps) { + const serverThread = useStore(useMemo(() => createThreadSelectorByRef(threadRef), [threadRef])); + const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef)); + const projectRef = serverThread + ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) + : draftThread + ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) + : null; + const project = useStore(useMemo(() => createProjectSelectorByRef(projectRef), [projectRef])); + const knownTerminalSessions = useKnownTerminalSessions({ + environmentId: threadRef.environmentId, + threadId: threadRef.threadId, + }); + const terminalSummary = + knownTerminalSessions.find((session) => session.target.terminalId === surface.activeTerminalId) + ?.state.summary ?? null; + const threadWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; + const worktreePath = + launchContext?.worktreePath ?? terminalSummary?.worktreePath ?? threadWorktreePath; + const cwd = useMemo( + () => + launchContext?.cwd ?? + terminalSummary?.cwd ?? + (project + ? projectScriptCwd({ + project: { cwd: project.cwd }, + worktreePath, + }) + : null), + [launchContext?.cwd, project, terminalSummary?.cwd, worktreePath], + ); + const runtimeEnv = useMemo( + () => + project + ? projectScriptRuntimeEnv({ + project: { cwd: project.cwd }, + worktreePath, + }) + : {}, + [project, worktreePath], + ); + const terminalLabelsById = useMemo(() => { + const labels = new Map<string, string>(); + for (const terminalId of surface.terminalIds) { + const summary = + knownTerminalSessions.find((session) => session.target.terminalId === terminalId)?.state + .summary ?? null; + labels.set(terminalId, resolveTerminalSessionLabel(terminalId, summary)); + } + return labels; + }, [knownTerminalSessions, surface.terminalIds]); + const terminalLaunchLocationsById = useMemo(() => { + const locations = new Map< + string, + { + readonly cwd: string; + readonly worktreePath: string | null; + readonly runtimeEnv: Record<string, string>; + } + >(); + for (const terminalId of surface.terminalIds) { + const summary = + knownTerminalSessions.find((session) => session.target.terminalId === terminalId)?.state + .summary ?? null; + const terminalWorktreePath = + launchContext?.worktreePath ?? summary?.worktreePath ?? threadWorktreePath; + const terminalCwd = + launchContext?.cwd ?? + summary?.cwd ?? + (project + ? projectScriptCwd({ + project: { cwd: project.cwd }, + worktreePath: terminalWorktreePath, + }) + : null); + if (!terminalCwd || !project) continue; + locations.set(terminalId, { + cwd: terminalCwd, + worktreePath: terminalWorktreePath, + runtimeEnv: projectScriptRuntimeEnv({ + project: { cwd: project.cwd }, + worktreePath: terminalWorktreePath, + }), + }); + } + return locations; + }, [ + knownTerminalSessions, + launchContext?.cwd, + launchContext?.worktreePath, + project, + surface.terminalIds, + threadWorktreePath, + ]); + + if (!project || !cwd) { + return null; + } + + return ( + <ThreadTerminalDrawer + mode="panel" + threadRef={threadRef} + threadId={threadRef.threadId} + cwd={cwd} + worktreePath={worktreePath} + runtimeEnv={runtimeEnv} + height={0} + terminalIds={surface.terminalIds} + activeTerminalId={surface.activeTerminalId} + terminalGroups={[ + { + id: surface.id, + terminalIds: surface.terminalIds, + ...(surface.splitDirection === "vertical" ? { splitDirection: "vertical" as const } : {}), + }, + ]} + activeTerminalGroupId={surface.id} + focusRequestId={focusRequestId} + onSplitTerminal={onSplitTerminal} + onSplitTerminalVertical={onSplitTerminalVertical} + onNewTerminal={onNewTerminal} + splitShortcutLabel={splitShortcutLabel} + splitVerticalShortcutLabel={splitVerticalShortcutLabel} + newShortcutLabel={newShortcutLabel} + closeShortcutLabel={closeShortcutLabel} + onActiveTerminalChange={onActiveTerminalChange} + onCloseTerminal={onCloseTerminal} + onHeightChange={() => undefined} + onAddTerminalContext={onAddTerminalContext} + terminalLabelsById={terminalLabelsById} + terminalLaunchLocationsById={terminalLaunchLocationsById} + keybindings={keybindings} + /> + ); +}); + export default function ChatView(props: ChatViewProps) { const { environmentId, @@ -967,6 +1190,7 @@ export default function ChatView(props: ChatViewProps) { ); const storeSetTerminalOpen = useTerminalUiStateStore((s) => s.setTerminalOpen); const storeSplitTerminal = useTerminalUiStateStore((s) => s.splitTerminal); + const storeSplitTerminalVertical = useTerminalUiStateStore((s) => s.splitTerminalVertical); const storeNewTerminal = useTerminalUiStateStore((s) => s.newTerminal); const storeSetActiveTerminal = useTerminalUiStateStore((s) => s.setActiveTerminal); const storeCloseTerminal = useTerminalUiStateStore((s) => s.closeTerminal); @@ -1029,50 +1253,73 @@ export default function ChatView(props: ChatViewProps) { () => [...new Set([...activeServerOrderedTerminalIds, ...terminalUiState.terminalIds])], [activeServerOrderedTerminalIds, terminalUiState.terminalIds], ); + const activeTerminalLabelsById = useMemo(() => { + const next = new Map<string, string>(); + for (const session of activeThreadKnownSessions) { + next.set( + session.target.terminalId, + resolveTerminalSessionLabel(session.target.terminalId, session.state.summary), + ); + } + return next; + }, [activeThreadKnownSessions]); const reconcileTerminalIds = useTerminalUiStateStore((state) => state.reconcileTerminalIds); const activeThreadRef = useMemo( () => (activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null), [activeThread], ); const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; + const activeRightPanelKind = useRightPanelStore((store) => + selectActiveRightPanelKindWithUrl(store.byThreadKey, activeThreadRef, diffOpen), + ); + const rightPanelState = useRightPanelStore((store) => + selectThreadRightPanelState(store.byThreadKey, activeThreadRef), + ); + const activeRightPanelSurface = useRightPanelStore((store) => + selectActiveRightPanelSurface(store.byThreadKey, activeThreadRef), + ); + const activePreviewState = usePreviewStateStore((state) => + selectThreadPreviewState(state.byThreadKey, activeThreadRef), + ); + const panelTerminalIds = useMemo( + () => + new Set( + rightPanelState.surfaces.flatMap((surface) => + surface.kind === "terminal" ? surface.terminalIds : [], + ), + ), + [rightPanelState.surfaces], + ); + const drawerServerOrderedTerminalIds = useMemo( + () => activeServerOrderedTerminalIds.filter((terminalId) => !panelTerminalIds.has(terminalId)), + [activeServerOrderedTerminalIds, panelTerminalIds], + ); useEffect(() => { if (!activeThreadRef) { return; } - if (terminalIdListsEqual(activeServerOrderedTerminalIds, terminalUiState.terminalIds)) { + if (terminalIdListsEqual(drawerServerOrderedTerminalIds, terminalUiState.terminalIds)) { return; } if ( serverTerminalIdsStrictSubsetOfClient( - activeServerOrderedTerminalIds, + drawerServerOrderedTerminalIds, terminalUiState.terminalIds, ) ) { return; } - reconcileTerminalIds(activeThreadRef, activeServerOrderedTerminalIds); + reconcileTerminalIds(activeThreadRef, drawerServerOrderedTerminalIds); }, [ activeThreadRef, - activeServerOrderedTerminalIds, + drawerServerOrderedTerminalIds, reconcileTerminalIds, terminalUiState.terminalIds, ]); - const activeRightPanelKind = useRightPanelStore((store) => - selectActiveRightPanelKindWithUrl(store.byThreadKey, activeThreadRef, diffOpen), - ); - const rightPanelState = useRightPanelStore((store) => - selectThreadRightPanelState(store.byThreadKey, activeThreadRef), - ); - const activeRightPanelSurface = useRightPanelStore((store) => - selectActiveRightPanelSurface(store.byThreadKey, activeThreadRef), - ); - const activePreviewState = usePreviewStateStore((state) => - selectThreadPreviewState(state.byThreadKey, activeThreadRef), - ); const planSidebarOpen = activeRightPanelKind === "plan"; const previewPanelOpen = activeRightPanelKind === "preview" && isPreviewSupportedInRuntime(); - const terminalPanelOpen = activeRightPanelKind === "terminal"; - const rightPanelOpen = activeRightPanelSurface !== null; + const rightPanelOpen = rightPanelState.isOpen; + const inlineRightPanelOwnsTitleBar = rightPanelOpen && !shouldUsePlanSidebarSheet; useEffect(() => { if (!activeThreadRef) return; @@ -1085,15 +1332,6 @@ export default function ChatView(props: ChatViewProps) { if (!activeThreadRef || !diffOpen) return; useRightPanelStore.getState().open(activeThreadRef, "diff"); }, [activeThreadRef, diffOpen]); - useEffect(() => { - if (!activeThreadRef || !terminalUiState.terminalOpen) return; - const state = selectThreadRightPanelState( - useRightPanelStore.getState().byThreadKey, - activeThreadRef, - ); - if (state.surfaces.some((surface) => surface.kind === "terminal")) return; - useRightPanelStore.getState().open(activeThreadRef, "terminal"); - }, [activeThreadRef, terminalUiState.terminalOpen]); const activeLatestTurn = activeThread?.latestTurn ?? null; const threadPlanCatalog = useThreadPlanCatalog( useMemo(() => { @@ -1115,6 +1353,10 @@ export default function ChatView(props: ChatViewProps) { const activeProject = useStore( useMemo(() => createProjectSelectorByRef(activeProjectRef), [activeProjectRef]), ); + const configuredPreviewUrls = useMemo( + () => getConfiguredPreviewUrls(activeProject?.scripts), + [activeProject?.scripts], + ); useEffect(() => { if (routeKind !== "server") { @@ -1906,6 +2148,11 @@ export default function ChatView(props: ChatViewProps) { () => shortcutLabelForCommand(keybindings, "terminal.split", terminalShortcutLabelOptions), [keybindings, terminalShortcutLabelOptions], ); + const splitTerminalVerticalShortcutLabel = useMemo( + () => + shortcutLabelForCommand(keybindings, "terminal.splitVertical", terminalShortcutLabelOptions), + [keybindings, terminalShortcutLabelOptions], + ); const newTerminalShortcutLabel = useMemo( () => shortcutLabelForCommand(keybindings, "terminal.new", terminalShortcutLabelOptions), [keybindings, terminalShortcutLabelOptions], @@ -1988,7 +2235,8 @@ export default function ChatView(props: ChatViewProps) { if (!isServerThread) { return; } - if (!diffOpen) { + const diffPanelOpen = activeRightPanelKind === "diff"; + if (!diffPanelOpen) { onDiffPanelOpen?.(); if (activeThreadRef) { useRightPanelStore.getState().open(activeThreadRef, "diff"); @@ -2005,12 +2253,12 @@ export default function ChatView(props: ChatViewProps) { replace: true, search: (previous) => { const rest = stripDiffSearchParams(previous); - return diffOpen ? { ...rest, diff: undefined } : { ...rest, diff: "1" }; + return diffPanelOpen ? { ...rest, diff: undefined } : { ...rest, diff: "1" }; }, }); }, [ + activeRightPanelKind, activeThreadRef, - diffOpen, environmentId, isServerThread, navigate, @@ -2105,61 +2353,184 @@ export default function ChatView(props: ChatViewProps) { const setTerminalOpen = useCallback( (open: boolean) => { if (!activeThreadRef) return; + if (open && terminalUiState.terminalIds.length === 0) { + storeNewTerminal( + activeThreadRef, + nextTerminalId([...activeKnownTerminalIds, ...panelTerminalIds]), + ); + return; + } storeSetTerminalOpen(activeThreadRef, open); }, - [activeThreadRef, storeSetTerminalOpen], + [ + activeKnownTerminalIds, + activeThreadRef, + panelTerminalIds, + storeNewTerminal, + storeSetTerminalOpen, + terminalUiState.terminalIds.length, + ], ); - const toggleTerminalVisibility = useCallback(() => { - if (!activeThreadRef) return; - if (terminalPanelOpen) { - useRightPanelStore.getState().close(activeThreadRef); - return; - } - setTerminalOpen(true); - useRightPanelStore.getState().open(activeThreadRef, "terminal"); - }, [activeThreadRef, setTerminalOpen, terminalPanelOpen]); - const splitTerminal = useCallback(() => { - if (!activeThreadRef || hasReachedSplitLimit || !activeThreadId || !activeProject) { - return; - } - const cwdForOpen = gitCwd ?? activeProject.cwd; - if (!cwdForOpen) { - return; - } - const api = readEnvironmentApi(environmentId); - if (!api) { - return; - } - const terminalId = nextTerminalId(activeKnownTerminalIds); - storeSplitTerminal(activeThreadRef, terminalId); + const addTerminalSurface = useCallback(() => { + if (!activeThreadRef || !activeThreadId || !activeProject) return; + const api = readEnvironmentApi(activeThreadRef.environmentId); + const cwd = gitCwd ?? activeProject.cwd; + if (!api || !cwd) return; + const panelIds = selectThreadRightPanelState( + useRightPanelStore.getState().byThreadKey, + activeThreadRef, + ).surfaces.flatMap((surface) => (surface.kind === "terminal" ? surface.terminalIds : [])); + const terminalId = nextTerminalId([...activeKnownTerminalIds, ...panelIds]); + useRightPanelStore.getState().openTerminal(activeThreadRef, terminalId); setTerminalFocusRequestId((value) => value + 1); - void (async () => { - try { - await api.terminal.open({ + void api.terminal + .open({ + threadId: activeThreadId, + terminalId, + cwd, + ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}), + env: projectScriptRuntimeEnv({ + project: { cwd: activeProject.cwd }, + worktreePath: activeThreadWorktreePath, + }), + }) + .catch(() => undefined); + }, [ + activeKnownTerminalIds, + activeProject, + activeThreadId, + activeThreadRef, + activeThreadWorktreePath, + gitCwd, + ]); + const splitPanelTerminal = useCallback( + (direction: "horizontal" | "vertical" = "horizontal") => { + if ( + !activeThreadRef || + !activeThreadId || + !activeProject || + activeRightPanelSurface?.kind !== "terminal" || + activeRightPanelSurface.terminalIds.length >= MAX_TERMINALS_PER_GROUP + ) { + return; + } + const api = readEnvironmentApi(activeThreadRef.environmentId); + const cwd = gitCwd ?? activeProject.cwd; + if (!api || !cwd) return; + const terminalId = nextTerminalId([...activeKnownTerminalIds, ...panelTerminalIds]); + useRightPanelStore + .getState() + .splitTerminal(activeThreadRef, activeRightPanelSurface.id, terminalId, direction); + setTerminalFocusRequestId((value) => value + 1); + void api.terminal + .open({ threadId: activeThreadId, terminalId, - cwd: cwdForOpen, + cwd, ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}), env: projectScriptRuntimeEnv({ project: { cwd: activeProject.cwd }, worktreePath: activeThreadWorktreePath, }), - }); - } catch { - // Opening failed; the tab is already in the store — user can retry or close it. + }) + .catch(() => undefined); + }, + [ + activeKnownTerminalIds, + activeProject, + activeRightPanelSurface, + activeThreadId, + activeThreadRef, + activeThreadWorktreePath, + gitCwd, + panelTerminalIds, + ], + ); + const splitPanelTerminalVertical = useCallback(() => { + splitPanelTerminal("vertical"); + }, [splitPanelTerminal]); + const activatePanelTerminal = useCallback( + (terminalId: string) => { + if (!activeThreadRef || activeRightPanelSurface?.kind !== "terminal") return; + useRightPanelStore + .getState() + .activateTerminal(activeThreadRef, activeRightPanelSurface.id, terminalId); + setTerminalFocusRequestId((value) => value + 1); + }, + [activeRightPanelSurface, activeThreadRef], + ); + const closePanelTerminal = useCallback( + (terminalId: string) => { + if (!activeThreadRef || activeRightPanelSurface?.kind !== "terminal") return; + const api = readEnvironmentApi(activeThreadRef.environmentId); + void api?.terminal + .close({ + threadId: activeThreadRef.threadId, + terminalId, + deleteHistory: true, + }) + .catch(() => undefined); + useRightPanelStore + .getState() + .closeTerminal(activeThreadRef, activeRightPanelSurface.id, terminalId); + setTerminalFocusRequestId((value) => value + 1); + }, + [activeRightPanelSurface, activeThreadRef], + ); + const toggleTerminalVisibility = useCallback(() => { + if (!activeThreadRef) return; + setTerminalOpen(!terminalUiState.terminalOpen); + }, [activeThreadRef, setTerminalOpen, terminalUiState.terminalOpen]); + const splitTerminal = useCallback( + (direction: "horizontal" | "vertical" = "horizontal") => { + if (!activeThreadRef || hasReachedSplitLimit || !activeThreadId || !activeProject) { + return; } - })(); - }, [ - activeProject, - activeKnownTerminalIds, - activeThreadId, - activeThreadRef, - activeThreadWorktreePath, - environmentId, - gitCwd, - hasReachedSplitLimit, - storeSplitTerminal, - ]); + const cwdForOpen = gitCwd ?? activeProject.cwd; + if (!cwdForOpen) { + return; + } + const api = readEnvironmentApi(environmentId); + if (!api) { + return; + } + const terminalId = nextTerminalId(activeKnownTerminalIds); + if (direction === "vertical") { + storeSplitTerminalVertical(activeThreadRef, terminalId); + } else { + storeSplitTerminal(activeThreadRef, terminalId); + } + setTerminalFocusRequestId((value) => value + 1); + void (async () => { + try { + await api.terminal.open({ + threadId: activeThreadId, + terminalId, + cwd: cwdForOpen, + ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}), + env: projectScriptRuntimeEnv({ + project: { cwd: activeProject.cwd }, + worktreePath: activeThreadWorktreePath, + }), + }); + } catch { + // Opening failed; the tab is already in the store — user can retry or close it. + } + })(); + }, + [ + activeProject, + activeKnownTerminalIds, + activeThreadId, + activeThreadRef, + activeThreadWorktreePath, + environmentId, + gitCwd, + hasReachedSplitLimit, + storeSplitTerminal, + storeSplitTerminalVertical, + ], + ); const createNewTerminal = useCallback(() => { if (!activeThreadRef || !activeThreadId || !activeProject) { return; @@ -2552,15 +2923,7 @@ export default function ChatView(props: ChatViewProps) { const closePreviewPanel = useCallback(() => { if (!activeThreadRef) return; useRightPanelStore.getState().close(activeThreadRef); - if (diffOpen) { - void navigate({ - to: "/$environmentId/$threadId", - params: { environmentId, threadId }, - replace: true, - search: (previous) => ({ ...stripDiffSearchParams(previous), diff: undefined }), - }); - } - }, [activeThreadRef, diffOpen, environmentId, navigate, threadId]); + }, [activeThreadRef]); const activateRightPanelSurface = useCallback( (surface: RightPanelSurface) => { if (!activeThreadRef) return; @@ -2569,7 +2932,7 @@ export default function ChatView(props: ChatViewProps) { usePreviewStateStore.getState().setActiveTab(activeThreadRef, surface.resourceId); } if (surface.kind === "terminal") { - setTerminalOpen(true); + setTerminalFocusRequestId((value) => value + 1); } if (surface.kind === "diff" && !diffOpen) { onDiffPanelOpen?.(); @@ -2588,48 +2951,41 @@ export default function ChatView(props: ChatViewProps) { }); } }, - [ - activeThreadRef, - diffOpen, - environmentId, - navigate, - onDiffPanelOpen, - setTerminalOpen, - threadId, - ], + [activeThreadRef, diffOpen, environmentId, navigate, onDiffPanelOpen, threadId], ); const toggleRightPanel = useCallback(() => { if (!activeThreadRef) return; - if (rightPanelOpen) { - closePreviewPanel(); - return; - } - const surface = rightPanelState.surfaces.at(-1) ?? null; - if (surface) { - activateRightPanelSurface(surface); - return; - } - createBrowserSurface(); - }, [ - activeThreadRef, - activateRightPanelSurface, - closePreviewPanel, - createBrowserSurface, - rightPanelOpen, - rightPanelState.surfaces, - ]); + useRightPanelStore.getState().toggleVisibility(activeThreadRef); + }, [activeThreadRef]); const closeRightPanelSurface = useCallback( (surface: RightPanelSurface) => { if (!activeThreadRef) return; - useRightPanelStore.getState().closeSurface(activeThreadRef, surface.id); if (surface.kind === "preview" && surface.resourceId) { + usePreviewStateStore.getState().removeSession(activeThreadRef, surface.resourceId); const api = readEnvironmentApi(activeThreadRef.environmentId); void api?.preview .close({ threadId: activeThreadRef.threadId, tabId: surface.resourceId }) .catch(() => undefined); } + useRightPanelStore.getState().closeSurface(activeThreadRef, surface.id); + const nextActiveSurface = selectActiveRightPanelSurface( + useRightPanelStore.getState().byThreadKey, + activeThreadRef, + ); + if (nextActiveSurface?.kind === "preview" && nextActiveSurface.resourceId) { + usePreviewStateStore.getState().setActiveTab(activeThreadRef, nextActiveSurface.resourceId); + } if (surface.kind === "terminal") { - setTerminalOpen(false); + const api = readEnvironmentApi(activeThreadRef.environmentId); + for (const terminalId of surface.terminalIds) { + void api?.terminal + .close({ + threadId: activeThreadRef.threadId, + terminalId, + deleteHistory: true, + }) + .catch(() => undefined); + } } if (surface.kind === "diff" && diffOpen) { void navigate({ @@ -2640,7 +2996,7 @@ export default function ChatView(props: ChatViewProps) { }); } }, - [activeThreadRef, diffOpen, environmentId, navigate, setTerminalOpen, threadId], + [activeThreadRef, diffOpen, environmentId, navigate, threadId], ); const persistThreadSettingsForNextTurn = useCallback( async (input: { @@ -2920,8 +3276,9 @@ export default function ChatView(props: ChatViewProps) { if (!activeThreadId || useCommandPaletteStore.getState().open || event.defaultPrevented) { return; } + const terminalFocusOwner = getTerminalFocusOwner(); const shortcutContext = { - terminalFocus: isTerminalFocused(), + terminalFocus: terminalFocusOwner !== null, terminalOpen: Boolean(terminalUiState.terminalOpen), modelPickerOpen: composerRef.current?.isModelPickerOpen() ?? false, }; @@ -2950,9 +3307,20 @@ export default function ChatView(props: ChatViewProps) { return; } + if (command === "rightPanel.toggle") { + event.preventDefault(); + event.stopPropagation(); + toggleRightPanel(); + return; + } + if (command === "terminal.split") { event.preventDefault(); event.stopPropagation(); + if (terminalFocusOwner === "right-panel") { + splitPanelTerminal(); + return; + } if (!terminalUiState.terminalOpen) { setTerminalOpen(true); } @@ -2960,9 +3328,27 @@ export default function ChatView(props: ChatViewProps) { return; } + if (command === "terminal.splitVertical") { + event.preventDefault(); + event.stopPropagation(); + if (terminalFocusOwner === "right-panel") { + splitPanelTerminal("vertical"); + return; + } + if (!terminalUiState.terminalOpen) { + setTerminalOpen(true); + } + splitTerminal("vertical"); + return; + } + if (command === "terminal.close") { event.preventDefault(); event.stopPropagation(); + if (terminalFocusOwner === "right-panel" && activeRightPanelSurface?.kind === "terminal") { + closePanelTerminal(activeRightPanelSurface.activeTerminalId); + return; + } if (!terminalUiState.terminalOpen) return; closeTerminal(terminalUiState.activeTerminalId); return; @@ -2971,6 +3357,10 @@ export default function ChatView(props: ChatViewProps) { if (command === "terminal.new") { event.preventDefault(); event.stopPropagation(); + if (terminalFocusOwner === "right-panel") { + addTerminalSurface(); + return; + } if (!terminalUiState.terminalOpen) { setTerminalOpen(true); } @@ -3003,17 +3393,22 @@ export default function ChatView(props: ChatViewProps) { window.addEventListener("keydown", handler, true); return () => window.removeEventListener("keydown", handler, true); }, [ + activeRightPanelSurface, activeProject, + addTerminalSurface, terminalUiState.terminalOpen, terminalUiState.activeTerminalId, activeThreadId, + closePanelTerminal, closeTerminal, createNewTerminal, setTerminalOpen, runProjectScript, splitTerminal, + splitPanelTerminal, keybindings, onToggleDiff, + toggleRightPanel, toggleTerminalVisibility, composerRef, ]); @@ -4026,6 +4421,7 @@ export default function ChatView(props: ChatViewProps) { ? cn( "drag-region flex h-[52px] items-center px-3 sm:px-5 wco:h-[env(titlebar-area-height)]", reserveTitleBarControlInset && + !inlineRightPanelOwnsTitleBar && "wco:pr-[calc(100vw-env(titlebar-area-width)-env(titlebar-area-x)+1em)]", ) : "pb-2 pl-[calc(env(safe-area-inset-left)+0.75rem)] pr-[calc(env(safe-area-inset-right)+0.75rem)] pt-2 sm:pb-3 sm:pl-[calc(env(safe-area-inset-left)+1.25rem)] sm:pr-[calc(env(safe-area-inset-right)+1.25rem)] sm:pt-3", @@ -4044,7 +4440,7 @@ export default function ChatView(props: ChatViewProps) { } keybindings={keybindings} availableEditors={availableEditors} - rightPanelAvailable={isPreviewSupportedInRuntime() || isGitRepo} + rightPanelAvailable={Boolean(activeProject)} rightPanelOpen={rightPanelOpen} gitCwd={gitCwd} onRunProjectScript={runProjectScript} @@ -4238,33 +4634,67 @@ export default function ChatView(props: ChatViewProps) { {/* end chat column */} </div> {/* end horizontal flex container */} + {activeThreadRef ? ( + <PersistentThreadTerminalDrawer + threadRef={activeThreadRef} + threadId={activeThreadRef.threadId} + visible={terminalUiState.terminalOpen} + launchContext={activeTerminalLaunchContext ?? null} + focusRequestId={terminalFocusRequestId} + splitShortcutLabel={splitTerminalShortcutLabel ?? undefined} + splitVerticalShortcutLabel={splitTerminalVerticalShortcutLabel ?? undefined} + newShortcutLabel={newTerminalShortcutLabel ?? undefined} + closeShortcutLabel={closeTerminalShortcutLabel ?? undefined} + keybindings={keybindings} + onAddTerminalContext={addTerminalContextToDraft} + /> + ) : null} </div> - {!shouldUsePlanSidebarSheet && - rightPanelOpen && - activeThreadRef && - activeRightPanelSurface ? ( + {!shouldUsePlanSidebarSheet && rightPanelOpen && activeThreadRef ? ( <RightPanelTabs mode="inline" surfaces={rightPanelState.surfaces} - activeSurfaceId={activeRightPanelSurface.id} + activeSurfaceId={activeRightPanelSurface?.id ?? null} previewSessions={activePreviewState.sessions} + terminalLabelsById={activeTerminalLabelsById} onActivate={activateRightPanelSurface} onCloseSurface={closeRightPanelSurface} onAddBrowser={createBrowserSurface} + onAddTerminal={addTerminalSurface} onAddDiff={addDiffSurface} + browserAvailable={isPreviewSupportedInRuntime()} diffAvailable={isServerThread && isGitRepo} > - {activeRightPanelSurface.kind === "preview" ? ( + {activeRightPanelSurface?.kind === "preview" ? ( <Suspense fallback={null}> <PreviewPanel mode="embedded" threadRef={activeThreadRef} tabId={activeRightPanelSurface.resourceId} + configuredUrls={configuredPreviewUrls} visible /> </Suspense> - ) : activeRightPanelSurface.kind === "diff" ? ( + ) : activeRightPanelSurface?.kind === "terminal" ? ( + <PersistentThreadTerminalPanel + threadRef={activeThreadRef} + surface={activeRightPanelSurface} + launchContext={activeTerminalLaunchContext ?? null} + focusRequestId={terminalFocusRequestId} + keybindings={keybindings} + onAddTerminalContext={addTerminalContextToDraft} + onSplitTerminal={splitPanelTerminal} + onSplitTerminalVertical={splitPanelTerminalVertical} + onNewTerminal={addTerminalSurface} + onActiveTerminalChange={activatePanelTerminal} + onCloseTerminal={closePanelTerminal} + splitShortcutLabel={splitTerminalShortcutLabel ?? undefined} + splitVerticalShortcutLabel={splitTerminalVerticalShortcutLabel ?? undefined} + newShortcutLabel={newTerminalShortcutLabel ?? undefined} + closeShortcutLabel={closeTerminalShortcutLabel ?? undefined} + /> + ) : activeRightPanelSurface?.kind === "diff" ? ( <DiffWorkerPoolProvider> <Suspense fallback={null}> <DiffPanel mode="embedded" /> @@ -4274,49 +4704,57 @@ export default function ChatView(props: ChatViewProps) { </RightPanelTabs> ) : null} - {shouldUsePlanSidebarSheet && rightPanelOpen && activeThreadRef && activeRightPanelSurface ? ( + {shouldUsePlanSidebarSheet && rightPanelOpen && activeThreadRef ? ( <RightPanelSheet open onClose={closePreviewPanel}> <RightPanelTabs mode="sheet" surfaces={rightPanelState.surfaces} - activeSurfaceId={activeRightPanelSurface.id} + activeSurfaceId={activeRightPanelSurface?.id ?? null} previewSessions={activePreviewState.sessions} + terminalLabelsById={activeTerminalLabelsById} onActivate={activateRightPanelSurface} onCloseSurface={closeRightPanelSurface} onAddBrowser={createBrowserSurface} + onAddTerminal={addTerminalSurface} onAddDiff={addDiffSurface} + browserAvailable={isPreviewSupportedInRuntime()} diffAvailable={isServerThread && isGitRepo} > - {activeRightPanelSurface.kind === "preview" ? ( + {activeRightPanelSurface?.kind === "preview" ? ( <Suspense fallback={null}> <PreviewPanel mode="embedded" threadRef={activeThreadRef} tabId={activeRightPanelSurface.resourceId} + configuredUrls={configuredPreviewUrls} visible /> </Suspense> - ) : activeRightPanelSurface.kind === "terminal" ? ( - <PersistentThreadTerminalDrawer + ) : activeRightPanelSurface?.kind === "terminal" ? ( + <PersistentThreadTerminalPanel threadRef={activeThreadRef} - threadId={activeThreadRef.threadId} - visible - mode="panel" + surface={activeRightPanelSurface} launchContext={activeTerminalLaunchContext ?? null} focusRequestId={terminalFocusRequestId} + keybindings={keybindings} + onAddTerminalContext={addTerminalContextToDraft} + onSplitTerminal={splitPanelTerminal} + onSplitTerminalVertical={splitPanelTerminalVertical} + onNewTerminal={addTerminalSurface} + onActiveTerminalChange={activatePanelTerminal} + onCloseTerminal={closePanelTerminal} splitShortcutLabel={splitTerminalShortcutLabel ?? undefined} + splitVerticalShortcutLabel={splitTerminalVerticalShortcutLabel ?? undefined} newShortcutLabel={newTerminalShortcutLabel ?? undefined} closeShortcutLabel={closeTerminalShortcutLabel ?? undefined} - keybindings={keybindings} - onAddTerminalContext={addTerminalContextToDraft} /> - ) : activeRightPanelSurface.kind === "diff" ? ( + ) : activeRightPanelSurface?.kind === "diff" ? ( <DiffWorkerPoolProvider> <Suspense fallback={null}> <DiffPanel mode="embedded" /> </Suspense> </DiffWorkerPoolProvider> - ) : ( + ) : activeRightPanelSurface?.kind === "plan" ? ( <PlanSidebar activePlan={activePlan} activeProposedPlan={sidebarProposedPlan} @@ -4328,7 +4766,7 @@ export default function ChatView(props: ChatViewProps) { mode="embedded" onClose={closePlanSidebar} /> - )} + ) : null} </RightPanelTabs> </RightPanelSheet> ) : null} diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index af2d118e868..7c07b61d931 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -1,7 +1,9 @@ import type { PreviewSessionSnapshot } from "@t3tools/contracts"; +import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; import { ClipboardList, FileDiff, Globe2, Plus, TerminalSquare, X } from "lucide-react"; import { type ReactNode, useState } from "react"; +import { isElectron } from "~/env"; import type { RightPanelSurface } from "~/rightPanelStore"; import { cn } from "~/lib/utils"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu"; @@ -12,25 +14,97 @@ import { PreviewPanelShell, type PreviewPanelMode } from "./preview/PreviewPanel interface RightPanelTabsProps { mode: PreviewPanelMode; surfaces: readonly RightPanelSurface[]; - activeSurfaceId: string; + activeSurfaceId: string | null; previewSessions: Readonly<Record<string, PreviewSessionSnapshot>>; + terminalLabelsById: ReadonlyMap<string, string>; onActivate: (surface: RightPanelSurface) => void; onCloseSurface: (surface: RightPanelSurface) => void; onAddBrowser: () => void; + onAddTerminal: () => void; onAddDiff: () => void; + browserAvailable: boolean; diffAvailable: boolean; children: ReactNode; } +function RightPanelEmptyState(props: { + onAddBrowser: () => void; + onAddTerminal: () => void; + onAddDiff: () => void; + browserAvailable: boolean; + diffAvailable: boolean; +}) { + const actions = [ + { + label: "Browser", + description: "Open a local app or URL.", + icon: Globe2, + available: props.browserAvailable, + onClick: props.onAddBrowser, + }, + { + label: "Terminal", + description: "Start a shell in this workspace.", + icon: TerminalSquare, + available: true, + onClick: props.onAddTerminal, + }, + { + label: "Diff", + description: "Review changes in this thread.", + icon: FileDiff, + available: props.diffAvailable, + onClick: props.onAddDiff, + }, + ] as const; + + return ( + <div className="flex min-h-0 flex-1 items-center justify-center p-6"> + <div className="w-full max-w-xl"> + <div className="mb-5 text-center"> + <h3 className="text-sm font-medium text-foreground">Open a surface</h3> + <p className="mt-1 text-xs text-muted-foreground"> + Choose what to show in the right panel. + </p> + </div> + <div className="grid gap-2 sm:grid-cols-3"> + {actions.map((action) => { + const Icon = action.icon; + return ( + <button + key={action.label} + type="button" + disabled={!action.available} + onClick={action.onClick} + className="flex min-h-28 flex-col items-start rounded-lg border border-border/80 bg-card/40 p-4 text-left transition hover:border-border hover:bg-accent/60 disabled:cursor-not-allowed disabled:opacity-40" + > + <Icon className="mb-3 size-5" /> + <span className="text-sm font-medium">{action.label}</span> + <span className="mt-1 text-xs leading-relaxed text-muted-foreground"> + {action.description} + </span> + </button> + ); + })} + </div> + </div> + </div> + ); +} + function surfaceTitle( surface: RightPanelSurface, sessions: Readonly<Record<string, PreviewSessionSnapshot>>, + terminalLabelsById: ReadonlyMap<string, string>, ): string { switch (surface.kind) { case "diff": return "Diff"; case "terminal": - return "Terminal"; + return ( + terminalLabelsById.get(surface.activeTerminalId) ?? + getTerminalLabel(surface.activeTerminalId) + ); case "plan": return "Plan"; case "preview": { @@ -85,13 +159,22 @@ function SurfaceIcon({ } export function RightPanelTabs(props: RightPanelTabsProps) { + const ownsDesktopTitleBar = isElectron && props.mode === "inline"; + return ( <PreviewPanelShell mode={props.mode}> - <div className="flex h-10 shrink-0 items-center px-2"> + <div + className={cn( + "flex shrink-0 items-center px-2", + ownsDesktopTitleBar + ? "drag-region h-[52px] wco:h-[env(titlebar-area-height)] wco:pr-[calc(100vw-env(titlebar-area-width)-env(titlebar-area-x)+1em)]" + : "h-10", + )} + > <div className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto"> {props.surfaces.map((surface) => { const active = surface.id === props.activeSurfaceId; - const title = surfaceTitle(surface, props.previewSessions); + const title = surfaceTitle(surface, props.previewSessions, props.terminalLabelsById); return ( <div key={surface.id} @@ -131,10 +214,14 @@ export function RightPanelTabs(props: RightPanelTabsProps) { <Plus className="size-4" /> </MenuTrigger> <MenuPopup align="start" side="bottom" sideOffset={6} className="min-w-44"> - <MenuItem onClick={props.onAddBrowser}> + <MenuItem onClick={props.onAddBrowser} disabled={!props.browserAvailable}> <Globe2 /> Browser </MenuItem> + <MenuItem onClick={props.onAddTerminal}> + <TerminalSquare /> + Terminal + </MenuItem> <MenuItem onClick={props.onAddDiff} disabled={!props.diffAvailable}> <FileDiff /> Diff @@ -142,7 +229,19 @@ export function RightPanelTabs(props: RightPanelTabsProps) { </MenuPopup> </Menu> </div> - <div className="flex min-h-0 flex-1 flex-col">{props.children}</div> + <div className="flex min-h-0 flex-1 flex-col"> + {props.activeSurfaceId === null ? ( + <RightPanelEmptyState + onAddBrowser={props.onAddBrowser} + onAddTerminal={props.onAddTerminal} + onAddDiff={props.onAddDiff} + browserAvailable={props.browserAvailable} + diffAvailable={props.diffAvailable} + /> + ) : ( + props.children + )} + </div> </PreviewPanelShell> ); } diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index dc5acaaadc7..67b575e4b46 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -4,6 +4,7 @@ import { ChevronRightIcon, CloudIcon, FolderPlusIcon, + Globe2Icon, SearchIcon, SettingsIcon, SquarePenIcon, @@ -75,6 +76,7 @@ import { } from "../store"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { useThreadRunningTerminalIds } from "../terminalSessionState"; +import { useThreadDiscoveredPorts } from "../portDiscoveryState"; import { useUiStateStore } from "../uiStateStore"; import { resolveShortcutCommand, @@ -198,6 +200,7 @@ import { type SidebarProjectSnapshot, } from "../sidebarProjectGrouping"; import { SidebarProviderUpdatePill } from "./sidebar/SidebarProviderUpdatePill"; +import { openDiscoveredPort } from "./preview/openDiscoveredPort"; const SIDEBAR_SORT_LABELS: Record<SidebarProjectSortOrder, string> = { updated_at: "Last user message", created_at: "Created at", @@ -349,6 +352,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP environmentId: thread.environmentId, threadId: thread.id, }); + const discoveredPorts = useThreadDiscoveredPorts({ + environmentId: thread.environmentId, + threadId: thread.id, + }); const primaryEnvironmentId = usePrimaryEnvironmentId(); const isRemoteThread = primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId; @@ -419,6 +426,17 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP }, [handleThreadClick, orderedProjectThreadKeys, threadRef], ); + const handleOpenDiscoveredPort = useCallback( + (event: React.MouseEvent<HTMLButtonElement>) => { + const port = discoveredPorts[0]; + if (!port) return; + event.preventDefault(); + event.stopPropagation(); + navigateToThread(threadRef); + void openDiscoveredPort({ threadRef, port }); + }, + [discoveredPorts, navigateToThread, threadRef], + ); const handleRowKeyDown = useCallback( (event: React.KeyboardEvent) => { if (event.key !== "Enter" && event.key !== " ") return; @@ -609,6 +627,26 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP )} </div> <div className="ml-auto flex shrink-0 items-center gap-1.5"> + {discoveredPorts.length > 0 && ( + <Tooltip> + <TooltipTrigger + render={ + <button + type="button" + aria-label={`Open localhost:${discoveredPorts[0]?.port ?? ""}`} + className="inline-flex cursor-pointer items-center justify-center text-emerald-600 outline-hidden focus-visible:ring-1 focus-visible:ring-ring dark:text-emerald-400" + onClick={handleOpenDiscoveredPort} + /> + } + > + <Globe2Icon className="size-3" /> + </TooltipTrigger> + <TooltipPopup side="top"> + Open localhost:{discoveredPorts[0]?.port} + {discoveredPorts.length > 1 ? ` (+${discoveredPorts.length - 1})` : ""} + </TooltipPopup> + </Tooltip> + )} {terminalStatus && ( <Tooltip> <TooltipTrigger diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index c4757cb3455..d07057c00c0 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -1,5 +1,13 @@ import { FitAddon } from "@xterm/addon-fit"; -import { Plus, SquareSplitHorizontal, TerminalSquare, Trash2, XIcon } from "lucide-react"; +import { + Globe2, + Plus, + SquareSplitHorizontal, + SquareSplitVertical, + TerminalSquare, + Trash2, + XIcon, +} from "lucide-react"; import { type ResolvedKeybindingsConfig, type ScopedThreadRef, @@ -37,6 +45,7 @@ import { isTerminalCloseShortcut, isTerminalNewShortcut, isTerminalSplitShortcut, + isTerminalSplitVerticalShortcut, isTerminalToggleShortcut, terminalDeleteShortcutData, terminalNavigationShortcutData, @@ -50,6 +59,8 @@ import { readEnvironmentApi } from "~/environmentApi"; import { readLocalApi } from "~/localApi"; import { attachTerminalSession } from "../terminalSessionState"; import { openTerminalLinkInPreview } from "./preview/openTerminalLinkInPreview"; +import { useDiscoveredPorts } from "../portDiscoveryState"; +import { openDiscoveredPort } from "./preview/openDiscoveredPort"; const MIN_DRAWER_HEIGHT = 180; const MAX_DRAWER_HEIGHT_RATIO = 0.75; @@ -437,6 +448,7 @@ export function TerminalViewport({ if ( isTerminalToggleShortcut(event, currentKeybindings, options) || isTerminalSplitShortcut(event, currentKeybindings, options) || + isTerminalSplitVerticalShortcut(event, currentKeybindings, options) || isTerminalNewShortcut(event, currentKeybindings, options) || isTerminalCloseShortcut(event, currentKeybindings, options) || isDiffToggleShortcut(event, currentKeybindings, options) @@ -723,12 +735,44 @@ export function TerminalViewport({ .catch(() => undefined); }, 30); attachTerminal(); + let resizeFrame = 0; + const resizeObserver = + typeof ResizeObserver === "undefined" + ? null + : new ResizeObserver(() => { + if (resizeFrame !== 0) return; + resizeFrame = window.requestAnimationFrame(() => { + resizeFrame = 0; + const activeTerminal = terminalRef.current; + const activeFitAddon = fitAddonRef.current; + if (!activeTerminal || !activeFitAddon) return; + const wasAtBottom = + activeTerminal.buffer.active.viewportY >= activeTerminal.buffer.active.baseY; + fitTerminalSafely(activeFitAddon); + if (wasAtBottom) { + activeTerminal.scrollToBottom(); + } + void api.terminal + .resize({ + threadId, + terminalId, + cols: activeTerminal.cols, + rows: activeTerminal.rows, + }) + .catch(() => undefined); + }); + }); + resizeObserver?.observe(mount); return () => { disposed = true; unsubscribeAttach?.(); unsubscribeAttach = null; window.clearTimeout(fitTimer); + if (resizeFrame !== 0) { + window.cancelAnimationFrame(resizeFrame); + } + resizeObserver?.disconnect(); inputDisposable.dispose(); selectionDisposable.dispose(); terminalLinksDisposable.dispose(); @@ -806,8 +850,10 @@ interface ThreadTerminalDrawerProps { activeTerminalGroupId: string; focusRequestId: number; onSplitTerminal: () => void; + onSplitTerminalVertical: () => void; onNewTerminal: () => void; splitShortcutLabel?: string | undefined; + splitVerticalShortcutLabel?: string | undefined; newShortcutLabel?: string | undefined; closeShortcutLabel?: string | undefined; onActiveTerminalChange: (terminalId: string) => void; @@ -865,8 +911,10 @@ export default function ThreadTerminalDrawer({ activeTerminalGroupId, focusRequestId, onSplitTerminal, + onSplitTerminalVertical, onNewTerminal, splitShortcutLabel, + splitVerticalShortcutLabel, newShortcutLabel, closeShortcutLabel, onActiveTerminalChange, @@ -957,6 +1005,9 @@ export default function ThreadTerminalDrawer({ nextGroups.push({ id: assignUniqueGroupId(baseGroupId), terminalIds: nextTerminalIds, + ...(terminalGroup.splitDirection === "vertical" + ? { splitDirection: "vertical" as const } + : {}), }); } @@ -994,6 +1045,8 @@ export default function ThreadTerminalDrawer({ const visibleTerminalIds = resolvedTerminalGroups[resolvedActiveGroupIndex]?.terminalIds ?? (normalizedTerminalIds.length > 0 ? [resolvedActiveTerminalId] : []); + const splitDirection = + resolvedTerminalGroups[resolvedActiveGroupIndex]?.splitDirection ?? "horizontal"; const hasTerminalSidebar = normalizedTerminalIds.length > 1; const isSplitView = visibleTerminalIds.length > 1; const showGroupHeaders = @@ -1007,6 +1060,17 @@ export default function ThreadTerminalDrawer({ } return next; }, [normalizedTerminalIds, terminalLabelsById]); + const discoveredPorts = useDiscoveredPorts(threadRef.environmentId); + const discoveredPortByTerminalId = useMemo(() => { + const next = new Map<string, (typeof discoveredPorts)[number]>(); + for (const port of discoveredPorts) { + if (port.terminal?.threadId !== threadId) continue; + if (!next.has(port.terminal.terminalId)) { + next.set(port.terminal.terminalId, port); + } + } + return next; + }, [discoveredPorts, threadId]); const resolveTerminalLaunchLocation = useCallback( (terminalId: string): TerminalLaunchLocation => { return ( @@ -1020,10 +1084,15 @@ export default function ThreadTerminalDrawer({ [cwd, runtimeEnv, terminalLaunchLocationsById, worktreePath], ); const splitTerminalActionLabel = hasReachedSplitLimit - ? `Split Terminal (max ${MAX_TERMINALS_PER_GROUP} per group)` + ? `Split Terminal Horizontally (max ${MAX_TERMINALS_PER_GROUP} per group)` : splitShortcutLabel - ? `Split Terminal (${splitShortcutLabel})` - : "Split Terminal"; + ? `Split Terminal Horizontally (${splitShortcutLabel})` + : "Split Terminal Horizontally"; + const splitTerminalVerticalActionLabel = hasReachedSplitLimit + ? `Split Terminal Vertically (max ${MAX_TERMINALS_PER_GROUP} per group)` + : splitVerticalShortcutLabel + ? `Split Terminal Vertically (${splitVerticalShortcutLabel})` + : "Split Terminal Vertically"; const newTerminalActionLabel = newShortcutLabel ? `New Terminal (${newShortcutLabel})` : "New Terminal"; @@ -1034,6 +1103,10 @@ export default function ThreadTerminalDrawer({ if (hasReachedSplitLimit) return; onSplitTerminal(); }, [hasReachedSplitLimit, onSplitTerminal]); + const onSplitTerminalVerticalAction = useCallback(() => { + if (hasReachedSplitLimit) return; + onSplitTerminalVertical(); + }, [hasReachedSplitLimit, onSplitTerminalVertical]); const onNewTerminalAction = useCallback(() => { onNewTerminal(); }, [onNewTerminal]); @@ -1143,6 +1216,7 @@ export default function ThreadTerminalDrawer({ if (normalizedTerminalIds.length === 0) { return ( <aside + data-terminal-owner={isPanel ? "right-panel" : "drawer"} className={cn( "thread-terminal-drawer relative flex min-w-0 flex-col overflow-hidden bg-background", isPanel ? "h-full flex-1" : "shrink-0 border-t border-border/80", @@ -1176,6 +1250,7 @@ export default function ThreadTerminalDrawer({ return ( <aside + data-terminal-owner={isPanel ? "right-panel" : "drawer"} className={cn( "thread-terminal-drawer relative flex min-w-0 flex-col overflow-hidden bg-background", isPanel ? "h-full flex-1" : "shrink-0 border-t border-border/80", @@ -1207,6 +1282,18 @@ export default function ThreadTerminalDrawer({ <SquareSplitHorizontal className="size-3.25" /> </TerminalActionButton> <div className="h-4 w-px bg-border/80" /> + <TerminalActionButton + className={`p-1 text-foreground/90 transition-colors ${ + hasReachedSplitLimit + ? "cursor-not-allowed opacity-45 hover:bg-transparent" + : "hover:bg-accent" + }`} + onClick={onSplitTerminalVerticalAction} + label={splitTerminalVerticalActionLabel} + > + <SquareSplitVertical className="size-3.25" /> + </TerminalActionButton> + <div className="h-4 w-px bg-border/80" /> <TerminalActionButton className="p-1 text-foreground/90 transition-colors hover:bg-accent" onClick={onNewTerminalAction} @@ -1232,16 +1319,26 @@ export default function ThreadTerminalDrawer({ {isSplitView ? ( <div className="grid h-full w-full min-w-0 gap-0 overflow-hidden" - style={{ - gridTemplateColumns: `repeat(${visibleTerminalIds.length}, minmax(0, 1fr))`, - }} + style={ + splitDirection === "vertical" + ? { + gridTemplateRows: `repeat(${visibleTerminalIds.length}, minmax(0, 1fr))`, + } + : { + gridTemplateColumns: `repeat(${visibleTerminalIds.length}, minmax(0, 1fr))`, + } + } > {visibleTerminalIds.map((terminalId) => { const terminalLaunchLocation = resolveTerminalLaunchLocation(terminalId); return ( <div key={terminalId} - className={`min-h-0 min-w-0 border-l first:border-l-0 ${ + className={`min-h-0 min-w-0 ${ + splitDirection === "vertical" + ? "border-t first:border-t-0" + : "border-l first:border-l-0" + } ${ terminalId === resolvedActiveTerminalId ? "border-border" : "border-border/70" @@ -1320,6 +1417,17 @@ export default function ThreadTerminalDrawer({ > <SquareSplitHorizontal className="size-3.25" /> </TerminalActionButton> + <TerminalActionButton + className={`inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors ${ + hasReachedSplitLimit + ? "cursor-not-allowed opacity-45 hover:bg-transparent" + : "hover:bg-accent/70" + }`} + onClick={onSplitTerminalVerticalAction} + label={splitTerminalVerticalActionLabel} + > + <SquareSplitVertical className="size-3.25" /> + </TerminalActionButton> <TerminalActionButton className="inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors hover:bg-accent/70" onClick={onNewTerminalAction} @@ -1366,6 +1474,7 @@ export default function ThreadTerminalDrawer({ > {terminalGroup.terminalIds.map((terminalId) => { const isActive = terminalId === resolvedActiveTerminalId; + const discoveredPort = discoveredPortByTerminalId.get(terminalId); const closeTerminalLabel = `Close ${ terminalLabelById.get(terminalId) ?? "terminal" }${isActive && closeShortcutLabel ? ` (${closeShortcutLabel})` : ""}`; @@ -1391,6 +1500,37 @@ export default function ThreadTerminalDrawer({ {terminalLabelById.get(terminalId) ?? "Terminal"} </span> </button> + {discoveredPort && ( + <Popover> + <PopoverTrigger + openOnHover + render={ + <button + type="button" + className="inline-flex size-4 items-center justify-center rounded text-emerald-600 transition hover:bg-accent dark:text-emerald-400" + onClick={() => + void openDiscoveredPort({ + threadRef, + port: discoveredPort, + }) + } + aria-label={`Open localhost:${discoveredPort.port}`} + /> + } + > + <Globe2 className="size-3" /> + </PopoverTrigger> + <PopoverPopup + tooltipStyle + side="bottom" + sideOffset={6} + align="center" + className="pointer-events-none select-none" + > + Open localhost:{discoveredPort.port} + </PopoverPopup> + </Popover> + )} {normalizedTerminalIds.length > 1 && ( <Popover> <PopoverTrigger diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index c035b2d771f..8421fc18464 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -16,6 +16,7 @@ import { Toggle } from "../ui/toggle"; import { SidebarTrigger } from "../ui/sidebar"; import { OpenInPicker } from "./OpenInPicker"; import { usePrimaryEnvironmentId } from "../../environments/primary"; +import { shortcutLabelForCommand } from "../../keybindings"; interface ChatHeaderProps { activeThreadEnvironmentId: EnvironmentId; @@ -76,6 +77,7 @@ export const ChatHeader = memo(function ChatHeader({ activeThreadEnvironmentId, primaryEnvironmentId, }); + const rightPanelShortcutLabel = shortcutLabelForCommand(keybindings, "rightPanel.toggle"); return ( <div className="@container/header-actions flex min-w-0 flex-1 flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"> @@ -138,7 +140,9 @@ export const ChatHeader = memo(function ChatHeader({ } /> <TooltipPopup side="bottom"> - {rightPanelAvailable ? "Toggle right panel" : "Right panel is unavailable"} + {rightPanelAvailable + ? `Toggle right panel${rightPanelShortcutLabel ? ` (${rightPanelShortcutLabel})` : ""}` + : "Right panel is unavailable"} </TooltipPopup> </Tooltip> </div> diff --git a/apps/web/src/components/preview/AgentBrowserCursor.tsx b/apps/web/src/components/preview/AgentBrowserCursor.tsx new file mode 100644 index 00000000000..2f12300400d --- /dev/null +++ b/apps/web/src/components/preview/AgentBrowserCursor.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { MousePointer2 } from "lucide-react"; +import { useEffect, useState } from "react"; + +import { useBrowserPointerStore } from "~/browser/browserPointerStore"; + +import { agentBrowserCursorOpacity, type BrowserController } from "./agentBrowserCursorLogic"; + +const CURSOR_ACTIVE_MS = 700; + +export function AgentBrowserCursor(props: { + readonly tabId: string; + readonly zoomFactor: number; + readonly controller: BrowserController; +}) { + const { tabId, zoomFactor, controller } = props; + const event = useBrowserPointerStore((state) => state.byTabId[tabId] ?? null); + const [active, setActive] = useState(false); + + useEffect(() => { + if (!event) return; + setActive(true); + const timeout = window.setTimeout(() => setActive(false), CURSOR_ACTIVE_MS); + return () => window.clearTimeout(timeout); + }, [event]); + + if (!event) return null; + + return ( + <div + className="pointer-events-none absolute left-0 top-0 z-40 transition-[transform,opacity] duration-150 ease-out motion-reduce:transition-none" + style={{ + opacity: agentBrowserCursorOpacity(active, controller), + transform: `translate3d(${event.x * zoomFactor}px, ${event.y * zoomFactor}px, 0)`, + }} + aria-hidden="true" + data-agent-browser-cursor + > + {event.phase === "click" ? ( + <span + key={event.sequence} + className="absolute left-0.5 top-0.5 size-4 animate-ping rounded-full bg-primary/25 motion-reduce:animate-none" + /> + ) : null} + <MousePointer2 + className="relative size-5 -translate-x-0.5 -translate-y-0.5 fill-background text-primary drop-shadow-sm" + strokeWidth={2} + /> + </div> + ); +} diff --git a/apps/web/src/components/preview/PreviewChromeRow.browser.tsx b/apps/web/src/components/preview/PreviewChromeRow.browser.tsx new file mode 100644 index 00000000000..f9a0e16e378 --- /dev/null +++ b/apps/web/src/components/preview/PreviewChromeRow.browser.tsx @@ -0,0 +1,41 @@ +import "../../index.css"; + +import { page } from "vite-plus/test/browser"; +import { describe, expect, it, vi } from "vite-plus/test"; +import { render } from "vitest-browser-react"; + +import { PreviewChromeRow } from "./PreviewChromeRow"; + +const defaultProps = { + url: "https://example.com/", + loading: false, + loadProgress: 0, + canGoBack: false, + canGoForward: false, + refreshDisabled: false, + onBack: vi.fn(), + onForward: vi.fn(), + onRefresh: vi.fn(), + onSubmit: vi.fn(), +}; + +describe("PreviewChromeRow", () => { + it("only focuses the URL input after an explicit focus request", async () => { + const previouslyFocused = document.createElement("button"); + document.body.append(previouslyFocused); + previouslyFocused.focus(); + + const screen = await render(<PreviewChromeRow {...defaultProps} focusUrlNonce={undefined} />); + const input = page.getByRole("textbox").element() as HTMLInputElement; + + expect(document.activeElement).toBe(previouslyFocused); + + await screen.rerender(<PreviewChromeRow {...defaultProps} focusUrlNonce={1} />); + + expect(document.activeElement).toBe(input); + expect(input.selectionStart).toBe(0); + expect(input.selectionEnd).toBe(input.value.length); + + previouslyFocused.remove(); + }); +}); diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index 0bec9d5d44b..a330382e21d 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -22,16 +22,19 @@ import { PreviewChromeRow } from "./PreviewChromeRow"; import { PreviewEmptyState } from "./PreviewEmptyState"; import { PreviewMoreMenu } from "./PreviewMoreMenu"; import { PreviewUnreachable } from "./PreviewUnreachable"; +import { revealInFileExplorerLabel } from "./fileExplorerLabel"; +import { shouldShowPreviewEmptyState } from "./previewEmptyStateLogic"; import { BrowserSurfaceSlot } from "~/browser/BrowserSurfaceSlot"; import { useLoadingProgress } from "./useLoadingProgress"; import { usePreviewSession } from "./usePreviewSession"; import { ZoomIndicator } from "./ZoomIndicator"; +import { AgentBrowserCursor } from "./AgentBrowserCursor"; import { startBrowserRecording, stopBrowserRecording, useBrowserRecordingStore, } from "~/browser/browserRecording"; -import { toastManager } from "~/components/ui/toast"; +import { stackedThreadToast, toastManager } from "~/components/ui/toast"; interface Props { threadRef: ScopedThreadRef; @@ -47,7 +50,7 @@ const localApi = typeof window === "undefined" ? null : ensureLocalApi(); * state when no session exists for the thread. */ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, visible }: Props) { - const [focusUrlNonce, setFocusUrlNonce] = useState(0); + const [focusUrlNonce, setFocusUrlNonce] = useState<number | undefined>(undefined); const [pickActive, setPickActive] = useState(false); const activeRecordingTabId = useBrowserRecordingStore((state) => state.activeTabId); const pickActiveRef = useRef(false); @@ -81,6 +84,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, const canGoForward = desktopOverlay?.canGoForward ?? snapshot?.canGoForward ?? false; const refreshDisabled = navStatus._tag === "Idle"; const isUnreachable = navStatus._tag === "LoadFailed"; + const showEmptyState = shouldShowPreviewEmptyState(snapshot); const controller = desktopOverlay?.controller ?? "none"; const loadProgress = useLoadingProgress(loading); @@ -142,16 +146,89 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, const handleCapture = useCallback( (record: boolean) => { if (!previewBridge || !tabId) return; + const bridge = previewBridge; const recordingThisTab = activeRecordingTabId === tabId; if (recordingThisTab) { void stopBrowserRecording(tabId).then( (artifact) => { if (!artifact) return; - toastManager.add({ - type: "success", - title: "Recording saved", - description: artifact.path, - }); + let pathCopied = false; + let toastId: ReturnType<typeof toastManager.add>; + + const copyPath = () => { + if (!navigator.clipboard?.writeText) { + toastManager.update( + toastId, + stackedThreadToast({ + type: "error", + title: "Unable to copy recording path", + description: "Clipboard API unavailable.", + actionProps: revealAction, + }), + ); + return; + } + + void navigator.clipboard.writeText(artifact.path).then( + () => { + pathCopied = true; + updateRecordingToast(); + window.setTimeout(() => { + pathCopied = false; + updateRecordingToast(); + }, 2_000); + }, + (error) => { + toastManager.update( + toastId, + stackedThreadToast({ + type: "error", + title: "Unable to copy recording path", + description: error instanceof Error ? error.message : "An error occurred.", + actionProps: revealAction, + }), + ); + }, + ); + }; + + const revealAction = { + children: revealInFileExplorerLabel(navigator.platform), + onClick: () => void bridge.revealArtifact(artifact.path), + }; + const updateRecordingToast = () => { + toastManager.update( + toastId, + stackedThreadToast({ + type: "success", + title: "Recording saved", + actionProps: revealAction, + data: { + secondaryActionProps: { + children: pathCopied ? "Copied!" : "Copy path", + disabled: pathCopied, + onClick: copyPath, + }, + secondaryActionVariant: "outline", + }, + }), + ); + }; + + toastId = toastManager.add( + stackedThreadToast({ + type: "success", + title: "Recording saved", + actionProps: revealAction, + data: { + secondaryActionProps: { + children: "Copy path", + onClick: copyPath, + }, + secondaryActionVariant: "outline", + }, + }), + ); }, (error) => { toastManager.add({ @@ -181,13 +258,126 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, }); return; } - void previewBridge.captureScreenshot(tabId).then( + void bridge.captureScreenshot(tabId).then( (artifact) => { - toastManager.add({ - type: "success", - title: "Screenshot saved", - description: artifact.path, - }); + const revealAction = { + children: revealInFileExplorerLabel(navigator.platform), + onClick: () => void bridge.revealArtifact(artifact.path), + }; + let pathCopied = false; + let imageCopied = false; + let toastId: ReturnType<typeof toastManager.add>; + + const updateScreenshotToast = ( + type: "success" | "error" = "success", + title = "Screenshot saved", + description?: string, + ) => { + toastManager.update( + toastId, + stackedThreadToast({ + type, + title, + description, + actionProps: { + children: imageCopied ? "Copied!" : "Copy image", + disabled: imageCopied, + onClick: copyImage, + }, + data: { + additionalActions: [ + { + id: "copy-path", + props: { + children: pathCopied ? "Copied!" : "Copy path", + disabled: pathCopied, + onClick: copyPath, + }, + }, + ], + secondaryActionProps: { + ...revealAction, + }, + secondaryActionVariant: "outline", + }, + }), + ); + }; + + const copyPath = () => { + if (!navigator.clipboard?.writeText) { + updateScreenshotToast( + "error", + "Unable to copy screenshot path", + "Clipboard API unavailable.", + ); + return; + } + + void navigator.clipboard.writeText(artifact.path).then( + () => { + pathCopied = true; + updateScreenshotToast(); + window.setTimeout(() => { + pathCopied = false; + updateScreenshotToast(); + }, 2_000); + }, + (error) => { + updateScreenshotToast( + "error", + "Unable to copy screenshot path", + error instanceof Error ? error.message : "An error occurred.", + ); + }, + ); + }; + + const copyImage = () => { + void bridge.copyArtifactToClipboard(artifact.path).then( + () => { + imageCopied = true; + updateScreenshotToast(); + window.setTimeout(() => { + imageCopied = false; + updateScreenshotToast(); + }, 2_000); + }, + (error) => { + updateScreenshotToast( + "error", + "Unable to copy screenshot", + error instanceof Error ? error.message : "An error occurred.", + ); + }, + ); + }; + + toastId = toastManager.add( + stackedThreadToast({ + type: "success", + title: "Screenshot saved", + actionProps: { + children: "Copy image", + onClick: copyImage, + }, + data: { + additionalActions: [ + { + id: "copy-path", + props: { + children: "Copy path", + onClick: copyPath, + }, + }, + ], + secondaryActionProps: { + ...revealAction, + }, + secondaryActionVariant: "outline", + }, + }), + ); }, (error) => { toastManager.add({ @@ -287,7 +477,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, handleRefresh(); return; case "focus-url": - setFocusUrlNonce((value) => value + 1); + setFocusUrlNonce((value) => (value ?? 0) + 1); return; case "zoom-in": handleZoomIn(); @@ -346,24 +536,32 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, /> <div className="relative min-h-0 flex-1 overflow-hidden"> - {tabId && snapshot ? ( + {tabId && snapshot && !showEmptyState ? ( <BrowserSurfaceSlot key={tabId} tabId={tabId} visible={visible && !isUnreachable} className="absolute inset-0 h-full w-full" /> - ) : ( + ) : null} + {showEmptyState ? ( <PreviewEmptyState environmentId={threadRef.environmentId} configuredUrls={configuredUrls} recentlySeenUrls={previewState.recentlySeenUrls} onOpenUrl={(next) => void handleSubmitUrl(next)} /> - )} + ) : null} {snapshot && desktopOverlay ? ( <ZoomIndicator zoomFactor={desktopOverlay.zoomFactor} /> ) : null} + {tabId && desktopOverlay && !showEmptyState && !isUnreachable ? ( + <AgentBrowserCursor + tabId={tabId} + zoomFactor={desktopOverlay.zoomFactor} + controller={controller} + /> + ) : null} {controller !== "none" ? ( <div className="pointer-events-none absolute left-3 top-3 z-40 rounded-full border border-border/70 bg-background/90 px-2.5 py-1 text-[11px] font-medium shadow-sm backdrop-blur"> {controller === "agent" ? "Agent controlling browser" : "Human control"} diff --git a/apps/web/src/components/preview/agentBrowserCursorLogic.test.ts b/apps/web/src/components/preview/agentBrowserCursorLogic.test.ts new file mode 100644 index 00000000000..fa01bc138af --- /dev/null +++ b/apps/web/src/components/preview/agentBrowserCursorLogic.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { agentBrowserCursorOpacity } from "./agentBrowserCursorLogic"; + +describe("agentBrowserCursorOpacity", () => { + it("keeps active movement fully visible", () => { + expect(agentBrowserCursorOpacity(true, "agent")).toBe(1); + expect(agentBrowserCursorOpacity(true, "human")).toBe(1); + }); + + it("settles to a visible idle state", () => { + expect(agentBrowserCursorOpacity(false, "none")).toBe(0.35); + expect(agentBrowserCursorOpacity(false, "agent")).toBe(0.35); + }); + + it("dims further while the human controls the page", () => { + expect(agentBrowserCursorOpacity(false, "human")).toBe(0.18); + }); +}); diff --git a/apps/web/src/components/preview/agentBrowserCursorLogic.ts b/apps/web/src/components/preview/agentBrowserCursorLogic.ts new file mode 100644 index 00000000000..0dfdc64aa6b --- /dev/null +++ b/apps/web/src/components/preview/agentBrowserCursorLogic.ts @@ -0,0 +1,6 @@ +export type BrowserController = "human" | "agent" | "none"; + +export function agentBrowserCursorOpacity(active: boolean, controller: BrowserController): number { + if (active) return 1; + return controller === "human" ? 0.18 : 0.35; +} diff --git a/apps/web/src/components/preview/fileExplorerLabel.test.ts b/apps/web/src/components/preview/fileExplorerLabel.test.ts new file mode 100644 index 00000000000..0b39a8d9ef9 --- /dev/null +++ b/apps/web/src/components/preview/fileExplorerLabel.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { revealInFileExplorerLabel } from "./fileExplorerLabel"; + +describe("revealInFileExplorerLabel", () => { + it.each([ + ["MacIntel", "Reveal in Finder"], + ["Win32", "Reveal in File Explorer"], + ["Linux x86_64", "Reveal in Files"], + ])("maps %s to %s", (platform, expected) => { + expect(revealInFileExplorerLabel(platform)).toBe(expected); + }); +}); diff --git a/apps/web/src/components/preview/fileExplorerLabel.ts b/apps/web/src/components/preview/fileExplorerLabel.ts new file mode 100644 index 00000000000..fd0785dfadf --- /dev/null +++ b/apps/web/src/components/preview/fileExplorerLabel.ts @@ -0,0 +1,6 @@ +export function revealInFileExplorerLabel(platform: string): string { + const normalized = platform.toLowerCase(); + if (normalized.includes("mac")) return "Reveal in Finder"; + if (normalized.includes("win")) return "Reveal in File Explorer"; + return "Reveal in Files"; +} diff --git a/apps/web/src/components/preview/openDiscoveredPort.ts b/apps/web/src/components/preview/openDiscoveredPort.ts new file mode 100644 index 00000000000..226b6548924 --- /dev/null +++ b/apps/web/src/components/preview/openDiscoveredPort.ts @@ -0,0 +1,24 @@ +import type { DiscoveredLocalServer, ScopedThreadRef } from "@t3tools/contracts"; + +import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver"; +import { ensureEnvironmentApi } from "~/environmentApi"; +import { usePreviewStateStore } from "~/previewStateStore"; +import { useRightPanelStore } from "~/rightPanelStore"; +import { openPreviewSession } from "./openPreviewSession"; + +export async function openDiscoveredPort(input: { + readonly threadRef: ScopedThreadRef; + readonly port: DiscoveredLocalServer; +}): Promise<void> { + const api = ensureEnvironmentApi(input.threadRef.environmentId); + const resolvedUrl = resolveDiscoveredServerUrl(input.threadRef.environmentId, input.port.url); + const previewState = usePreviewStateStore.getState(); + const snapshot = await openPreviewSession({ + previewApi: api.preview, + threadRef: input.threadRef, + url: resolvedUrl, + applyServerSnapshot: previewState.applyServerSnapshot, + rememberUrl: previewState.rememberUrl, + }); + useRightPanelStore.getState().openBrowser(input.threadRef, snapshot.tabId); +} diff --git a/apps/web/src/components/preview/openPreviewSession.ts b/apps/web/src/components/preview/openPreviewSession.ts index 37ef33069ce..e33361057ce 100644 --- a/apps/web/src/components/preview/openPreviewSession.ts +++ b/apps/web/src/components/preview/openPreviewSession.ts @@ -1,4 +1,4 @@ -import type { EnvironmentApi, ScopedThreadRef } from "@t3tools/contracts"; +import type { EnvironmentApi, PreviewSessionSnapshot, ScopedThreadRef } from "@t3tools/contracts"; import type { PreviewStateStoreState } from "~/previewStateStore"; @@ -10,7 +10,9 @@ interface OpenPreviewSessionInput { rememberUrl: PreviewStateStoreState["rememberUrl"]; } -export async function openPreviewSession(input: OpenPreviewSessionInput): Promise<void> { +export async function openPreviewSession( + input: OpenPreviewSessionInput, +): Promise<PreviewSessionSnapshot> { const snapshot = await input.previewApi.open({ threadId: input.threadRef.threadId, url: input.url, @@ -20,4 +22,5 @@ export async function openPreviewSession(input: OpenPreviewSessionInput): Promis input.threadRef, snapshot.navStatus._tag === "Idle" ? input.url : snapshot.navStatus.url, ); + return snapshot; } diff --git a/apps/web/src/components/preview/previewEmptyStateLogic.test.ts b/apps/web/src/components/preview/previewEmptyStateLogic.test.ts new file mode 100644 index 00000000000..3759173d3cc --- /dev/null +++ b/apps/web/src/components/preview/previewEmptyStateLogic.test.ts @@ -0,0 +1,42 @@ +import type { PreviewSessionSnapshot, ProjectScript } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { getConfiguredPreviewUrls, shouldShowPreviewEmptyState } from "./previewEmptyStateLogic"; + +const snapshot = (navStatus: PreviewSessionSnapshot["navStatus"]): PreviewSessionSnapshot => ({ + threadId: "thread-1", + tabId: "tab-1", + navStatus, + canGoBack: false, + canGoForward: false, + updatedAt: "2026-06-12T20:00:00.000Z", +}); + +describe("shouldShowPreviewEmptyState", () => { + it("shows quick-open options for a new idle browser tab", () => { + expect(shouldShowPreviewEmptyState(snapshot({ _tag: "Idle" }))).toBe(true); + }); + + it("shows browser content once navigation starts", () => { + expect( + shouldShowPreviewEmptyState( + snapshot({ _tag: "Loading", url: "http://localhost:5173", title: "" }), + ), + ).toBe(false); + }); +}); + +describe("getConfiguredPreviewUrls", () => { + it("collects configured preview URLs from project scripts", () => { + const scripts = [ + { previewUrl: "http://localhost:5173" }, + {}, + { previewUrl: "http://localhost:3000" }, + ] as ProjectScript[]; + + expect(getConfiguredPreviewUrls(scripts)).toEqual([ + "http://localhost:5173", + "http://localhost:3000", + ]); + }); +}); diff --git a/apps/web/src/components/preview/previewEmptyStateLogic.ts b/apps/web/src/components/preview/previewEmptyStateLogic.ts new file mode 100644 index 00000000000..1ebd074032b --- /dev/null +++ b/apps/web/src/components/preview/previewEmptyStateLogic.ts @@ -0,0 +1,11 @@ +import type { PreviewSessionSnapshot, ProjectScript } from "@t3tools/contracts"; + +export function shouldShowPreviewEmptyState(snapshot: PreviewSessionSnapshot | null): boolean { + return snapshot === null || snapshot.navStatus._tag === "Idle"; +} + +export function getConfiguredPreviewUrls( + scripts: ReadonlyArray<ProjectScript> | undefined, +): ReadonlyArray<string> { + return scripts?.flatMap((script) => (script.previewUrl ? [script.previewUrl] : [])) ?? []; +} diff --git a/apps/web/src/components/preview/useDiscoveredLocalServers.test.ts b/apps/web/src/components/preview/useDiscoveredLocalServers.test.ts index ada9153b4b6..bb3b7cd6fa8 100644 --- a/apps/web/src/components/preview/useDiscoveredLocalServers.test.ts +++ b/apps/web/src/components/preview/useDiscoveredLocalServers.test.ts @@ -9,6 +9,7 @@ const scannerServer = (overrides: Partial<DiscoveredLocalServer>): DiscoveredLoc url: "http://localhost:5173", processName: "vite", pid: 1234, + terminal: null, ...overrides, }); diff --git a/apps/web/src/components/preview/useDiscoveredLocalServers.ts b/apps/web/src/components/preview/useDiscoveredLocalServers.ts index 370dd4cba67..118a56b9068 100644 --- a/apps/web/src/components/preview/useDiscoveredLocalServers.ts +++ b/apps/web/src/components/preview/useDiscoveredLocalServers.ts @@ -1,10 +1,10 @@ import type { DiscoveredLocalServer } from "@t3tools/contracts"; import { isLoopbackHost } from "@t3tools/shared/preview"; -import { useEffect, useMemo, useState } from "react"; +import { useMemo } from "react"; -import { ensureEnvironmentApi } from "~/environmentApi"; import type { EnvironmentId } from "@t3tools/contracts"; import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver"; +import { useDiscoveredPorts } from "~/portDiscoveryState"; export interface PreviewableServer extends DiscoveredLocalServer { source: "scanner" | "configured" | "recent"; @@ -22,23 +22,13 @@ interface UseDiscoveredLocalServersInput { } /** - * Subscribe to live localhost port scans, merge in configured / - * recently-seen URLs, and return a stable sorted list. Retains the scanner - * while mounted. + * Merge the environment-level port snapshot with configured / recently-seen + * URLs and return a stable sorted list. */ export function useDiscoveredLocalServers( input: UseDiscoveredLocalServersInput, ): ReadonlyArray<PreviewableServer> { - const [scannerSnapshot, setScannerSnapshot] = useState<ReadonlyArray<DiscoveredLocalServer>>([]); - - useEffect(() => { - const api = ensureEnvironmentApi(input.environmentId); - setScannerSnapshot([]); - const unsubscribe = api.preview.subscribePorts((next) => { - setScannerSnapshot(next.servers); - }); - return unsubscribe; - }, [input.environmentId]); + const scannerSnapshot = useDiscoveredPorts(input.environmentId); return useMemo( () => @@ -72,6 +62,7 @@ export function mergeServers(input: { url: parsed.url, processName: null, pid: null, + terminal: null, source: "configured", listening: false, }); @@ -87,6 +78,7 @@ export function mergeServers(input: { ...existing, processName: server.processName ?? existing.processName, pid: server.pid ?? existing.pid, + terminal: server.terminal ?? existing.terminal, listening: true, }); continue; @@ -105,6 +97,7 @@ export function mergeServers(input: { url: parsed.url, processName: null, pid: null, + terminal: null, source: "recent", listening: false, }); diff --git a/apps/web/src/components/preview/usePreviewBridge.ts b/apps/web/src/components/preview/usePreviewBridge.ts index 9eb6eebbbe3..4a3bf1de931 100644 --- a/apps/web/src/components/preview/usePreviewBridge.ts +++ b/apps/web/src/components/preview/usePreviewBridge.ts @@ -8,6 +8,7 @@ import type { } from "@t3tools/contracts"; import { useEffect, useRef } from "react"; +import { useBrowserPointerStore } from "~/browser/browserPointerStore"; import { ensureEnvironmentApi } from "~/environmentApi"; import { type DesktopPreviewOverlay, usePreviewStateStore } from "~/previewStateStore"; @@ -20,19 +21,26 @@ import { previewBridge } from "./previewBridge"; export function usePreviewBridge(input: { threadRef: ScopedThreadRef; tabId: string }): void { const { threadRef, tabId } = input; const applyDesktopState = usePreviewStateStore((state) => state.applyDesktopState); + const clearBrowserPointer = useBrowserPointerStore((state) => state.clear); const bridge = previewBridge; // One bridge subscription does both jobs (mirror state + forward to // server) so the desktop bridge keeps a single listener entry per tab. const lastReportedUrl = useRef<string | null>(null); const lastReportedKind = useRef<DesktopPreviewTabState["navStatus"]["kind"] | null>(null); + const lastDesktopNavStatus = useRef<DesktopPreviewTabState["navStatus"] | null>(null); useEffect(() => { if (!bridge || typeof window === "undefined") return; const api = ensureEnvironmentApi(threadRef.environmentId); lastReportedUrl.current = null; lastReportedKind.current = null; + lastDesktopNavStatus.current = null; const unsubscribe = bridge.onStateChange((changedTabId, state) => { if (changedTabId !== tabId) return; + if (shouldClearBrowserPointer(lastDesktopNavStatus.current, state.navStatus)) { + clearBrowserPointer(tabId); + } + lastDesktopNavStatus.current = state.navStatus; applyDesktopState(threadRef, tabId, projectDesktopState(state)); const reported = buildReportInput({ threadId: threadRef.threadId, @@ -47,7 +55,17 @@ export function usePreviewBridge(input: { threadRef: ScopedThreadRef; tabId: str void api.preview.reportStatus(reported.input).catch(() => undefined); }); return unsubscribe; - }, [applyDesktopState, bridge, tabId, threadRef]); + }, [applyDesktopState, bridge, clearBrowserPointer, tabId, threadRef]); +} + +function shouldClearBrowserPointer( + previous: DesktopPreviewTabState["navStatus"] | null, + current: DesktopPreviewTabState["navStatus"], +): boolean { + if (!previous) return false; + if (current.kind === "Loading" && previous.kind !== "Loading") return true; + if (current.kind === "Idle" || previous.kind === "Idle") return false; + return current.url !== previous.url; } function projectDesktopState(state: DesktopPreviewTabState): DesktopPreviewOverlay { diff --git a/apps/web/src/components/ui/toast.tsx b/apps/web/src/components/ui/toast.tsx index 9ec0c236462..2c2a554871a 100644 --- a/apps/web/src/components/ui/toast.tsx +++ b/apps/web/src/components/ui/toast.tsx @@ -45,6 +45,10 @@ export type ThreadToastData = { onClose?: (() => void) | undefined; dismissAfterVisibleMs?: number; hideCopyButton?: boolean; + additionalActions?: ReadonlyArray<{ + id: string; + props: ComponentPropsWithoutRef<"button">; + }>; secondaryActionProps?: ComponentPropsWithoutRef<"button">; secondaryActionVariant?: | "default" @@ -292,9 +296,13 @@ function deriveToastBodyDescriptor(toast: { toast.type === "error" && typeof toast.description === "string" && !toast.data?.hideCopyButton ? toast.description : null; + const hasAdditionalActions = (toast.data?.additionalActions?.length ?? 0) > 0; const hasSecondaryAction = toast.data?.secondaryActionProps !== undefined; const hasTrailingControls = - copyErrorText !== null || toast.actionProps !== undefined || hasSecondaryAction; + copyErrorText !== null || + toast.actionProps !== undefined || + hasAdditionalActions || + hasSecondaryAction; const inlineContentEndPad = hasTrailingControls ? "pr-6" : "pr-10"; return { Icon, @@ -326,6 +334,7 @@ function ToastBodyContent({ toastDescription, toastType, }: ToastBodyContentProps) { + const additionalActions = toastData?.additionalActions ?? []; const secondaryActionProps = toastData?.secondaryActionProps; const leadingIcon = toastData?.leadingIcon; const { className: secondaryActionClassName, ...secondaryActionRest } = @@ -371,6 +380,17 @@ function ToastBodyContent({ )} > {copyErrorText !== null ? <CopyErrorButton text={copyErrorText} /> : null} + {additionalActions.map(({ id, props: { className, ...props } }) => ( + <button + {...props} + className={cn( + buttonVariants({ size: "xs", variant: secondaryActionVariant }), + className, + )} + key={id} + type="button" + /> + ))} {secondaryActionProps ? ( <button {...secondaryActionRest} diff --git a/apps/web/src/environments/runtime/service.ts b/apps/web/src/environments/runtime/service.ts index 45aef9bacd6..e9a4389764b 100644 --- a/apps/web/src/environments/runtime/service.ts +++ b/apps/web/src/environments/runtime/service.ts @@ -90,6 +90,7 @@ import { const decodeIssuedBearerScopes = Schema.decodeUnknownSync(Schema.Array(AuthEnvironmentScope)); import { getClientSettings } from "~/hooks/useSettings"; import { subscribeTerminalMetadata, terminalSessionManager } from "../../terminalSessionState"; +import { subscribePortDiscovery, usePortDiscoveryStore } from "../../portDiscoveryState"; import { resetWsReconnectBackoff } from "~/rpc/wsConnectionState"; import { resolveRemotePairingTarget } from "@t3tools/shared/remote"; @@ -140,6 +141,7 @@ const lastAppliedProjectionVersionByEnvironment = new Map< } >(); const terminalMetadataSubscriptions = new Map<EnvironmentId, () => void>(); +const portDiscoverySubscriptions = new Map<EnvironmentId, () => void>(); let activeService: EnvironmentServiceState | null = null; let needsProviderInvalidation = false; @@ -1397,6 +1399,14 @@ function registerConnection(connection: EnvironmentConnection): EnvironmentConne client: connection.client, }), ); + portDiscoverySubscriptions.get(connection.environmentId)?.(); + portDiscoverySubscriptions.set( + connection.environmentId, + subscribePortDiscovery({ + environmentId: connection.environmentId, + previewApi: connection.client.preview, + }), + ); attachThreadDetailSubscriptionsForEnvironment(connection.environmentId); emitEnvironmentConnectionRegistryChange(); return connection; @@ -1412,6 +1422,9 @@ async function removeConnection(environmentId: EnvironmentId): Promise<boolean> environmentConnections.delete(environmentId); terminalMetadataSubscriptions.get(environmentId)?.(); terminalMetadataSubscriptions.delete(environmentId); + portDiscoverySubscriptions.get(environmentId)?.(); + portDiscoverySubscriptions.delete(environmentId); + usePortDiscoveryStore.getState().clearEnvironment(environmentId); terminalSessionManager.invalidateEnvironment(environmentId); emitEnvironmentConnectionRegistryChange(); detachThreadDetailSubscriptionsForEnvironment(environmentId); @@ -2048,6 +2061,11 @@ export async function resetEnvironmentServiceForTests(): Promise<void> { unsubscribe(); } terminalMetadataSubscriptions.clear(); + for (const unsubscribe of portDiscoverySubscriptions.values()) { + unsubscribe(); + } + portDiscoverySubscriptions.clear(); + usePortDiscoveryStore.getState().reset(); terminalSessionManager.reset(); await Promise.all( [...environmentConnections.keys()].map((environmentId) => removeConnection(environmentId)), diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index a0190ed9b5c..5563378da97 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -18,6 +18,7 @@ import { isTerminalCloseShortcut, isTerminalNewShortcut, isTerminalSplitShortcut, + isTerminalSplitVerticalShortcut, isTerminalToggleShortcut, resolveShortcutCommand, shouldShowModelPickerJumpHints, @@ -85,6 +86,7 @@ function compile(bindings: TestBinding[]): ResolvedKeybindingsConfig { const DEFAULT_BINDINGS = compile([ { shortcut: modShortcut("j"), command: "terminal.toggle" }, + { shortcut: modShortcut("b", { altKey: true }), command: "rightPanel.toggle" }, { shortcut: modShortcut("d"), command: "terminal.split", @@ -92,6 +94,11 @@ const DEFAULT_BINDINGS = compile([ }, { shortcut: modShortcut("d", { shiftKey: true }), + command: "terminal.splitVertical", + whenAst: whenIdentifier("terminalFocus"), + }, + { + shortcut: modShortcut("n"), command: "terminal.new", whenAst: whenIdentifier("terminalFocus"), }, @@ -174,7 +181,17 @@ describe("split/new/close terminal shortcuts", () => { }), ); assert.isFalse( - isTerminalNewShortcut(event({ key: "d", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, { + isTerminalSplitVerticalShortcut( + event({ key: "d", metaKey: true, shiftKey: true }), + DEFAULT_BINDINGS, + { + platform: "MacIntel", + context: { terminalFocus: false }, + }, + ), + ); + assert.isFalse( + isTerminalNewShortcut(event({ key: "n", ctrlKey: true }), DEFAULT_BINDINGS, { platform: "Linux", context: { terminalFocus: false }, }), @@ -195,7 +212,17 @@ describe("split/new/close terminal shortcuts", () => { }), ); assert.isTrue( - isTerminalNewShortcut(event({ key: "d", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, { + isTerminalSplitVerticalShortcut( + event({ key: "d", metaKey: true, shiftKey: true }), + DEFAULT_BINDINGS, + { + platform: "MacIntel", + context: { terminalFocus: true }, + }, + ), + ); + assert.isTrue( + isTerminalNewShortcut(event({ key: "n", ctrlKey: true }), DEFAULT_BINDINGS, { platform: "Linux", context: { terminalFocus: true }, }), @@ -287,6 +314,10 @@ describe("shortcutLabelForCommand", () => { it("returns effective labels for non-terminal commands", () => { assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "chat.new", "MacIntel"), "⇧⌘O"); assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "diff.toggle", "Linux"), "Ctrl+D"); + assert.strictEqual( + shortcutLabelForCommand(DEFAULT_BINDINGS, "rightPanel.toggle", "MacIntel"), + "⌥⌘B", + ); assert.strictEqual( shortcutLabelForCommand(DEFAULT_BINDINGS, "commandPalette.toggle", "MacIntel"), "⌘K", diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index f9603bdce6c..24ed02223d0 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -359,6 +359,14 @@ export function isTerminalSplitShortcut( return matchesCommandShortcut(event, keybindings, "terminal.split", options); } +export function isTerminalSplitVerticalShortcut( + event: ShortcutEventLike, + keybindings: ResolvedKeybindingsConfig, + options?: ShortcutMatchOptions, +): boolean { + return matchesCommandShortcut(event, keybindings, "terminal.splitVertical", options); +} + export function isTerminalNewShortcut( event: ShortcutEventLike, keybindings: ResolvedKeybindingsConfig, diff --git a/apps/web/src/lib/terminalFocus.test.ts b/apps/web/src/lib/terminalFocus.test.ts index fa6c7a5c954..83f26637d4e 100644 --- a/apps/web/src/lib/terminalFocus.test.ts +++ b/apps/web/src/lib/terminalFocus.test.ts @@ -1,10 +1,12 @@ import { afterEach, describe, expect, it } from "vite-plus/test"; -import { isTerminalFocused } from "./terminalFocus"; +import { getTerminalFocusOwner, isTerminalFocused } from "./terminalFocus"; class MockHTMLElement { isConnected = false; className = ""; + terminalOwner: string | null = null; + readonly dataset: { terminalOwner?: string } = {}; readonly classList = { contains: (value: string) => this.className.split(/\s+/).includes(value), @@ -14,7 +16,7 @@ class MockHTMLElement { if (!this.isConnected) { return null; } - if (selector === ".thread-terminal-drawer .xterm" || selector === ".thread-terminal-drawer") { + if (selector === "[data-terminal-owner]" && this.terminalOwner !== null) { return this; } return null; @@ -44,30 +46,36 @@ describe("isTerminalFocused", () => { detached.className = "xterm-helper-textarea"; globalThis.HTMLElement = MockHTMLElement as unknown as typeof HTMLElement; - globalThis.document = { activeElement: detached } as Document; + globalThis.document = { activeElement: detached } as unknown as Document; expect(isTerminalFocused()).toBe(false); }); - it("returns true for connected xterm helper textareas", () => { + it("returns the drawer owner for connected xterm helper textareas", () => { const attached = new MockHTMLElement(); attached.className = "xterm-helper-textarea"; attached.isConnected = true; + attached.terminalOwner = "drawer"; + attached.dataset.terminalOwner = "drawer"; globalThis.HTMLElement = MockHTMLElement as unknown as typeof HTMLElement; - globalThis.document = { activeElement: attached } as Document; + globalThis.document = { activeElement: attached } as unknown as Document; + expect(getTerminalFocusOwner()).toBe("drawer"); expect(isTerminalFocused()).toBe(true); }); - it("returns true for focus inside the terminal drawer (e.g. sidebar)", () => { + it("returns the right panel owner for focus inside its terminal UI", () => { const sidebarButton = new MockHTMLElement(); sidebarButton.className = "terminal-sidebar-button"; sidebarButton.isConnected = true; + sidebarButton.terminalOwner = "right-panel"; + sidebarButton.dataset.terminalOwner = "right-panel"; globalThis.HTMLElement = MockHTMLElement as unknown as typeof HTMLElement; - globalThis.document = { activeElement: sidebarButton } as Document; + globalThis.document = { activeElement: sidebarButton } as unknown as Document; + expect(getTerminalFocusOwner()).toBe("right-panel"); expect(isTerminalFocused()).toBe(true); }); }); diff --git a/apps/web/src/lib/terminalFocus.ts b/apps/web/src/lib/terminalFocus.ts index 61b969c2b82..158c7fb2c98 100644 --- a/apps/web/src/lib/terminalFocus.ts +++ b/apps/web/src/lib/terminalFocus.ts @@ -1,9 +1,14 @@ -export function isTerminalFocused(): boolean { +export type TerminalFocusOwner = "drawer" | "right-panel"; + +export function getTerminalFocusOwner(): TerminalFocusOwner | null { const activeElement = document.activeElement; - if (!(activeElement instanceof HTMLElement)) return false; - if (!activeElement.isConnected) return false; - if (activeElement.classList.contains("xterm-helper-textarea")) return true; - if (activeElement.closest(".thread-terminal-drawer .xterm") !== null) return true; - // Sidebar / toolbar / resize affordances: still "terminal UI" for split vs diff.toggle (⌘D). - return activeElement.closest(".thread-terminal-drawer") !== null; + if (!(activeElement instanceof HTMLElement)) return null; + if (!activeElement.isConnected) return null; + const owner = activeElement.closest<HTMLElement>("[data-terminal-owner]")?.dataset.terminalOwner; + if (owner === "drawer" || owner === "right-panel") return owner; + return null; +} + +export function isTerminalFocused(): boolean { + return getTerminalFocusOwner() !== null; } diff --git a/apps/web/src/portDiscoveryState.ts b/apps/web/src/portDiscoveryState.ts new file mode 100644 index 00000000000..8b7c4b1a1dc --- /dev/null +++ b/apps/web/src/portDiscoveryState.ts @@ -0,0 +1,86 @@ +import type { + DiscoveredLocalServer, + EnvironmentApi, + EnvironmentId, + ThreadId, +} from "@t3tools/contracts"; +import { useMemo } from "react"; +import { create } from "zustand"; + +const EMPTY_PORTS: ReadonlyArray<DiscoveredLocalServer> = Object.freeze([]); + +interface PortDiscoveryState { + readonly byEnvironment: Record<string, ReadonlyArray<DiscoveredLocalServer>>; + setPorts: (environmentId: EnvironmentId, ports: ReadonlyArray<DiscoveredLocalServer>) => void; + clearEnvironment: (environmentId: EnvironmentId) => void; + reset: () => void; +} + +export const usePortDiscoveryStore = create<PortDiscoveryState>((set) => ({ + byEnvironment: {}, + setPorts: (environmentId, ports) => + set((state) => ({ + byEnvironment: { + ...state.byEnvironment, + [environmentId]: ports, + }, + })), + clearEnvironment: (environmentId) => + set((state) => { + if (!(environmentId in state.byEnvironment)) return state; + const { [environmentId]: _removed, ...byEnvironment } = state.byEnvironment; + return { byEnvironment }; + }), + reset: () => set({ byEnvironment: {} }), +})); + +export function subscribePortDiscovery(input: { + readonly environmentId: EnvironmentId; + readonly previewApi: Pick<EnvironmentApi["preview"], "subscribePorts">; +}): () => void { + usePortDiscoveryStore.getState().clearEnvironment(input.environmentId); + return input.previewApi.subscribePorts((snapshot) => { + usePortDiscoveryStore.getState().setPorts(input.environmentId, snapshot.servers); + }); +} + +export function useDiscoveredPorts( + environmentId: EnvironmentId | null, +): ReadonlyArray<DiscoveredLocalServer> { + return usePortDiscoveryStore( + (state) => (environmentId ? state.byEnvironment[environmentId] : undefined) ?? EMPTY_PORTS, + ); +} + +export function useThreadDiscoveredPorts(input: { + readonly environmentId: EnvironmentId | null; + readonly threadId: ThreadId | null; +}): ReadonlyArray<DiscoveredLocalServer> { + const ports = useDiscoveredPorts(input.environmentId); + return useMemo( + () => + input.threadId + ? ports.filter((port) => port.terminal?.threadId === input.threadId) + : EMPTY_PORTS, + [input.threadId, ports], + ); +} + +export function useTerminalDiscoveredPorts(input: { + readonly environmentId: EnvironmentId | null; + readonly threadId: ThreadId | null; + readonly terminalId: string | null; +}): ReadonlyArray<DiscoveredLocalServer> { + const ports = useDiscoveredPorts(input.environmentId); + return useMemo( + () => + input.threadId && input.terminalId + ? ports.filter( + (port) => + port.terminal?.threadId === input.threadId && + port.terminal.terminalId === input.terminalId, + ) + : EMPTY_PORTS, + [input.terminalId, input.threadId, ports], + ); +} diff --git a/apps/web/src/previewStateStore.test.ts b/apps/web/src/previewStateStore.test.ts index 9a1722380f8..3adb954cb98 100644 --- a/apps/web/src/previewStateStore.test.ts +++ b/apps/web/src/previewStateStore.test.ts @@ -154,6 +154,42 @@ describe("previewStateStore (single-tab)", () => { expect(state.recentlySeenUrls).toContain("http://localhost:5173/"); }); + it("optimistically removes a session before the server close event arrives", () => { + const first = makeSnapshot({ tabId: "tab_a" }); + const second = makeSnapshot({ + tabId: "tab_b", + updatedAt: "2026-01-01T00:00:01.000Z", + }); + const store = usePreviewStateStore.getState(); + store.applyServerSnapshot(ref, first); + store.applyServerSnapshot(ref, second); + + store.removeSession(ref, second.tabId); + + const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); + expect(Object.keys(state.sessions)).toEqual([first.tabId]); + expect(state.activeTabId).toBe(first.tabId); + expect(state.snapshot?.tabId).toBe(first.tabId); + }); + + it("treats a late server close event after optimistic removal as a no-op", () => { + const snapshot = makeSnapshot(); + const store = usePreviewStateStore.getState(); + store.applyServerSnapshot(ref, snapshot); + store.removeSession(ref, snapshot.tabId); + + store.applyServerEvent(ref, { + type: "closed", + threadId: "thread-1", + tabId: snapshot.tabId, + createdAt: "2026-01-01T00:00:01.000Z", + }); + + const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); + expect(state.sessions).toEqual({}); + expect(state.snapshot).toBeNull(); + }); + it("closed event for a different tab is a no-op", () => { const snapshot = makeSnapshot({ tabId: "tab_a" }); const store = usePreviewStateStore.getState(); diff --git a/apps/web/src/previewStateStore.ts b/apps/web/src/previewStateStore.ts index e9a9fc695b0..d6cacda3af7 100644 --- a/apps/web/src/previewStateStore.ts +++ b/apps/web/src/previewStateStore.ts @@ -58,6 +58,7 @@ export interface PreviewStateStoreState { tabId: string, overlay: DesktopPreviewOverlay | null, ) => void; + removeSession: (ref: ScopedThreadRef, tabId: string) => void; setActiveTab: (ref: ScopedThreadRef, tabId: string) => void; rememberUrl: (ref: ScopedThreadRef, url: string) => void; removeThread: (ref: ScopedThreadRef) => void; @@ -93,6 +94,27 @@ const dedupeRecentUrls = (existing: string[], url: string): string[] => { return next.slice(0, PREVIEW_RECENT_URL_LIMIT); }; +const removeSession = (current: ThreadPreviewState, tabId: string): ThreadPreviewState => { + if (!current.sessions[tabId]) return current; + const { [tabId]: _closed, ...sessions } = current.sessions; + const { [tabId]: _desktop, ...desktopByTabId } = current.desktopByTabId; + const nextSnapshot = + Object.values(sessions) + .toSorted((a, b) => a.updatedAt.localeCompare(b.updatedAt)) + .at(-1) ?? null; + const activeTabId = + current.activeTabId === tabId ? (nextSnapshot?.tabId ?? null) : current.activeTabId; + const snapshot = activeTabId ? (sessions[activeTabId] ?? nextSnapshot) : nextSnapshot; + return { + ...current, + sessions, + desktopByTabId, + activeTabId: snapshot?.tabId ?? null, + snapshot, + desktopOverlay: snapshot ? (desktopByTabId[snapshot.tabId] ?? null) : null, + }; +}; + export const usePreviewStateStore = create<PreviewStateStoreState>()((set) => ({ byThreadKey: {}, applyServerEvent: (ref, event) => @@ -145,28 +167,9 @@ export const usePreviewStateStore = create<PreviewStateStoreState>()((set) => ({ }); break; case "closed": - nextByThread = updateThread(state, threadKey, (current) => { - if (!current.sessions[event.tabId]) return current; - const { [event.tabId]: _closed, ...sessions } = current.sessions; - const { [event.tabId]: _desktop, ...desktopByTabId } = current.desktopByTabId; - const nextSnapshot = - Object.values(sessions) - .toSorted((a, b) => a.updatedAt.localeCompare(b.updatedAt)) - .at(-1) ?? null; - const activeTabId = - current.activeTabId === event.tabId - ? (nextSnapshot?.tabId ?? null) - : current.activeTabId; - const snapshot = activeTabId ? (sessions[activeTabId] ?? nextSnapshot) : nextSnapshot; - return { - ...current, - sessions, - desktopByTabId, - activeTabId: snapshot?.tabId ?? null, - snapshot, - desktopOverlay: snapshot ? (desktopByTabId[snapshot.tabId] ?? null) : null, - }; - }); + nextByThread = updateThread(state, threadKey, (current) => + removeSession(current, event.tabId), + ); break; } return { byThreadKey: nextByThread }; @@ -216,6 +219,13 @@ export const usePreviewStateStore = create<PreviewStateStoreState>()((set) => ({ }); return { byThreadKey: nextByThread }; }), + removeSession: (ref, tabId) => + set((state) => { + const threadKey = scopedThreadKey(ref); + return { + byThreadKey: updateThread(state, threadKey, (current) => removeSession(current, tabId)), + }; + }), setActiveTab: (ref, tabId) => set((state) => { const threadKey = scopedThreadKey(ref); diff --git a/apps/web/src/rightPanelStore.test.ts b/apps/web/src/rightPanelStore.test.ts index 1820898cbab..9b66855ff8f 100644 --- a/apps/web/src/rightPanelStore.test.ts +++ b/apps/web/src/rightPanelStore.test.ts @@ -3,6 +3,7 @@ import { type EnvironmentId, ThreadId } from "@t3tools/contracts"; import { beforeEach, describe, expect, it } from "vite-plus/test"; import { + migratePersistedRightPanelState, selectActiveRightPanel, selectActiveRightPanelSurface, selectActiveRightPanelKindWithUrl, @@ -18,6 +19,60 @@ beforeEach(() => { }); describe("rightPanelStore", () => { + it("drops the legacy singleton terminal surface during migration", () => { + expect( + migratePersistedRightPanelState({ + byThreadKey: { + "env-1:thread-A": { + activeSurfaceId: "terminal", + surfaces: [ + { id: "browser:tab-a", kind: "preview", resourceId: "tab-a" }, + { id: "terminal", kind: "terminal" }, + ], + }, + }, + }), + ).toEqual({ + byThreadKey: { + "env-1:thread-A": { + isOpen: false, + activeSurfaceId: null, + surfaces: [{ id: "browser:tab-a", kind: "preview", resourceId: "tab-a" }], + }, + }, + }); + }); + + it("upgrades saved single-session terminal surfaces to split-capable surfaces", () => { + expect( + migratePersistedRightPanelState({ + byThreadKey: { + "env-1:thread-A": { + isOpen: true, + activeSurfaceId: "terminal:term-1", + surfaces: [{ id: "terminal:term-1", kind: "terminal", resourceId: "term-1" }], + }, + }, + }), + ).toEqual({ + byThreadKey: { + "env-1:thread-A": { + isOpen: true, + activeSurfaceId: "terminal:term-1", + surfaces: [ + { + id: "terminal:term-1", + kind: "terminal", + resourceId: "term-1", + terminalIds: ["term-1"], + activeTerminalId: "term-1", + }, + ], + }, + }, + }); + }); + it("open sets the active panel for a thread", () => { useRightPanelStore.getState().open(refA, "preview"); expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("preview"); @@ -33,10 +88,27 @@ describe("rightPanelStore", () => { ).toHaveLength(2); }); - it("close clears the active panel", () => { + it("close hides the panel without clearing its selected surface", () => { useRightPanelStore.getState().open(refA, "plan"); useRightPanelStore.getState().close(refA); expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBeNull(); + expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({ + isOpen: false, + activeSurfaceId: "plan", + surfaces: [{ id: "plan", kind: "plan" }], + }); + }); + + it("toggles empty panel visibility without creating a surface", () => { + useRightPanelStore.getState().toggleVisibility(refA); + expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({ + isOpen: true, + activeSurfaceId: null, + surfaces: [], + }); + + useRightPanelStore.getState().toggleVisibility(refA); + expect(useRightPanelStore.getState().byThreadKey).toEqual({}); }); it("toggle opens then closes the same kind", () => { @@ -86,18 +158,101 @@ describe("rightPanelStore", () => { }); }); + it("tracks one surface per terminal session", () => { + useRightPanelStore.getState().openTerminal(refA, "term-1"); + useRightPanelStore.getState().openTerminal(refA, "term-2"); + + const state = selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA); + expect(state.surfaces).toEqual([ + { + id: "terminal:term-1", + kind: "terminal", + resourceId: "term-1", + terminalIds: ["term-1"], + activeTerminalId: "term-1", + }, + { + id: "terminal:term-2", + kind: "terminal", + resourceId: "term-2", + terminalIds: ["term-2"], + activeTerminalId: "term-2", + }, + ]); + expect(state.activeSurfaceId).toBe("terminal:term-2"); + }); + + it("tracks split panes and the active pane within a terminal surface", () => { + useRightPanelStore.getState().openTerminal(refA, "term-1"); + useRightPanelStore.getState().splitTerminal(refA, "terminal:term-1", "term-2"); + + expect(selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, refA)).toEqual({ + id: "terminal:term-1", + kind: "terminal", + resourceId: "term-1", + terminalIds: ["term-1", "term-2"], + activeTerminalId: "term-2", + }); + + useRightPanelStore.getState().activateTerminal(refA, "terminal:term-1", "term-1"); + useRightPanelStore.getState().closeTerminal(refA, "terminal:term-1", "term-1"); + expect(selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, refA)).toEqual({ + id: "terminal:term-1", + kind: "terminal", + resourceId: "term-1", + terminalIds: ["term-2"], + activeTerminalId: "term-2", + }); + }); + + it("tracks vertical layout for a terminal surface", () => { + useRightPanelStore.getState().openTerminal(refA, "term-1"); + useRightPanelStore.getState().splitTerminal(refA, "terminal:term-1", "term-2", "vertical"); + + expect(selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, refA)).toEqual({ + id: "terminal:term-1", + kind: "terminal", + resourceId: "term-1", + terminalIds: ["term-1", "term-2"], + activeTerminalId: "term-2", + splitDirection: "vertical", + }); + }); + + it("closing the final terminal pane removes its surface but keeps the panel open", () => { + useRightPanelStore.getState().openTerminal(refA, "term-1"); + useRightPanelStore.getState().closeTerminal(refA, "terminal:term-1", "term-1"); + + expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({ + isOpen: true, + activeSurfaceId: null, + surfaces: [], + }); + }); + it("closing the active surface activates a neighboring surface", () => { useRightPanelStore.getState().openBrowser(refA, "tab-a"); - useRightPanelStore.getState().open(refA, "terminal"); - useRightPanelStore.getState().closeSurface(refA, "terminal"); + useRightPanelStore.getState().openTerminal(refA, "term-1"); + useRightPanelStore.getState().closeSurface(refA, "terminal:term-1"); expect(selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, refA)?.id).toBe( "browser:tab-a", ); }); + it("closing the final surface leaves the panel open and empty", () => { + useRightPanelStore.getState().openTerminal(refA, "term-1"); + useRightPanelStore.getState().closeSurface(refA, "terminal:term-1"); + + expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({ + isOpen: true, + activeSurfaceId: null, + surfaces: [], + }); + }); + it("reconciles browser surfaces without deleting other surface kinds", () => { - useRightPanelStore.getState().open(refA, "terminal"); + useRightPanelStore.getState().openTerminal(refA, "term-1"); useRightPanelStore.getState().openBrowser(refA, "tab-a"); useRightPanelStore.getState().openBrowser(refA, "tab-b"); useRightPanelStore.getState().reconcileBrowserSurfaces(refA, ["tab-b", "tab-c"]); @@ -106,6 +261,6 @@ describe("rightPanelStore", () => { selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA).surfaces.map( (surface) => surface.id, ), - ).toEqual(["terminal", "browser:tab-b", "browser:tab-c"]); + ).toEqual(["terminal:term-1", "browser:tab-b", "browser:tab-c"]); }); }); diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts index c5e0b23a60e..7bcf38fb1dd 100644 --- a/apps/web/src/rightPanelStore.ts +++ b/apps/web/src/rightPanelStore.ts @@ -3,8 +3,9 @@ * * This is intentionally a shallow workspace model: it owns an ordered set of * surface descriptors and the active surface, while each feature continues to - * own its durable resource state. Browser surfaces point at preview tab ids; - * singleton surfaces bridge the existing terminal, diff, and plan features. + * own its durable resource state. Browser surfaces point at preview tab ids, + * terminal surfaces point at terminal session ids, and diff/plan remain + * singleton surfaces. */ import { scopedThreadKey } from "@t3tools/client-runtime"; import type { ScopedThreadRef } from "@t3tools/contracts"; @@ -19,35 +20,59 @@ export type RightPanelKind = (typeof RIGHT_PANEL_KINDS)[number]; export type RightPanelSurface = | { id: `browser:${string}`; kind: "preview"; resourceId: string } | { id: "browser:new"; kind: "preview"; resourceId: null } - | { id: "terminal"; kind: "terminal" } + | { + id: `terminal:${string}`; + kind: "terminal"; + resourceId: string; + terminalIds: string[]; + activeTerminalId: string; + splitDirection?: "horizontal" | "vertical"; + } | { id: "diff"; kind: "diff" } | { id: "plan"; kind: "plan" }; const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v2"; +const RIGHT_PANEL_STORAGE_VERSION = 5; export interface ThreadRightPanelState { + isOpen: boolean; activeSurfaceId: string | null; surfaces: RightPanelSurface[]; } interface RightPanelStoreState { byThreadKey: Record<string, ThreadRightPanelState>; - open: (ref: ScopedThreadRef, kind: RightPanelKind) => void; + open: (ref: ScopedThreadRef, kind: Exclude<RightPanelKind, "terminal">) => void; openBrowser: (ref: ScopedThreadRef, tabId: string | null) => void; + openTerminal: (ref: ScopedThreadRef, terminalId: string) => void; + splitTerminal: ( + ref: ScopedThreadRef, + surfaceId: string, + terminalId: string, + direction?: "horizontal" | "vertical", + ) => void; + activateTerminal: (ref: ScopedThreadRef, surfaceId: string, terminalId: string) => void; + closeTerminal: (ref: ScopedThreadRef, surfaceId: string, terminalId: string) => void; activateSurface: (ref: ScopedThreadRef, surfaceId: string) => void; closeSurface: (ref: ScopedThreadRef, surfaceId: string) => void; reconcileBrowserSurfaces: (ref: ScopedThreadRef, tabIds: readonly string[]) => void; + show: (ref: ScopedThreadRef) => void; close: (ref: ScopedThreadRef) => void; - toggle: (ref: ScopedThreadRef, kind: RightPanelKind) => void; + toggleVisibility: (ref: ScopedThreadRef) => void; + toggle: (ref: ScopedThreadRef, kind: Exclude<RightPanelKind, "terminal">) => void; removeThread: (ref: ScopedThreadRef) => void; } -const EMPTY_THREAD_STATE: ThreadRightPanelState = { activeSurfaceId: null, surfaces: [] }; +const EMPTY_THREAD_STATE: ThreadRightPanelState = { + isOpen: false, + activeSurfaceId: null, + surfaces: [], +}; -const singletonSurface = (kind: Exclude<RightPanelKind, "preview">): RightPanelSurface => { +const singletonSurface = ( + kind: Exclude<RightPanelKind, "preview" | "terminal">, +): RightPanelSurface => { switch (kind) { - case "terminal": - return { id: "terminal", kind }; case "diff": return { id: "diff", kind }; case "plan": @@ -60,11 +85,20 @@ const browserSurface = (tabId: string | null): RightPanelSurface => ? { id: `browser:${tabId}`, kind: "preview", resourceId: tabId } : { id: "browser:new", kind: "preview", resourceId: null }; +const terminalSurface = (terminalId: string): RightPanelSurface => ({ + id: `terminal:${terminalId}`, + kind: "terminal", + resourceId: terminalId, + terminalIds: [terminalId], + activeTerminalId: terminalId, +}); + const upsertSurface = ( current: ThreadRightPanelState, surface: RightPanelSurface, activate = true, ): ThreadRightPanelState => ({ + isOpen: true, surfaces: current.surfaces.some((entry) => entry.id === surface.id) ? current.surfaces : [...current.surfaces, surface], @@ -78,7 +112,7 @@ const updateThread = ( ): Record<string, ThreadRightPanelState> => { const current = byThreadKey[threadKey] ?? EMPTY_THREAD_STATE; const next = updater(current); - if (next.activeSurfaceId === null && next.surfaces.length === 0) { + if (!next.isOpen && next.activeSurfaceId === null && next.surfaces.length === 0) { if (!(threadKey in byThreadKey)) return byThreadKey; const { [threadKey]: _removed, ...rest } = byThreadKey; return rest; @@ -87,6 +121,74 @@ const updateThread = ( return { ...byThreadKey, [threadKey]: next }; }; +export function migratePersistedRightPanelState(persistedState: unknown): { + byThreadKey: Record<string, ThreadRightPanelState>; +} { + if (!persistedState || typeof persistedState !== "object") { + return { byThreadKey: {} }; + } + const byThreadKey = + "byThreadKey" in persistedState && + persistedState.byThreadKey && + typeof persistedState.byThreadKey === "object" + ? Object.fromEntries( + Object.entries(persistedState.byThreadKey as Record<string, ThreadRightPanelState>).map( + ([threadKey, threadState]) => { + const validThreadState = + threadState && typeof threadState === "object" ? threadState : null; + const surfaces = Array.isArray(validThreadState?.surfaces) + ? validThreadState.surfaces.flatMap<RightPanelSurface>((surface) => { + if (surface.kind !== "terminal") return [surface]; + if ( + !("resourceId" in surface) || + typeof surface.resourceId !== "string" || + surface.id !== `terminal:${surface.resourceId}` + ) { + return []; + } + const terminalIds = + "terminalIds" in surface && Array.isArray(surface.terminalIds) + ? [ + ...new Set( + surface.terminalIds.filter( + (terminalId): terminalId is string => + typeof terminalId === "string", + ), + ), + ] + : [surface.resourceId]; + const activeTerminalId = + "activeTerminalId" in surface && + typeof surface.activeTerminalId === "string" && + terminalIds.includes(surface.activeTerminalId) + ? surface.activeTerminalId + : (terminalIds[0] ?? surface.resourceId); + return [ + { + ...surface, + terminalIds: terminalIds.length > 0 ? terminalIds : [surface.resourceId], + activeTerminalId, + }, + ]; + }) + : []; + const activeSurfaceId = surfaces.some( + (surface) => surface.id === validThreadState?.activeSurfaceId, + ) + ? (validThreadState?.activeSurfaceId ?? null) + : null; + const isOpen = + typeof validThreadState?.isOpen === "boolean" + ? validThreadState.isOpen + : activeSurfaceId !== null; + return [threadKey, { isOpen, surfaces, activeSurfaceId }]; + }, + ), + ) + : {}; + return { byThreadKey }; +} + export const useRightPanelStore = create<RightPanelStoreState>()( persist( (set) => ({ @@ -111,11 +213,89 @@ export const useRightPanelStore = create<RightPanelStoreState>()( return upsertSurface({ ...current, surfaces: withoutPlaceholder }, surface); }), })), + openTerminal: (ref, terminalId) => + set((state) => ({ + byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => + upsertSurface(current, terminalSurface(terminalId)), + ), + })), + splitTerminal: (ref, surfaceId, terminalId, direction = "horizontal") => + set((state) => ({ + byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => ({ + ...current, + isOpen: true, + activeSurfaceId: surfaceId, + surfaces: current.surfaces.map((surface) => { + if (surface.id !== surfaceId || surface.kind !== "terminal") return surface; + const { splitDirection: _splitDirection, ...baseSurface } = surface; + return { + ...baseSurface, + terminalIds: surface.terminalIds.includes(terminalId) + ? surface.terminalIds + : [...surface.terminalIds, terminalId], + activeTerminalId: terminalId, + ...(direction === "vertical" ? { splitDirection: "vertical" as const } : {}), + }; + }), + })), + })), + activateTerminal: (ref, surfaceId, terminalId) => + set((state) => ({ + byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => ({ + ...current, + activeSurfaceId: surfaceId, + surfaces: current.surfaces.map((surface) => + surface.id === surfaceId && + surface.kind === "terminal" && + surface.terminalIds.includes(terminalId) + ? { ...surface, activeTerminalId: terminalId } + : surface, + ), + })), + })), + closeTerminal: (ref, surfaceId, terminalId) => + set((state) => ({ + byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => { + const surface = current.surfaces.find( + (entry) => entry.id === surfaceId && entry.kind === "terminal", + ); + if (!surface || surface.kind !== "terminal") return current; + const terminalIds = surface.terminalIds.filter((id) => id !== terminalId); + if (terminalIds.length === 0) { + const index = current.surfaces.findIndex((entry) => entry.id === surfaceId); + const surfaces = current.surfaces.filter((entry) => entry.id !== surfaceId); + const fallback = surfaces[Math.min(index, surfaces.length - 1)] ?? null; + return { + ...current, + surfaces, + activeSurfaceId: + current.activeSurfaceId === surfaceId + ? (fallback?.id ?? null) + : current.activeSurfaceId, + }; + } + return { + ...current, + surfaces: current.surfaces.map((entry) => + entry.id === surfaceId && entry.kind === "terminal" + ? { + ...entry, + terminalIds, + activeTerminalId: + entry.activeTerminalId === terminalId + ? (terminalIds.at(-1) ?? terminalIds[0]!) + : entry.activeTerminalId, + } + : entry, + ), + }; + }), + })), activateSurface: (ref, surfaceId) => set((state) => ({ byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => current.surfaces.some((surface) => surface.id === surfaceId) - ? { ...current, activeSurfaceId: surfaceId } + ? { ...current, isOpen: true, activeSurfaceId: surfaceId } : current, ), })), @@ -127,7 +307,7 @@ export const useRightPanelStore = create<RightPanelStoreState>()( const surfaces = current.surfaces.filter((surface) => surface.id !== surfaceId); if (current.activeSurfaceId !== surfaceId) return { ...current, surfaces }; const fallback = surfaces[Math.min(index, surfaces.length - 1)] ?? null; - return { surfaces, activeSurfaceId: fallback?.id ?? null }; + return { ...current, surfaces, activeSurfaceId: fallback?.id ?? null }; }), })), reconcileBrowserSurfaces: (ref, tabIds) => @@ -151,6 +331,7 @@ export const useRightPanelStore = create<RightPanelStoreState>()( ); const fallbackBrowser = surfaces.find((surface) => surface.kind === "preview"); return { + ...current, surfaces, activeSurfaceId: activeStillExists ? current.activeSurfaceId @@ -158,11 +339,23 @@ export const useRightPanelStore = create<RightPanelStoreState>()( }; }), })), + show: (ref) => + set((state) => ({ + byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => + current.isOpen ? current : { ...current, isOpen: true }, + ), + })), close: (ref) => + set((state) => ({ + byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => + current.isOpen ? { ...current, isOpen: false } : current, + ), + })), + toggleVisibility: (ref) => set((state) => ({ byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => ({ ...current, - activeSurfaceId: null, + isOpen: !current.isOpen, })), })), toggle: (ref, kind) => @@ -171,7 +364,9 @@ export const useRightPanelStore = create<RightPanelStoreState>()( const active = current.surfaces.find( (surface) => surface.id === current.activeSurfaceId, ); - if (active?.kind === kind) return { ...current, activeSurfaceId: null }; + if (current.isOpen && active?.kind === kind) { + return { ...current, isOpen: false }; + } if (kind === "preview") { const existing = current.surfaces.find((surface) => surface.kind === "preview"); return upsertSurface(current, existing ?? browserSurface(null)); @@ -189,11 +384,12 @@ export const useRightPanelStore = create<RightPanelStoreState>()( }), { name: RIGHT_PANEL_STORAGE_KEY, - version: 2, + version: RIGHT_PANEL_STORAGE_VERSION, storage: createJSONStorage(() => resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined), ), partialize: (state) => ({ byThreadKey: state.byThreadKey }), + migrate: migratePersistedRightPanelState, }, ), ); @@ -211,6 +407,7 @@ export function selectActiveRightPanel( ref: ScopedThreadRef | null | undefined, ): RightPanelKind | null { const state = selectThreadRightPanelState(byThreadKey, ref); + if (!state.isOpen) return null; return state.surfaces.find((surface) => surface.id === state.activeSurfaceId)?.kind ?? null; } @@ -219,6 +416,7 @@ export function selectActiveRightPanelSurface( ref: ScopedThreadRef | null | undefined, ): RightPanelSurface | null { const state = selectThreadRightPanelState(byThreadKey, ref); + if (!state.isOpen) return null; return state.surfaces.find((surface) => surface.id === state.activeSurfaceId) ?? null; } @@ -227,6 +425,7 @@ export function selectActiveRightPanelKindWithUrl( ref: ScopedThreadRef | null | undefined, diffSearchActive: boolean, ): RightPanelKind | null { + if (!selectThreadRightPanelState(byThreadKey, ref).isOpen) return null; if (diffSearchActive) return "diff"; return selectActiveRightPanel(byThreadKey, ref); } diff --git a/apps/web/src/rpc/requestLatencyState.test.ts b/apps/web/src/rpc/requestLatencyState.test.ts index 3f3ccc71fd9..504c93e1f78 100644 --- a/apps/web/src/rpc/requestLatencyState.test.ts +++ b/apps/web/src/rpc/requestLatencyState.test.ts @@ -1,3 +1,4 @@ +import { WS_METHODS } from "@t3tools/contracts"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { @@ -50,6 +51,13 @@ describe("requestLatencyState", () => { expect(getSlowRpcAckRequests()).toEqual([]); }); + it("ignores the long-lived preview automation connection", () => { + trackRpcRequestSent("1", WS_METHODS.previewAutomationConnect); + vi.advanceTimersByTime(SLOW_RPC_ACK_THRESHOLD_MS * 2); + + expect(getSlowRpcAckRequests()).toEqual([]); + }); + it("evicts the oldest pending requests once the tracker reaches capacity", () => { for (let index = 0; index < MAX_TRACKED_RPC_ACK_REQUESTS + 1; index += 1) { trackRpcRequestSent(String(index), "server.getConfig"); diff --git a/apps/web/src/rpc/requestLatencyState.ts b/apps/web/src/rpc/requestLatencyState.ts index ecc3b88275c..c30ffc88279 100644 --- a/apps/web/src/rpc/requestLatencyState.ts +++ b/apps/web/src/rpc/requestLatencyState.ts @@ -1,4 +1,5 @@ import { useAtomValue } from "@effect/atom-react"; +import { WS_METHODS } from "@t3tools/contracts"; import { Atom } from "effect/unstable/reactivity"; import { appAtomRegistry } from "./atomRegistry"; @@ -21,6 +22,7 @@ interface PendingRpcAckRequest { } const pendingRpcAckRequests = new Map<string, PendingRpcAckRequest>(); +const untrackedRpcAckTags = new Set<string>([WS_METHODS.previewAutomationConnect]); const slowRpcAckRequestsAtom = Atom.make<ReadonlyArray<SlowRpcAckRequest>>([]).pipe( Atom.keepAlive, @@ -36,7 +38,7 @@ function getSlowRpcAckRequestsValue(): ReadonlyArray<SlowRpcAckRequest> { } function shouldTrackRpcAck(tag: string): boolean { - return !tag.includes("subscribe"); + return !tag.includes("subscribe") && !untrackedRpcAckTags.has(tag); } export function getSlowRpcAckRequests(): ReadonlyArray<SlowRpcAckRequest> { diff --git a/apps/web/src/terminalUiStateStore.test.ts b/apps/web/src/terminalUiStateStore.test.ts index 5782961fdd7..c4d4e9ff8a6 100644 --- a/apps/web/src/terminalUiStateStore.test.ts +++ b/apps/web/src/terminalUiStateStore.test.ts @@ -56,6 +56,24 @@ describe("terminalUiStateStore actions", () => { ]); }); + it("stacks vertically split terminals in the active group", () => { + const store = useTerminalUiStateStore.getState(); + store.setTerminalOpen(THREAD_REF, true); + store.splitTerminalVertical(THREAD_REF, "terminal-2"); + + const terminalUiState = selectThreadTerminalUiState( + useTerminalUiStateStore.getState().terminalUiStateByThreadKey, + THREAD_REF, + ); + expect(terminalUiState.terminalGroups).toEqual([ + { + id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, + terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "terminal-2"], + splitDirection: "vertical", + }, + ]); + }); + it("materializes the default terminal when opening an empty drawer", () => { useTerminalUiStateStore.getState().setTerminalOpen(THREAD_REF, true); diff --git a/apps/web/src/terminalUiStateStore.ts b/apps/web/src/terminalUiStateStore.ts index 6c10ae448e0..e5262bfcf7c 100644 --- a/apps/web/src/terminalUiStateStore.ts +++ b/apps/web/src/terminalUiStateStore.ts @@ -126,6 +126,7 @@ function normalizeTerminalGroups( nextGroups.push({ id: assignUniqueGroupId(baseGroupId, usedGroupIds), terminalIds: groupTerminalIds, + ...(group.splitDirection === "vertical" ? { splitDirection: "vertical" as const } : {}), }); } @@ -155,6 +156,11 @@ function terminalGroupsEqual(left: ThreadTerminalGroup[], right: ThreadTerminalG const rightGroup = right[index]; if (!leftGroup || !rightGroup) return false; if (leftGroup.id !== rightGroup.id) return false; + if ( + (leftGroup.splitDirection ?? "horizontal") !== (rightGroup.splitDirection ?? "horizontal") + ) { + return false; + } if (!arraysEqual(leftGroup.terminalIds, rightGroup.terminalIds)) return false; } return true; @@ -241,6 +247,7 @@ function copyTerminalGroups(groups: ThreadTerminalGroup[]): ThreadTerminalGroup[ return groups.map((group) => ({ id: group.id, terminalIds: [...group.terminalIds], + ...(group.splitDirection === "vertical" ? { splitDirection: "vertical" as const } : {}), })); } @@ -248,6 +255,7 @@ function upsertTerminalIntoGroups( state: ThreadTerminalUiState, terminalId: string, mode: "split" | "new", + splitDirection: "horizontal" | "vertical" = "horizontal", ): ThreadTerminalUiState { const normalized = normalizeThreadTerminalUiState(state); const effectiveMode: "split" | "new" = normalized.terminalIds.length === 0 ? "new" : mode; @@ -323,6 +331,11 @@ function upsertTerminalIntoGroups( destinationGroup.terminalIds.push(terminalId); } } + if (splitDirection === "vertical") { + destinationGroup.splitDirection = "vertical"; + } else { + delete destinationGroup.splitDirection; + } return normalizeThreadTerminalUiState({ ...normalized, @@ -357,8 +370,9 @@ function setThreadTerminalHeight( function splitThreadTerminal( state: ThreadTerminalUiState, terminalId: string, + direction: "horizontal" | "vertical" = "horizontal", ): ThreadTerminalUiState { - return upsertTerminalIntoGroups(state, terminalId, "split"); + return upsertTerminalIntoGroups(state, terminalId, "split", direction); } function newThreadTerminal( @@ -510,6 +524,7 @@ interface TerminalUiStateStoreState { setTerminalOpen: (threadRef: ScopedThreadRef, open: boolean) => void; setTerminalHeight: (threadRef: ScopedThreadRef, height: number) => void; splitTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void; + splitTerminalVertical: (threadRef: ScopedThreadRef, terminalId: string) => void; newTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void; ensureTerminal: ( threadRef: ScopedThreadRef, @@ -554,6 +569,8 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()( updateTerminal(threadRef, (state) => setThreadTerminalHeight(state, height)), splitTerminal: (threadRef, terminalId) => updateTerminal(threadRef, (state) => splitThreadTerminal(state, terminalId)), + splitTerminalVertical: (threadRef, terminalId) => + updateTerminal(threadRef, (state) => splitThreadTerminal(state, terminalId, "vertical")), newTerminal: (threadRef, terminalId) => updateTerminal(threadRef, (state) => newThreadTerminal(state, terminalId)), ensureTerminal: (threadRef, terminalId, options) => diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index c2e4b235e21..d508e3c6010 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -30,6 +30,7 @@ export type ProjectScript = ContractProjectScript; export interface ThreadTerminalGroup { id: string; terminalIds: string[]; + splitDirection?: "horizontal" | "vertical"; } export interface ChatImageAttachment { diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 00c5f130c18..4236a5ff2a7 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -455,6 +455,15 @@ export interface DesktopPreviewTabState { updatedAt: string; } +export interface DesktopPreviewPointerEvent { + tabId: string; + phase: "move" | "click"; + x: number; + y: number; + sequence: number; + createdAt: string; +} + /** * Static config a renderer needs to mount a preview `<webview>`. Returned * atomically by `DesktopPreviewBridge.getPreviewConfig()` so the renderer @@ -478,6 +487,26 @@ export interface DesktopPreviewWebviewConfig { preloadUrl: string | null; } +export interface DesktopPreviewAnnotationTheme { + colorScheme: "light" | "dark"; + radius: string; + background: string; + foreground: string; + popover: string; + popoverForeground: string; + primary: string; + primaryForeground: string; + muted: string; + mutedForeground: string; + accent: string; + accentForeground: string; + border: string; + input: string; + ring: string; + fontSans: string; + fontMono: string; +} + export interface DesktopPreviewRecordingFrame { tabId: string; data: string; @@ -700,6 +729,7 @@ export interface DesktopPreviewBridge { * the contract + main, not the renderer's mount logic. */ getPreviewConfig: (environmentId: EnvironmentId) => Promise<DesktopPreviewWebviewConfig>; + setAnnotationTheme: (theme: DesktopPreviewAnnotationTheme) => Promise<void>; /** * Activate the in-page element picker for the given tab. Resolves with * the picked payload, or `null` when the user cancels (Escape / nav). The @@ -709,6 +739,8 @@ export interface DesktopPreviewBridge { /** Cancel an in-flight preview annotation session. */ cancelPickElement: (tabId: string) => Promise<void>; captureScreenshot: (tabId: string) => Promise<DesktopPreviewScreenshotArtifact>; + revealArtifact: (path: string) => Promise<void>; + copyArtifactToClipboard: (path: string) => Promise<void>; recording: { startScreencast: (tabId: string) => Promise<void>; stopScreencast: (tabId: string) => Promise<void>; @@ -730,6 +762,7 @@ export interface DesktopPreviewBridge { waitFor: (tabId: string, input: PreviewAutomationWaitForInput) => Promise<void>; }; onStateChange: (listener: (tabId: string, state: DesktopPreviewTabState) => void) => () => void; + onPointerEvent: (listener: (event: DesktopPreviewPointerEvent) => void) => () => void; } /** diff --git a/packages/contracts/src/keybindings.test.ts b/packages/contracts/src/keybindings.test.ts index 2165597ac30..19c98c390c3 100644 --- a/packages/contracts/src/keybindings.test.ts +++ b/packages/contracts/src/keybindings.test.ts @@ -29,6 +29,12 @@ it.effect("parses keybinding rules", () => }); assert.strictEqual(parsed.command, "terminal.toggle"); + const parsedRightPanelToggle = yield* decode(KeybindingRule, { + key: "mod+alt+b", + command: "rightPanel.toggle", + }); + assert.strictEqual(parsedRightPanelToggle.command, "rightPanel.toggle"); + const parsedClose = yield* decode(KeybindingRule, { key: "mod+w", command: "terminal.close", @@ -100,8 +106,9 @@ it.effect("parses keybindings array payload", () => const parsed = yield* decode(KeybindingsConfig, [ { key: "mod+j", command: "terminal.toggle" }, { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, + { key: "mod+shift+d", command: "terminal.splitVertical", when: "terminalFocus" }, ]); - assert.lengthOf(parsed, 2); + assert.lengthOf(parsed, 3); }), ); diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 4399d89d368..4a5ffd0c3dd 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -50,8 +50,10 @@ export type ModelPickerKeybindingCommand = (typeof MODEL_PICKER_KEYBINDING_COMMA const STATIC_KEYBINDING_COMMANDS = [ "terminal.toggle", "terminal.split", + "terminal.splitVertical", "terminal.new", "terminal.close", + "rightPanel.toggle", "diff.toggle", "preview.toggle", "preview.refresh", diff --git a/packages/contracts/src/preview.test.ts b/packages/contracts/src/preview.test.ts index c11af1c89e1..e4e6757b441 100644 --- a/packages/contracts/src/preview.test.ts +++ b/packages/contracts/src/preview.test.ts @@ -123,6 +123,7 @@ describe("DiscoveredLocalServer", () => { url: "http://localhost:5173", processName: "node", pid: 12345, + terminal: null, }); expect(server.port).toBe(5173); expect(server.processName).toBe("node"); @@ -135,6 +136,7 @@ describe("DiscoveredLocalServer", () => { url: "http://localhost:3000", processName: null, pid: null, + terminal: null, }); expect(server.processName).toBeNull(); }); @@ -147,6 +149,7 @@ describe("DiscoveredLocalServer", () => { url: "http://localhost:0", processName: null, pid: null, + terminal: null, }), ).toThrow(); expect(() => @@ -156,6 +159,7 @@ describe("DiscoveredLocalServer", () => { url: "http://localhost:70000", processName: null, pid: null, + terminal: null, }), ).toThrow(); }); diff --git a/packages/contracts/src/preview.ts b/packages/contracts/src/preview.ts index f9ef049c386..044b8fbbd07 100644 --- a/packages/contracts/src/preview.ts +++ b/packages/contracts/src/preview.ts @@ -142,6 +142,12 @@ export const DiscoveredLocalServer = Schema.Struct({ url: Url, processName: Schema.NullOr(TrimmedNonEmptyString), pid: Schema.NullOr(Schema.Int.check(Schema.isGreaterThan(0))), + terminal: Schema.NullOr( + Schema.Struct({ + threadId: ThreadId, + terminalId: TrimmedNonEmptyString, + }), + ), }); export type DiscoveredLocalServer = typeof DiscoveredLocalServer.Type; diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index ca4942cf9fd..4abe53f2053 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -20,7 +20,9 @@ type WhenToken = export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [ { key: "mod+j", command: "terminal.toggle" }, + { key: "mod+alt+b", command: "rightPanel.toggle" }, { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, + { key: "mod+shift+d", command: "terminal.splitVertical", when: "terminalFocus" }, { key: "mod+n", command: "terminal.new", when: "terminalFocus" }, { key: "mod+w", command: "terminal.close", when: "terminalFocus" }, { key: "mod+d", command: "diff.toggle", when: "!terminalFocus" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index de9a7b2a80e..f1325b3c44f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -147,6 +147,9 @@ importers: electron-builder: specifier: 26.8.1 version: 26.8.1(electron-builder-squirrel-windows@26.8.1) + tailwindcss: + specifier: ^4.0.0 + version: 4.3.0 vite-plus: specifier: 'catalog:' version: 0.1.24(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) From a3c37616d22f591107e139dbe785b2548454a62e Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Fri, 12 Jun 2026 16:55:49 -0700 Subject: [PATCH 12/25] Refactor MCP services into top-level modules - Move MCP session registry and preview broker out of `Layers/` and `Services/` - Update imports, tests, and server wiring to use the new module layout --- .../src/mcp/Layers/McpSessionRegistry.ts | 173 ---------- .../src/mcp/Layers/PreviewAutomationBroker.ts | 270 ---------------- .../mcp/{Layers => }/McpHttpServer.test.ts | 28 +- .../src/mcp/{Layers => }/McpHttpServer.ts | 49 +-- .../{Services => }/McpInvocationContext.ts | 2 +- .../mcp/{Services => }/McpProviderSession.ts | 6 - .../{Layers => }/McpSessionRegistry.test.ts | 6 +- apps/server/src/mcp/McpSessionRegistry.ts | 202 ++++++++++++ .../PreviewAutomationBroker.test.ts | 8 +- .../server/src/mcp/PreviewAutomationBroker.ts | 306 ++++++++++++++++++ .../src/mcp/Services/McpSessionRegistry.ts | 29 -- .../mcp/Services/PreviewAutomationBroker.ts | 40 --- .../src/mcp/toolkits/preview/handlers.ts | 28 +- apps/server/src/mcp/toolkits/preview/tools.ts | 32 +- .../src/preview/{Layers => }/Manager.test.ts | 35 +- .../src/preview/{Layers => }/Manager.ts | 189 ++++++----- .../preview/{Layers => }/PortScanner.test.ts | 15 +- .../src/preview/{Layers => }/PortScanner.ts | 163 ++++++---- apps/server/src/preview/Services/Manager.ts | 94 ------ .../src/preview/Services/PortScanner.ts | 46 --- .../src/provider/Layers/ClaudeAdapter.ts | 4 +- .../src/provider/Layers/CodexAdapter.ts | 4 +- .../src/provider/Layers/CursorAdapter.ts | 4 +- .../server/src/provider/Layers/GrokAdapter.ts | 4 +- .../src/provider/Layers/OpenCodeAdapter.ts | 4 +- .../src/provider/Layers/ProviderService.ts | 26 +- apps/server/src/server.test.ts | 8 +- apps/server/src/server.ts | 17 +- apps/server/src/terminal/Layers/Manager.ts | 4 +- apps/server/src/ws.ts | 12 +- 30 files changed, 866 insertions(+), 942 deletions(-) delete mode 100644 apps/server/src/mcp/Layers/McpSessionRegistry.ts delete mode 100644 apps/server/src/mcp/Layers/PreviewAutomationBroker.ts rename apps/server/src/mcp/{Layers => }/McpHttpServer.test.ts (84%) rename apps/server/src/mcp/{Layers => }/McpHttpServer.ts (79%) rename apps/server/src/mcp/{Services => }/McpInvocationContext.ts (95%) rename apps/server/src/mcp/{Services => }/McpProviderSession.ts (82%) rename apps/server/src/mcp/{Layers => }/McpSessionRegistry.test.ts (92%) create mode 100644 apps/server/src/mcp/McpSessionRegistry.ts rename apps/server/src/mcp/{Layers => }/PreviewAutomationBroker.test.ts (90%) create mode 100644 apps/server/src/mcp/PreviewAutomationBroker.ts delete mode 100644 apps/server/src/mcp/Services/McpSessionRegistry.ts delete mode 100644 apps/server/src/mcp/Services/PreviewAutomationBroker.ts rename apps/server/src/preview/{Layers => }/Manager.test.ts (89%) rename apps/server/src/preview/{Layers => }/Manager.ts (63%) rename apps/server/src/preview/{Layers => }/PortScanner.test.ts (94%) rename apps/server/src/preview/{Layers => }/PortScanner.ts (69%) delete mode 100644 apps/server/src/preview/Services/Manager.ts delete mode 100644 apps/server/src/preview/Services/PortScanner.ts diff --git a/apps/server/src/mcp/Layers/McpSessionRegistry.ts b/apps/server/src/mcp/Layers/McpSessionRegistry.ts deleted file mode 100644 index a6aa95072a6..00000000000 --- a/apps/server/src/mcp/Layers/McpSessionRegistry.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { ProviderInstanceId, ThreadId } from "@t3tools/contracts"; -import * as Crypto from "effect/Crypto"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Ref from "effect/Ref"; -import { HttpServer } from "effect/unstable/http"; - -import { ServerEnvironment } from "../../environment/Services/ServerEnvironment.ts"; -import type { McpInvocationScope } from "../Services/McpInvocationContext.ts"; -import { - McpSessionRegistry, - type McpCredentialRequest, - type McpIssuedCredential, - type McpSessionRegistryShape, -} from "../Services/McpSessionRegistry.ts"; - -interface CredentialRecord { - readonly tokenHash: string; - readonly scope: McpInvocationScope; - readonly lastUsedAt: number; -} - -interface RegistryState { - readonly records: ReadonlyMap<string, CredentialRecord>; -} - -export interface McpSessionRegistryOptions { - readonly idleTimeoutMs?: number; - readonly maximumLifetimeMs?: number; - readonly now?: () => number; -} - -const DEFAULT_IDLE_TIMEOUT_MS = 30 * 60 * 1_000; -const DEFAULT_MAXIMUM_LIFETIME_MS = 8 * 60 * 60 * 1_000; - -const bytesToHex = (bytes: Uint8Array): string => - Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); - -const tokenFromBytes = (bytes: Uint8Array): string => Buffer.from(bytes).toString("base64url"); - -export const makeMcpSessionRegistry = (options: McpSessionRegistryOptions = {}) => - Effect.gen(function* () { - const crypto = yield* Crypto.Crypto; - const environment = yield* ServerEnvironment; - const environmentId = yield* environment.getEnvironmentId; - const httpServer = yield* HttpServer.HttpServer; - const state = yield* Ref.make<RegistryState>({ records: new Map() }); - const now = options.now ?? Date.now; - const idleTimeoutMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS; - const maximumLifetimeMs = options.maximumLifetimeMs ?? DEFAULT_MAXIMUM_LIFETIME_MS; - const endpoint = - httpServer.address._tag === "TcpAddress" - ? `http://127.0.0.1:${httpServer.address.port}/mcp` - : "http://127.0.0.1/mcp"; - - const hashToken = (token: string) => - crypto - .digest("SHA-256", new TextEncoder().encode(token)) - .pipe(Effect.map(bytesToHex), Effect.orDie); - - const pruneExpired = (records: ReadonlyMap<string, CredentialRecord>, timestamp: number) => { - let changed = false; - const next = new Map<string, CredentialRecord>(); - for (const [hash, record] of records) { - if (timestamp <= record.scope.expiresAt && timestamp - record.lastUsedAt <= idleTimeoutMs) { - next.set(hash, record); - } else { - changed = true; - } - } - return changed ? next : records; - }; - - const issue: McpSessionRegistryShape["issue"] = (request) => - Effect.gen(function* () { - const issuedAt = now(); - const providerSessionId = yield* crypto.randomUUIDv4.pipe(Effect.orDie); - const rawToken = yield* crypto - .randomBytes(32) - .pipe(Effect.map(tokenFromBytes), Effect.orDie); - const tokenHash = yield* hashToken(rawToken); - const expiresAt = issuedAt + maximumLifetimeMs; - const scope: McpInvocationScope = { - environmentId, - threadId: ThreadId.make(request.threadId), - providerSessionId, - providerInstanceId: ProviderInstanceId.make(request.providerInstanceId), - capabilities: new Set(["preview"]), - issuedAt, - expiresAt, - }; - yield* Ref.update(state, ({ records }) => { - const next = new Map(pruneExpired(records, issuedAt)); - next.set(tokenHash, { tokenHash, scope, lastUsedAt: issuedAt }); - return { records: next }; - }); - return { - config: { - environmentId, - threadId: scope.threadId, - providerSessionId, - providerInstanceId: scope.providerInstanceId, - endpoint, - authorizationHeader: `Bearer ${rawToken}`, - }, - expiresAt, - }; - }); - - const resolve: McpSessionRegistryShape["resolve"] = (rawToken) => - Effect.gen(function* () { - if (rawToken.length === 0) return undefined; - const tokenHash = yield* hashToken(rawToken); - const timestamp = now(); - let resolved: McpInvocationScope | undefined; - yield* Ref.update(state, ({ records }) => { - const current = pruneExpired(records, timestamp); - const record = current.get(tokenHash); - if (!record) return { records: current }; - resolved = record.scope; - const next = new Map(current); - next.set(tokenHash, { ...record, lastUsedAt: timestamp }); - return { records: next }; - }); - return resolved; - }); - - const revokeWhere = (predicate: (record: CredentialRecord) => boolean) => - Ref.update(state, ({ records }) => ({ - records: new Map(Array.from(records).filter(([, record]) => !predicate(record))), - })); - - return McpSessionRegistry.of({ - issue, - resolve, - revokeProviderSession: (providerSessionId) => - revokeWhere((record) => record.scope.providerSessionId === providerSessionId), - revokeThread: (threadId) => revokeWhere((record) => record.scope.threadId === threadId), - revokeAll: Ref.set(state, { records: new Map() }), - }); - }); - -let activeMcpSessionRegistry: McpSessionRegistryShape | undefined; - -export const McpSessionRegistryLive: Layer.Layer< - McpSessionRegistry, - never, - Crypto.Crypto | ServerEnvironment | HttpServer.HttpServer -> = Layer.effect( - McpSessionRegistry, - makeMcpSessionRegistry().pipe( - Effect.tap((registry) => - Effect.sync(() => { - activeMcpSessionRegistry = registry; - }), - ), - ), -); - -export const issueActiveMcpCredential = ( - request: McpCredentialRequest, -): Effect.Effect<McpIssuedCredential | undefined> => - activeMcpSessionRegistry - ? activeMcpSessionRegistry - .revokeThread(request.threadId) - .pipe(Effect.andThen(activeMcpSessionRegistry.issue(request))) - : Effect.sync((): McpIssuedCredential | undefined => undefined); - -export const revokeActiveMcpThread = (threadId: ThreadId): Effect.Effect<void> => - activeMcpSessionRegistry ? activeMcpSessionRegistry.revokeThread(threadId) : Effect.void; - -export const revokeAllActiveMcpCredentials = (): Effect.Effect<void> => - activeMcpSessionRegistry ? activeMcpSessionRegistry.revokeAll : Effect.void; diff --git a/apps/server/src/mcp/Layers/PreviewAutomationBroker.ts b/apps/server/src/mcp/Layers/PreviewAutomationBroker.ts deleted file mode 100644 index d32523375b1..00000000000 --- a/apps/server/src/mcp/Layers/PreviewAutomationBroker.ts +++ /dev/null @@ -1,270 +0,0 @@ -import { - PreviewAutomationControlInterruptedError, - PreviewAutomationExecutionError, - PreviewAutomationInvalidSelectorError, - PreviewAutomationNoFocusedOwnerError, - PreviewAutomationResultTooLargeError, - PreviewAutomationTabNotFoundError, - PreviewAutomationTimeoutError, - PreviewAutomationUnavailableError, - PreviewAutomationUnsupportedClientError, - type PreviewAutomationError, - type PreviewAutomationOwner, - type PreviewAutomationResponse, -} from "@t3tools/contracts"; -import * as Deferred from "effect/Deferred"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as Queue from "effect/Queue"; -import * as Ref from "effect/Ref"; -import * as Stream from "effect/Stream"; - -import { - PreviewAutomationBroker, - type PreviewAutomationBrokerShape, -} from "../Services/PreviewAutomationBroker.ts"; - -interface ClientConnection { - readonly clientId: string; - readonly queue: Queue.Queue< - Parameters<PreviewAutomationBrokerShape["respond"]>[0] extends never - ? never - : import("@t3tools/contracts").PreviewAutomationRequest - >; -} - -interface PendingRequest { - readonly clientId: string; - readonly deferred: Deferred.Deferred<unknown, PreviewAutomationError>; -} - -interface BrokerState { - readonly clients: ReadonlyMap<string, ClientConnection>; - readonly owners: ReadonlyMap<string, PreviewAutomationOwner>; - readonly pending: ReadonlyMap<string, PendingRequest>; -} - -const makeResponseError = ( - error: NonNullable<PreviewAutomationResponse["error"]>, -): PreviewAutomationError => { - switch (error._tag) { - case "PreviewAutomationNoFocusedOwnerError": - return new PreviewAutomationNoFocusedOwnerError({ message: error.message }); - case "PreviewAutomationUnsupportedClientError": - return new PreviewAutomationUnsupportedClientError({ message: error.message }); - case "PreviewAutomationTabNotFoundError": - return new PreviewAutomationTabNotFoundError({ message: error.message }); - case "PreviewAutomationTimeoutError": - return new PreviewAutomationTimeoutError({ message: error.message }); - case "PreviewAutomationControlInterruptedError": - return new PreviewAutomationControlInterruptedError({ message: error.message }); - case "PreviewAutomationInvalidSelectorError": { - const detail = - typeof error.detail === "object" && error.detail !== null ? error.detail : undefined; - return new PreviewAutomationInvalidSelectorError({ - message: error.message, - selector: - detail && "selector" in detail && typeof detail.selector === "string" - ? detail.selector - : "", - }); - } - case "PreviewAutomationResultTooLargeError": { - const detail = - typeof error.detail === "object" && error.detail !== null ? error.detail : undefined; - return new PreviewAutomationResultTooLargeError({ - message: error.message, - maximumBytes: - detail && "maximumBytes" in detail && typeof detail.maximumBytes === "number" - ? detail.maximumBytes - : 64_000, - }); - } - case "PreviewAutomationUnavailableError": - return new PreviewAutomationUnavailableError({ message: error.message }); - default: - return new PreviewAutomationExecutionError({ - message: error.message, - detail: error.detail, - }); - } -}; - -export const makePreviewAutomationBroker = (): PreviewAutomationBrokerShape => { - const state = Effect.runSync( - Ref.make<BrokerState>({ - clients: new Map(), - owners: new Map(), - pending: new Map(), - }), - ); - let requestSequence = 0; - - const disconnect = (clientId: string, queue: ClientConnection["queue"]) => - Effect.gen(function* () { - const toFail: PendingRequest[] = []; - yield* Ref.update(state, (current) => { - if (current.clients.get(clientId)?.queue !== queue) return current; - const clients = new Map(current.clients); - const owners = new Map(current.owners); - const pending = new Map(current.pending); - clients.delete(clientId); - owners.delete(clientId); - for (const [requestId, entry] of pending) { - if (entry.clientId === clientId) { - pending.delete(requestId); - toFail.push(entry); - } - } - return { clients, owners, pending }; - }); - yield* Effect.forEach( - toFail, - ({ deferred }) => - Deferred.fail( - deferred, - new PreviewAutomationUnavailableError({ - message: "The preview automation client disconnected.", - }), - ), - { discard: true }, - ); - yield* Queue.shutdown(queue); - }); - - const connect: PreviewAutomationBrokerShape["connect"] = (clientId) => - Effect.gen(function* () { - const queue = yield* Queue.unbounded<import("@t3tools/contracts").PreviewAutomationRequest>(); - let previous: ClientConnection | undefined; - yield* Ref.update(state, (current) => { - previous = current.clients.get(clientId); - const clients = new Map(current.clients); - clients.set(clientId, { clientId, queue }); - return { ...current, clients }; - }); - if (previous) yield* disconnect(clientId, previous.queue); - return Stream.fromQueue(queue).pipe(Stream.ensuring(disconnect(clientId, queue))); - }); - - const reportOwner: PreviewAutomationBrokerShape["reportOwner"] = (owner) => - Ref.update(state, (current) => { - const owners = new Map(current.owners); - owners.set(owner.clientId, owner); - return { ...current, owners }; - }); - - const clearOwner: PreviewAutomationBrokerShape["clearOwner"] = (clientId) => - Ref.update(state, (current) => { - const owners = new Map(current.owners); - owners.delete(clientId); - return { ...current, owners }; - }); - - const respond: PreviewAutomationBrokerShape["respond"] = (response) => - Effect.gen(function* () { - let pending: PendingRequest | undefined; - yield* Ref.update(state, (current) => { - pending = current.pending.get(response.requestId); - if (!pending) return current; - const next = new Map(current.pending); - next.delete(response.requestId); - return { ...current, pending: next }; - }); - if (!pending) return; - if (response.ok) { - yield* Deferred.succeed(pending.deferred, response.result); - } else { - yield* Deferred.fail( - pending.deferred, - response.error - ? makeResponseError(response.error) - : new PreviewAutomationExecutionError({ - message: "Preview automation failed without an error payload.", - }), - ); - } - }); - - const invoke = <A = unknown>( - input: Parameters<PreviewAutomationBrokerShape["invoke"]>[0], - ): Effect.Effect<A, PreviewAutomationError> => - Effect.gen(function* () { - const current = yield* Ref.get(state); - const candidates = Array.from(current.owners.values()) - .filter( - (owner) => - owner.environmentId === input.scope.environmentId && - owner.threadId === input.scope.threadId && - owner.supportsAutomation, - ) - .sort((left, right) => right.focusedAt.localeCompare(left.focusedAt)); - const owner = candidates[0]; - if (!owner) { - return yield* new PreviewAutomationNoFocusedOwnerError({ - message: "No desktop browser host is available for this thread.", - }); - } - const connection = current.clients.get(owner.clientId); - if (!connection) { - return yield* new PreviewAutomationUnavailableError({ - message: "The browser host is not connected.", - }); - } - if ( - input.operation !== "open" && - input.operation !== "status" && - !owner.tabId && - !input.tabId - ) { - return yield* new PreviewAutomationTabNotFoundError({ - message: "The browser host does not have an active tab.", - }); - } - const requestId = `preview-${requestSequence++}`; - const timeoutMs = input.timeoutMs ?? 15_000; - const deferred = yield* Deferred.make<unknown, PreviewAutomationError>(); - yield* Ref.update(state, (next) => { - const pending = new Map(next.pending); - pending.set(requestId, { clientId: owner.clientId, deferred }); - return { ...next, pending }; - }); - const offered = yield* Queue.offer(connection.queue, { - requestId, - threadId: input.scope.threadId, - tabId: input.tabId ?? owner.tabId ?? undefined, - operation: input.operation, - input: input.input, - timeoutMs, - }); - if (!offered) { - return yield* new PreviewAutomationUnavailableError({ - message: "The preview automation client is no longer accepting requests.", - }); - } - const result = yield* Deferred.await(deferred).pipe(Effect.timeoutOption(timeoutMs)); - yield* Ref.update(state, (next) => { - const pending = new Map(next.pending); - pending.delete(requestId); - return { ...next, pending }; - }); - return yield* Option.match(result, { - onNone: () => - Effect.fail( - new PreviewAutomationTimeoutError({ - message: `Preview automation timed out after ${timeoutMs}ms.`, - }), - ), - onSome: (value) => Effect.succeed(value as A), - }); - }); - - return PreviewAutomationBroker.of({ connect, reportOwner, clearOwner, respond, invoke }); -}; - -export const previewAutomationBroker = makePreviewAutomationBroker(); - -export const PreviewAutomationBrokerLive: Layer.Layer<PreviewAutomationBroker> = Layer.succeed( - PreviewAutomationBroker, - previewAutomationBroker, -); diff --git a/apps/server/src/mcp/Layers/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts similarity index 84% rename from apps/server/src/mcp/Layers/McpHttpServer.test.ts rename to apps/server/src/mcp/McpHttpServer.test.ts index 374d147d802..ebf8bf00c1c 100644 --- a/apps/server/src/mcp/Layers/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -6,9 +6,9 @@ import * as Stream from "effect/Stream"; import { McpSchema, McpServer } from "effect/unstable/ai"; import { HttpServerResponse } from "effect/unstable/http"; -import { McpInvocationContext } from "../Services/McpInvocationContext.ts"; -import { normalizeMcpHttpResponse, PreviewToolkitRegistrationLive } from "./McpHttpServer.ts"; -import { previewAutomationBroker } from "./PreviewAutomationBroker.ts"; +import * as McpHttpServer from "./McpHttpServer.ts"; +import * as McpInvocationContext from "./McpInvocationContext.ts"; +import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts"; const environmentId = EnvironmentId.make("environment-mcp-test"); const threadId = ThreadId.make("thread-mcp-test"); @@ -31,17 +31,18 @@ const client = McpSchema.McpServerClient.of({ }, getClient: Effect.die("unused"), }); -const TestLayer = PreviewToolkitRegistrationLive.pipe( +const TestLayer = McpHttpServer.PreviewToolkitRegistrationLive.pipe( Layer.provideMerge(McpServer.McpServer.layer), + Layer.provideMerge(PreviewAutomationBroker.layer), ); it("normalizes empty successful notification responses to accepted", () => { - const notificationResponse = normalizeMcpHttpResponse( + const notificationResponse = McpHttpServer.normalizeMcpHttpResponse( HttpServerResponse.text("", { status: 200, contentType: "application/json" }), ); expect(notificationResponse.status).toBe(202); - const resultResponse = normalizeMcpHttpResponse( + const resultResponse = McpHttpServer.normalizeMcpHttpResponse( HttpServerResponse.jsonUnsafe({ jsonrpc: "2.0", id: 1, result: {} }), ); expect(resultResponse.status).toBe(200); @@ -51,9 +52,10 @@ it.effect("registers annotated tools and preserves authenticated request context Effect.scoped( Effect.gen(function* () { const server = yield* McpServer.McpServer; - const requests = yield* previewAutomationBroker.connect("mcp-test-client"); + const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; + const requests = yield* broker.connect("mcp-test-client"); yield* Stream.runForEach(requests, (request) => - previewAutomationBroker.respond({ + broker.respond({ requestId: request.requestId, ok: true, result: @@ -88,7 +90,7 @@ it.effect("registers annotated tools and preserves authenticated request context }), ).pipe(Effect.forkScoped); yield* Effect.yieldNow; - yield* previewAutomationBroker.reportOwner({ + yield* broker.reportOwner({ clientId: "mcp-test-client", environmentId, threadId, @@ -120,7 +122,7 @@ it.effect("registers annotated tools and preserves authenticated request context const status = yield* server .callTool({ name: "preview_status", arguments: {} }) .pipe( - Effect.provideService(McpInvocationContext, invocation), + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), Effect.provideService(McpSchema.McpServerClient, client), ); expect(status.isError).toBe(false); @@ -132,7 +134,7 @@ it.effect("registers annotated tools and preserves authenticated request context const malformed = yield* server .callTool({ name: "preview_click", arguments: { selector: "" } }) .pipe( - Effect.provideService(McpInvocationContext, invocation), + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), Effect.provideService(McpSchema.McpServerClient, client), ); expect(malformed.isError).toBe(true); @@ -140,7 +142,7 @@ it.effect("registers annotated tools and preserves authenticated request context const snapshot = yield* server .callTool({ name: "preview_snapshot", arguments: {} }) .pipe( - Effect.provideService(McpInvocationContext, invocation), + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), Effect.provideService(McpSchema.McpServerClient, client), ); expect(snapshot.isError).toBe(false); @@ -152,7 +154,7 @@ it.effect("registers annotated tools and preserves authenticated request context const press = yield* server .callTool({ name: "preview_press", arguments: { key: "Enter" } }) .pipe( - Effect.provideService(McpInvocationContext, invocation), + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), Effect.provideService(McpSchema.McpServerClient, client), ); expect(press.isError).toBe(false); diff --git a/apps/server/src/mcp/Layers/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts similarity index 79% rename from apps/server/src/mcp/Layers/McpHttpServer.ts rename to apps/server/src/mcp/McpHttpServer.ts index 356e2eabbef..d13eb004222 100644 --- a/apps/server/src/mcp/Layers/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -8,11 +8,12 @@ import * as Stream from "effect/Stream"; import { McpSchema, McpServer, Tool } from "effect/unstable/ai"; import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; -import packageJson from "../../../package.json" with { type: "json" }; -import { McpInvocationContext } from "../Services/McpInvocationContext.ts"; -import { McpSessionRegistry } from "../Services/McpSessionRegistry.ts"; -import { PreviewToolkitHandlersLive } from "../toolkits/preview/handlers.ts"; -import { PreviewToolkit } from "../toolkits/preview/tools.ts"; +import packageJson from "../../package.json" with { type: "json" }; +import * as McpInvocationContext from "./McpInvocationContext.ts"; +import * as McpSessionRegistry from "./McpSessionRegistry.ts"; +import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts"; +import { PreviewToolkitHandlersLive } from "./toolkits/preview/handlers.ts"; +import { PreviewToolkit } from "./toolkits/preview/tools.ts"; const unauthorized = HttpServerResponse.jsonUnsafe( { @@ -41,25 +42,24 @@ export const normalizeMcpHttpResponse = ( }; const McpAuthMiddlewareLive = HttpRouter.middleware<{ - provides: McpInvocationContext; + provides: McpInvocationContext.McpInvocationContext; }>()( Effect.gen(function* () { - const registry = yield* McpSessionRegistry; - return (httpEffect) => - Effect.gen(function* () { - const request = yield* HttpServerRequest.HttpServerRequest; - const authorization = request.headers.authorization; - const token = - authorization?.startsWith("Bearer ") === true - ? authorization.slice("Bearer ".length).trim() - : ""; - const invocation = yield* registry.resolve(token); - if (!invocation) return unauthorized; - return yield* httpEffect.pipe( - Effect.provideService(McpInvocationContext, invocation), - Effect.map(normalizeMcpHttpResponse), - ); - }); + const registry = yield* McpSessionRegistry.McpSessionRegistry; + return Effect.fn("McpHttpServer.authenticateRequest")(function* (httpEffect) { + const request = yield* HttpServerRequest.HttpServerRequest; + const authorization = request.headers.authorization; + const token = + authorization?.startsWith("Bearer ") === true + ? authorization.slice("Bearer ".length).trim() + : ""; + const invocation = yield* registry.resolve(token); + if (!invocation) return unauthorized; + return yield* httpEffect.pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.map(normalizeMcpHttpResponse), + ); + }); }), ).layer; @@ -79,7 +79,7 @@ export const PreviewToolkitRegistrationLive = Layer.effectDiscard( ) => Effect.Effect< Stream.Stream<{ readonly encodedResult: unknown }, Error>, Error, - McpInvocationContext + McpInvocationContext.McpInvocationContext >; for (const tool of Object.values(built.tools)) { yield* server.addTool({ @@ -160,6 +160,7 @@ export const PreviewToolkitRegistrationLive = Layer.effectDiscard( }), ).pipe(Layer.provide(PreviewToolkitHandlersLive)); -export const McpHttpServerLive = Layer.mergeAll(PreviewToolkitRegistrationLive).pipe( +export const layer = Layer.mergeAll(PreviewToolkitRegistrationLive).pipe( Layer.provideMerge(McpTransportLive), + Layer.provide(PreviewAutomationBroker.layer), ); diff --git a/apps/server/src/mcp/Services/McpInvocationContext.ts b/apps/server/src/mcp/McpInvocationContext.ts similarity index 95% rename from apps/server/src/mcp/Services/McpInvocationContext.ts rename to apps/server/src/mcp/McpInvocationContext.ts index 89a3820479b..0d3f84df42c 100644 --- a/apps/server/src/mcp/Services/McpInvocationContext.ts +++ b/apps/server/src/mcp/McpInvocationContext.ts @@ -18,7 +18,7 @@ export interface McpInvocationScope { export class McpInvocationContext extends Context.Service< McpInvocationContext, McpInvocationScope ->()("t3/mcp/Services/McpInvocationContext") {} +>()("t3/mcp/McpInvocationContext") {} export const requireMcpCapability = Effect.fn("mcp.requireCapability")(function* ( capability: McpCapability, diff --git a/apps/server/src/mcp/Services/McpProviderSession.ts b/apps/server/src/mcp/McpProviderSession.ts similarity index 82% rename from apps/server/src/mcp/Services/McpProviderSession.ts rename to apps/server/src/mcp/McpProviderSession.ts index b97cbb1e1c0..d5dc582046c 100644 --- a/apps/server/src/mcp/Services/McpProviderSession.ts +++ b/apps/server/src/mcp/McpProviderSession.ts @@ -1,5 +1,4 @@ import type { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; -import * as Context from "effect/Context"; export interface McpProviderSessionConfig { readonly environmentId: EnvironmentId; @@ -10,11 +9,6 @@ export interface McpProviderSessionConfig { readonly authorizationHeader: string; } -export class McpProviderSession extends Context.Service< - McpProviderSession, - McpProviderSessionConfig ->()("t3/mcp/Services/McpProviderSession") {} - const sessionsByThread = new Map<ThreadId, McpProviderSessionConfig>(); export function setMcpProviderSession(config: McpProviderSessionConfig): void { diff --git a/apps/server/src/mcp/Layers/McpSessionRegistry.test.ts b/apps/server/src/mcp/McpSessionRegistry.test.ts similarity index 92% rename from apps/server/src/mcp/Layers/McpSessionRegistry.test.ts rename to apps/server/src/mcp/McpSessionRegistry.test.ts index bbd27c63cac..d345953caeb 100644 --- a/apps/server/src/mcp/Layers/McpSessionRegistry.test.ts +++ b/apps/server/src/mcp/McpSessionRegistry.test.ts @@ -4,8 +4,8 @@ import { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts" import * as Effect from "effect/Effect"; import { HttpServer } from "effect/unstable/http"; -import { ServerEnvironment } from "../../environment/Services/ServerEnvironment.ts"; -import { makeMcpSessionRegistry } from "./McpSessionRegistry.ts"; +import { ServerEnvironment } from "../environment/Services/ServerEnvironment.ts"; +import * as McpSessionRegistry from "./McpSessionRegistry.ts"; const environmentId = EnvironmentId.make("environment-1"); const fakeHttpServer = HttpServer.HttpServer.of({ @@ -18,7 +18,7 @@ const fakeEnvironment = ServerEnvironment.of({ }); const makeRegistry = (now: () => number) => - makeMcpSessionRegistry({ + McpSessionRegistry.makeForTest({ now, idleTimeoutMs: 100, maximumLifetimeMs: 1_000, diff --git a/apps/server/src/mcp/McpSessionRegistry.ts b/apps/server/src/mcp/McpSessionRegistry.ts new file mode 100644 index 00000000000..c87abe88bb5 --- /dev/null +++ b/apps/server/src/mcp/McpSessionRegistry.ts @@ -0,0 +1,202 @@ +import { ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import { HttpServer } from "effect/unstable/http"; + +import { ServerEnvironment } from "../environment/Services/ServerEnvironment.ts"; +import * as McpInvocationContext from "./McpInvocationContext.ts"; +import * as McpProviderSession from "./McpProviderSession.ts"; + +export interface McpCredentialRequest { + readonly threadId: ThreadId; + readonly providerInstanceId: ProviderInstanceId; +} + +export interface McpIssuedCredential { + readonly config: McpProviderSession.McpProviderSessionConfig; + readonly expiresAt: number; +} + +export interface McpSessionRegistryShape { + readonly issue: (request: McpCredentialRequest) => Effect.Effect<McpIssuedCredential>; + readonly resolve: ( + rawToken: string, + ) => Effect.Effect<McpInvocationContext.McpInvocationScope | undefined>; + readonly revokeProviderSession: (providerSessionId: string) => Effect.Effect<void>; + readonly revokeThread: (threadId: ThreadId) => Effect.Effect<void>; + readonly revokeAll: Effect.Effect<void>; +} + +export class McpSessionRegistry extends Context.Service< + McpSessionRegistry, + McpSessionRegistryShape +>()("t3/mcp/McpSessionRegistry") {} + +interface CredentialRecord { + readonly tokenHash: string; + readonly scope: McpInvocationContext.McpInvocationScope; + readonly lastUsedAt: number; +} + +interface RegistryState { + readonly records: ReadonlyMap<string, CredentialRecord>; +} + +export interface McpSessionRegistryOptions { + readonly idleTimeoutMs?: number; + readonly maximumLifetimeMs?: number; + readonly now?: () => number; +} + +const DEFAULT_IDLE_TIMEOUT_MS = 30 * 60 * 1_000; +const DEFAULT_MAXIMUM_LIFETIME_MS = 8 * 60 * 60 * 1_000; + +const bytesToHex = (bytes: Uint8Array): string => + Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); + +const tokenFromBytes = (bytes: Uint8Array): string => Buffer.from(bytes).toString("base64url"); + +const make = Effect.fn("McpSessionRegistry.make")(function* ( + options: McpSessionRegistryOptions = {}, +) { + const crypto = yield* Crypto.Crypto; + const environment = yield* ServerEnvironment; + const environmentId = yield* environment.getEnvironmentId; + const httpServer = yield* HttpServer.HttpServer; + const state = yield* Ref.make<RegistryState>({ records: new Map() }); + const now = options.now ?? Date.now; + const idleTimeoutMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS; + const maximumLifetimeMs = options.maximumLifetimeMs ?? DEFAULT_MAXIMUM_LIFETIME_MS; + const endpoint = + httpServer.address._tag === "TcpAddress" + ? `http://127.0.0.1:${httpServer.address.port}/mcp` + : "http://127.0.0.1/mcp"; + + const hashToken = (token: string) => + crypto + .digest("SHA-256", new TextEncoder().encode(token)) + .pipe(Effect.map(bytesToHex), Effect.orDie); + + const pruneExpired = (records: ReadonlyMap<string, CredentialRecord>, timestamp: number) => { + let changed = false; + const next = new Map<string, CredentialRecord>(); + for (const [hash, record] of records) { + if (timestamp <= record.scope.expiresAt && timestamp - record.lastUsedAt <= idleTimeoutMs) { + next.set(hash, record); + } else { + changed = true; + } + } + return changed ? next : records; + }; + + const issue: McpSessionRegistryShape["issue"] = Effect.fn("McpSessionRegistry.issue")( + function* (request) { + const issuedAt = now(); + const providerSessionId = yield* crypto.randomUUIDv4.pipe(Effect.orDie); + const rawToken = yield* crypto.randomBytes(32).pipe(Effect.map(tokenFromBytes), Effect.orDie); + const tokenHash = yield* hashToken(rawToken); + const expiresAt = issuedAt + maximumLifetimeMs; + const scope: McpInvocationContext.McpInvocationScope = { + environmentId, + threadId: ThreadId.make(request.threadId), + providerSessionId, + providerInstanceId: ProviderInstanceId.make(request.providerInstanceId), + capabilities: new Set(["preview"]), + issuedAt, + expiresAt, + }; + yield* Ref.update(state, ({ records }) => { + const next = new Map(pruneExpired(records, issuedAt)); + next.set(tokenHash, { tokenHash, scope, lastUsedAt: issuedAt }); + return { records: next }; + }); + return { + config: { + environmentId, + threadId: scope.threadId, + providerSessionId, + providerInstanceId: scope.providerInstanceId, + endpoint, + authorizationHeader: `Bearer ${rawToken}`, + }, + expiresAt, + }; + }, + ); + + const resolve: McpSessionRegistryShape["resolve"] = Effect.fn("McpSessionRegistry.resolve")( + function* (rawToken) { + if (rawToken.length === 0) return undefined; + const tokenHash = yield* hashToken(rawToken); + const timestamp = now(); + let resolved: McpInvocationContext.McpInvocationScope | undefined; + yield* Ref.update(state, ({ records }) => { + const current = pruneExpired(records, timestamp); + const record = current.get(tokenHash); + if (!record) return { records: current }; + resolved = record.scope; + const next = new Map(current); + next.set(tokenHash, { ...record, lastUsedAt: timestamp }); + return { records: next }; + }); + return resolved; + }, + ); + + const revokeWhere = (predicate: (record: CredentialRecord) => boolean) => + Ref.update(state, ({ records }) => ({ + records: new Map(Array.from(records).filter(([, record]) => !predicate(record))), + })); + + return McpSessionRegistry.of({ + issue, + resolve, + revokeProviderSession: Effect.fn("McpSessionRegistry.revokeProviderSession")( + function* (providerSessionId) { + yield* revokeWhere((record) => record.scope.providerSessionId === providerSessionId); + }, + ), + revokeThread: Effect.fn("McpSessionRegistry.revokeThread")(function* (threadId) { + yield* revokeWhere((record) => record.scope.threadId === threadId); + }), + revokeAll: Ref.set(state, { records: new Map() }), + }); +}); + +let activeMcpSessionRegistry: McpSessionRegistryShape | undefined; + +export const layer: Layer.Layer< + McpSessionRegistry, + never, + Crypto.Crypto | ServerEnvironment | HttpServer.HttpServer +> = Layer.effect( + McpSessionRegistry, + make().pipe( + Effect.tap((registry) => + Effect.sync(() => { + activeMcpSessionRegistry = registry; + }), + ), + ), +); + +export const makeForTest = make; + +export const issueActiveMcpCredential = ( + request: McpCredentialRequest, +): Effect.Effect<McpIssuedCredential | undefined> => + activeMcpSessionRegistry + ? activeMcpSessionRegistry + .revokeThread(request.threadId) + .pipe(Effect.andThen(activeMcpSessionRegistry.issue(request))) + : Effect.sync((): McpIssuedCredential | undefined => undefined); + +export const revokeActiveMcpThread = (threadId: ThreadId): Effect.Effect<void> => + activeMcpSessionRegistry ? activeMcpSessionRegistry.revokeThread(threadId) : Effect.void; + +export const revokeAllActiveMcpCredentials = (): Effect.Effect<void> => + activeMcpSessionRegistry ? activeMcpSessionRegistry.revokeAll : Effect.void; diff --git a/apps/server/src/mcp/Layers/PreviewAutomationBroker.test.ts b/apps/server/src/mcp/PreviewAutomationBroker.test.ts similarity index 90% rename from apps/server/src/mcp/Layers/PreviewAutomationBroker.test.ts rename to apps/server/src/mcp/PreviewAutomationBroker.test.ts index 7c97da39f2e..c5d316fd87f 100644 --- a/apps/server/src/mcp/Layers/PreviewAutomationBroker.test.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.test.ts @@ -8,7 +8,7 @@ import { import * as Effect from "effect/Effect"; import * as Stream from "effect/Stream"; -import { makePreviewAutomationBroker } from "./PreviewAutomationBroker.ts"; +import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts"; const scope = { environmentId: EnvironmentId.make("environment-1"), @@ -23,7 +23,7 @@ const scope = { it.effect("routes a request to the focused owner and correlates its response", () => Effect.scoped( Effect.gen(function* () { - const broker = makePreviewAutomationBroker(); + const broker = yield* PreviewAutomationBroker.makeForTest; const requests = yield* broker.connect("client-1"); yield* Stream.runForEach(requests, (request) => broker.respond({ @@ -56,7 +56,7 @@ it.effect("routes a request to the focused owner and correlates its response", ( it.effect("rejects calls when no focused owner exists", () => Effect.gen(function* () { - const broker = makePreviewAutomationBroker(); + const broker = yield* PreviewAutomationBroker.makeForTest; const error = yield* broker .invoke<void>({ scope, operation: "status", input: {} }) .pipe(Effect.flip); @@ -67,7 +67,7 @@ it.effect("rejects calls when no focused owner exists", () => it.effect("routes interactive commands to a hidden durable browser host", () => Effect.scoped( Effect.gen(function* () { - const broker = makePreviewAutomationBroker(); + const broker = yield* PreviewAutomationBroker.makeForTest; const requests = yield* broker.connect("client-hidden"); yield* Stream.runForEach(requests, (request) => broker.respond({ requestId: request.requestId, ok: true }), diff --git a/apps/server/src/mcp/PreviewAutomationBroker.ts b/apps/server/src/mcp/PreviewAutomationBroker.ts new file mode 100644 index 00000000000..68080bd9360 --- /dev/null +++ b/apps/server/src/mcp/PreviewAutomationBroker.ts @@ -0,0 +1,306 @@ +import { + PreviewAutomationControlInterruptedError, + PreviewAutomationExecutionError, + PreviewAutomationInvalidSelectorError, + PreviewAutomationNoFocusedOwnerError, + PreviewAutomationResultTooLargeError, + PreviewAutomationTabNotFoundError, + PreviewAutomationTimeoutError, + PreviewAutomationUnavailableError, + PreviewAutomationUnsupportedClientError, + type PreviewAutomationError, + type PreviewAutomationOperation, + type PreviewAutomationOwner, + type PreviewAutomationRequest, + type PreviewAutomationResponse, + type PreviewTabId, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; + +import * as McpInvocationContext from "./McpInvocationContext.ts"; + +export interface PreviewAutomationInvokeInput { + readonly scope: McpInvocationContext.McpInvocationScope; + readonly operation: PreviewAutomationOperation; + readonly input: unknown; + readonly tabId?: PreviewTabId; + readonly timeoutMs?: number; +} + +export interface PreviewAutomationBrokerShape { + readonly connect: (clientId: string) => Effect.Effect<Stream.Stream<PreviewAutomationRequest>>; + readonly reportOwner: ( + owner: PreviewAutomationOwner, + ) => Effect.Effect<void, PreviewAutomationError>; + readonly clearOwner: (clientId: string) => Effect.Effect<void>; + readonly respond: ( + response: PreviewAutomationResponse, + ) => Effect.Effect<void, PreviewAutomationError>; + readonly invoke: <A = unknown>( + request: PreviewAutomationInvokeInput, + ) => Effect.Effect<A, PreviewAutomationError>; +} + +export class PreviewAutomationBroker extends Context.Service< + PreviewAutomationBroker, + PreviewAutomationBrokerShape +>()("t3/mcp/PreviewAutomationBroker") {} + +interface ClientConnection { + readonly clientId: string; + readonly queue: Queue.Queue< + Parameters<PreviewAutomationBrokerShape["respond"]>[0] extends never + ? never + : import("@t3tools/contracts").PreviewAutomationRequest + >; +} + +interface PendingRequest { + readonly clientId: string; + readonly deferred: Deferred.Deferred<unknown, PreviewAutomationError>; +} + +interface BrokerState { + readonly clients: ReadonlyMap<string, ClientConnection>; + readonly owners: ReadonlyMap<string, PreviewAutomationOwner>; + readonly pending: ReadonlyMap<string, PendingRequest>; +} + +const makeResponseError = ( + error: NonNullable<PreviewAutomationResponse["error"]>, +): PreviewAutomationError => { + switch (error._tag) { + case "PreviewAutomationNoFocusedOwnerError": + return new PreviewAutomationNoFocusedOwnerError({ message: error.message }); + case "PreviewAutomationUnsupportedClientError": + return new PreviewAutomationUnsupportedClientError({ message: error.message }); + case "PreviewAutomationTabNotFoundError": + return new PreviewAutomationTabNotFoundError({ message: error.message }); + case "PreviewAutomationTimeoutError": + return new PreviewAutomationTimeoutError({ message: error.message }); + case "PreviewAutomationControlInterruptedError": + return new PreviewAutomationControlInterruptedError({ message: error.message }); + case "PreviewAutomationInvalidSelectorError": { + const detail = + typeof error.detail === "object" && error.detail !== null ? error.detail : undefined; + return new PreviewAutomationInvalidSelectorError({ + message: error.message, + selector: + detail && "selector" in detail && typeof detail.selector === "string" + ? detail.selector + : "", + }); + } + case "PreviewAutomationResultTooLargeError": { + const detail = + typeof error.detail === "object" && error.detail !== null ? error.detail : undefined; + return new PreviewAutomationResultTooLargeError({ + message: error.message, + maximumBytes: + detail && "maximumBytes" in detail && typeof detail.maximumBytes === "number" + ? detail.maximumBytes + : 64_000, + }); + } + case "PreviewAutomationUnavailableError": + return new PreviewAutomationUnavailableError({ message: error.message }); + default: + return new PreviewAutomationExecutionError({ + message: error.message, + detail: error.detail, + }); + } +}; + +const makeService = (): PreviewAutomationBrokerShape => { + const state = Effect.runSync( + Ref.make<BrokerState>({ + clients: new Map(), + owners: new Map(), + pending: new Map(), + }), + ); + let requestSequence = 0; + + const disconnect = Effect.fn("PreviewAutomationBroker.disconnect")(function* ( + clientId: string, + queue: ClientConnection["queue"], + ) { + const toFail: PendingRequest[] = []; + yield* Ref.update(state, (current) => { + if (current.clients.get(clientId)?.queue !== queue) return current; + const clients = new Map(current.clients); + const owners = new Map(current.owners); + const pending = new Map(current.pending); + clients.delete(clientId); + owners.delete(clientId); + for (const [requestId, entry] of pending) { + if (entry.clientId === clientId) { + pending.delete(requestId); + toFail.push(entry); + } + } + return { clients, owners, pending }; + }); + yield* Effect.forEach( + toFail, + ({ deferred }) => + Deferred.fail( + deferred, + new PreviewAutomationUnavailableError({ + message: "The preview automation client disconnected.", + }), + ), + { discard: true }, + ); + yield* Queue.shutdown(queue); + }); + + const connect: PreviewAutomationBrokerShape["connect"] = Effect.fn( + "PreviewAutomationBroker.connect", + )(function* (clientId) { + const queue = yield* Queue.unbounded<import("@t3tools/contracts").PreviewAutomationRequest>(); + let previous: ClientConnection | undefined; + yield* Ref.update(state, (current) => { + previous = current.clients.get(clientId); + const clients = new Map(current.clients); + clients.set(clientId, { clientId, queue }); + return { ...current, clients }; + }); + if (previous) yield* disconnect(clientId, previous.queue); + return Stream.fromQueue(queue).pipe(Stream.ensuring(disconnect(clientId, queue))); + }); + + const reportOwner: PreviewAutomationBrokerShape["reportOwner"] = Effect.fn( + "PreviewAutomationBroker.reportOwner", + )(function* (owner) { + yield* Ref.update(state, (current) => { + const owners = new Map(current.owners); + owners.set(owner.clientId, owner); + return { ...current, owners }; + }); + }); + + const clearOwner: PreviewAutomationBrokerShape["clearOwner"] = Effect.fn( + "PreviewAutomationBroker.clearOwner", + )(function* (clientId) { + yield* Ref.update(state, (current) => { + const owners = new Map(current.owners); + owners.delete(clientId); + return { ...current, owners }; + }); + }); + + const respond: PreviewAutomationBrokerShape["respond"] = Effect.fn( + "PreviewAutomationBroker.respond", + )(function* (response) { + let pending: PendingRequest | undefined; + yield* Ref.update(state, (current) => { + pending = current.pending.get(response.requestId); + if (!pending) return current; + const next = new Map(current.pending); + next.delete(response.requestId); + return { ...current, pending: next }; + }); + if (!pending) return; + if (response.ok) { + yield* Deferred.succeed(pending.deferred, response.result); + } else { + yield* Deferred.fail( + pending.deferred, + response.error + ? makeResponseError(response.error) + : new PreviewAutomationExecutionError({ + message: "Preview automation failed without an error payload.", + }), + ); + } + }); + + const invoke = Effect.fn("PreviewAutomationBroker.invoke")(function* <A = unknown>( + input: Parameters<PreviewAutomationBrokerShape["invoke"]>[0], + ): Effect.fn.Return<A, PreviewAutomationError> { + const current = yield* Ref.get(state); + const candidates = Array.from(current.owners.values()) + .filter( + (owner) => + owner.environmentId === input.scope.environmentId && + owner.threadId === input.scope.threadId && + owner.supportsAutomation, + ) + .sort((left, right) => right.focusedAt.localeCompare(left.focusedAt)); + const owner = candidates[0]; + if (!owner) { + return yield* new PreviewAutomationNoFocusedOwnerError({ + message: "No desktop browser host is available for this thread.", + }); + } + const connection = current.clients.get(owner.clientId); + if (!connection) { + return yield* new PreviewAutomationUnavailableError({ + message: "The browser host is not connected.", + }); + } + if ( + input.operation !== "open" && + input.operation !== "status" && + !owner.tabId && + !input.tabId + ) { + return yield* new PreviewAutomationTabNotFoundError({ + message: "The browser host does not have an active tab.", + }); + } + const requestId = `preview-${requestSequence++}`; + const timeoutMs = input.timeoutMs ?? 15_000; + const deferred = yield* Deferred.make<unknown, PreviewAutomationError>(); + yield* Ref.update(state, (next) => { + const pending = new Map(next.pending); + pending.set(requestId, { clientId: owner.clientId, deferred }); + return { ...next, pending }; + }); + const offered = yield* Queue.offer(connection.queue, { + requestId, + threadId: input.scope.threadId, + tabId: input.tabId ?? owner.tabId ?? undefined, + operation: input.operation, + input: input.input, + timeoutMs, + }); + if (!offered) { + return yield* new PreviewAutomationUnavailableError({ + message: "The preview automation client is no longer accepting requests.", + }); + } + const result = yield* Deferred.await(deferred).pipe(Effect.timeoutOption(timeoutMs)); + yield* Ref.update(state, (next) => { + const pending = new Map(next.pending); + pending.delete(requestId); + return { ...next, pending }; + }); + return yield* Option.match(result, { + onNone: () => + Effect.fail( + new PreviewAutomationTimeoutError({ + message: `Preview automation timed out after ${timeoutMs}ms.`, + }), + ), + onSome: (value) => Effect.succeed(value as A), + }); + }); + + return PreviewAutomationBroker.of({ connect, reportOwner, clearOwner, respond, invoke }); +}; + +const service = makeService(); +const make = Effect.succeed(service); +export const layer = Layer.effect(PreviewAutomationBroker, make); + +export const makeForTest = Effect.sync(makeService); diff --git a/apps/server/src/mcp/Services/McpSessionRegistry.ts b/apps/server/src/mcp/Services/McpSessionRegistry.ts deleted file mode 100644 index df2ca991271..00000000000 --- a/apps/server/src/mcp/Services/McpSessionRegistry.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { ProviderInstanceId, ThreadId } from "@t3tools/contracts"; -import * as Context from "effect/Context"; -import type * as Effect from "effect/Effect"; - -import type { McpInvocationScope } from "./McpInvocationContext.ts"; -import type { McpProviderSessionConfig } from "./McpProviderSession.ts"; - -export interface McpCredentialRequest { - readonly threadId: ThreadId; - readonly providerInstanceId: ProviderInstanceId; -} - -export interface McpIssuedCredential { - readonly config: McpProviderSessionConfig; - readonly expiresAt: number; -} - -export interface McpSessionRegistryShape { - readonly issue: (request: McpCredentialRequest) => Effect.Effect<McpIssuedCredential, never>; - readonly resolve: (rawToken: string) => Effect.Effect<McpInvocationScope | undefined, never>; - readonly revokeProviderSession: (providerSessionId: string) => Effect.Effect<void>; - readonly revokeThread: (threadId: ThreadId) => Effect.Effect<void>; - readonly revokeAll: Effect.Effect<void>; -} - -export class McpSessionRegistry extends Context.Service< - McpSessionRegistry, - McpSessionRegistryShape ->()("t3/mcp/Services/McpSessionRegistry") {} diff --git a/apps/server/src/mcp/Services/PreviewAutomationBroker.ts b/apps/server/src/mcp/Services/PreviewAutomationBroker.ts deleted file mode 100644 index 1af1a3d24bf..00000000000 --- a/apps/server/src/mcp/Services/PreviewAutomationBroker.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { - PreviewAutomationError, - PreviewAutomationOperation, - PreviewAutomationOwner, - PreviewAutomationRequest, - PreviewAutomationResponse, - PreviewTabId, -} from "@t3tools/contracts"; -import * as Context from "effect/Context"; -import type * as Effect from "effect/Effect"; -import type * as Stream from "effect/Stream"; - -import type { McpInvocationScope } from "./McpInvocationContext.ts"; - -export interface PreviewAutomationInvokeInput { - readonly scope: McpInvocationScope; - readonly operation: PreviewAutomationOperation; - readonly input: unknown; - readonly tabId?: PreviewTabId; - readonly timeoutMs?: number; -} - -export interface PreviewAutomationBrokerShape { - readonly connect: (clientId: string) => Effect.Effect<Stream.Stream<PreviewAutomationRequest>>; - readonly reportOwner: ( - owner: PreviewAutomationOwner, - ) => Effect.Effect<void, PreviewAutomationError>; - readonly clearOwner: (clientId: string) => Effect.Effect<void>; - readonly respond: ( - response: PreviewAutomationResponse, - ) => Effect.Effect<void, PreviewAutomationError>; - readonly invoke: <A = unknown>( - request: PreviewAutomationInvokeInput, - ) => Effect.Effect<A, PreviewAutomationError>; -} - -export class PreviewAutomationBroker extends Context.Service< - PreviewAutomationBroker, - PreviewAutomationBrokerShape ->()("t3/mcp/Services/PreviewAutomationBroker") {} diff --git a/apps/server/src/mcp/toolkits/preview/handlers.ts b/apps/server/src/mcp/toolkits/preview/handlers.ts index 62f91421343..56c9a6d98e4 100644 --- a/apps/server/src/mcp/toolkits/preview/handlers.ts +++ b/apps/server/src/mcp/toolkits/preview/handlers.ts @@ -7,28 +7,28 @@ import type { PreviewAutomationStatus, } from "@t3tools/contracts"; -import { requireMcpCapability } from "../../Services/McpInvocationContext.ts"; -import { previewAutomationBroker } from "../../Layers/PreviewAutomationBroker.ts"; +import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts"; import { PreviewToolkit } from "./tools.ts"; -const invoke = <A>( +const invoke = Effect.fn("PreviewToolkit.invoke")(function* <A>( operation: PreviewAutomationOperation, input: unknown, timeoutMs?: number, -): Effect.Effect< +): Effect.fn.Return< A, import("@t3tools/contracts").PreviewAutomationError, - import("../../Services/McpInvocationContext.ts").McpInvocationContext -> => - Effect.gen(function* () { - const scope = yield* requireMcpCapability("preview"); - return yield* previewAutomationBroker.invoke<A>({ - scope, - operation, - input, - ...(timeoutMs === undefined ? {} : { timeoutMs }), - }); + McpInvocationContext.McpInvocationContext | PreviewAutomationBroker.PreviewAutomationBroker +> { + const scope = yield* McpInvocationContext.requireMcpCapability("preview"); + const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; + return yield* broker.invoke<A>({ + scope, + operation, + input, + ...(timeoutMs === undefined ? {} : { timeoutMs }), }); +}); export const PreviewToolkitHandlersLive = PreviewToolkit.toLayer({ preview_status: () => invoke<PreviewAutomationStatus>("status", {}), diff --git a/apps/server/src/mcp/toolkits/preview/tools.ts b/apps/server/src/mcp/toolkits/preview/tools.ts index effd87fb7e4..bcfe720a950 100644 --- a/apps/server/src/mcp/toolkits/preview/tools.ts +++ b/apps/server/src/mcp/toolkits/preview/tools.ts @@ -16,7 +16,13 @@ import { import * as Schema from "effect/Schema"; import { Tool, Toolkit } from "effect/unstable/ai"; -import { McpInvocationContext } from "../../Services/McpInvocationContext.ts"; +import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts"; + +const dependencies = [ + McpInvocationContext.McpInvocationContext, + PreviewAutomationBroker.PreviewAutomationBroker, +]; const browserTool = <T extends Tool.Any>(tool: T): T => tool.annotate(Tool.OpenWorld, true).annotate(Tool.Destructive, true) as T; @@ -32,7 +38,7 @@ export const PreviewStatusTool = Tool.make("preview_status", { "Report whether the scoped thread has an automation-capable desktop preview, including its active tab, URL, title, visibility, and loading state.", success: PreviewAutomationStatus, failure: PreviewAutomationError, - dependencies: [McpInvocationContext], + dependencies, }) .annotate(Tool.Title, "Get preview status") .annotate(Tool.Readonly, true) @@ -46,7 +52,7 @@ export const PreviewOpenTool = browserTool( parameters: PreviewAutomationOpenInput, success: PreviewAutomationStatus, failure: PreviewAutomationError, - dependencies: [McpInvocationContext], + dependencies, }) .annotate(Tool.Title, "Open browser preview") .annotate(Tool.Destructive, false), @@ -59,7 +65,7 @@ export const PreviewNavigateTool = safeBrowserTool( parameters: PreviewAutomationNavigateInput, success: PreviewAutomationStatus, failure: PreviewAutomationError, - dependencies: [McpInvocationContext], + dependencies, }).annotate(Tool.Title, "Navigate browser preview"), ); @@ -69,7 +75,7 @@ export const PreviewSnapshotTool = readonlyBrowserTool( "Inspect the current page before interacting. Returns URL/title/loading state, visible text, semantic interactive elements with reusable selectors and coordinates, accessibility data, recent console/network failures, action history, and a PNG screenshot.", success: PreviewAutomationSnapshot, failure: PreviewAutomationError, - dependencies: [McpInvocationContext], + dependencies, }).annotate(Tool.Title, "Inspect browser page"), ); @@ -79,7 +85,7 @@ export const PreviewClickTool = browserTool( "Click exactly one page target. Prefer locator with a Playwright selector such as role=button[name='Send']; selector accepts legacy CSS; x and y are viewport CSS pixels and must be supplied together. Call preview_snapshot first when the target is unknown.", parameters: PreviewAutomationClickInput, failure: PreviewAutomationError, - dependencies: [McpInvocationContext], + dependencies, }).annotate(Tool.Title, "Click preview page"), ); @@ -89,7 +95,7 @@ export const PreviewTypeTool = browserTool( "Insert literal text into one input. Prefer locator with a Playwright role/text selector; selector accepts legacy CSS. If neither is supplied, types into the currently focused element. Set clear=true to replace existing text.", parameters: PreviewAutomationTypeInput, failure: PreviewAutomationError, - dependencies: [McpInvocationContext], + dependencies, }).annotate(Tool.Title, "Type into preview page"), ); @@ -99,7 +105,7 @@ export const PreviewPressTool = browserTool( "Press one keyboard key in the active page, for example {key:'Enter'}, {key:'Escape'}, or {key:'a',modifiers:['Meta']}. This targets the page's current focus.", parameters: PreviewAutomationPressInput, failure: PreviewAutomationError, - dependencies: [McpInvocationContext], + dependencies, }).annotate(Tool.Title, "Press key in preview page"), ); @@ -109,7 +115,7 @@ export const PreviewScrollTool = safeBrowserTool( "Scroll by CSS pixels. Positive deltaY scrolls down and positive deltaX scrolls right. Without locator/selector it scrolls the viewport; otherwise it scrolls that container. At least one delta is required.", parameters: PreviewAutomationScrollInput, failure: PreviewAutomationError, - dependencies: [McpInvocationContext], + dependencies, }).annotate(Tool.Title, "Scroll preview page"), ); @@ -120,7 +126,7 @@ export const PreviewEvaluateTool = browserTool( parameters: PreviewAutomationEvaluateInput, success: Schema.Unknown, failure: PreviewAutomationError, - dependencies: [McpInvocationContext], + dependencies, }).annotate(Tool.Title, "Evaluate JavaScript in preview"), ); @@ -130,7 +136,7 @@ export const PreviewWaitForTool = readonlyBrowserTool( "Wait until all supplied conditions match: a Playwright locator, legacy CSS selector, visible-text substring, and/or URL substring. Provide at least one condition. Defaults to 15 seconds, maximum 60 seconds.", parameters: PreviewAutomationWaitForInput, failure: PreviewAutomationError, - dependencies: [McpInvocationContext], + dependencies, }).annotate(Tool.Title, "Wait for preview page condition"), ); @@ -140,7 +146,7 @@ export const PreviewRecordingStartTool = safeBrowserTool( "Start recording the active collaborative browser tab while keeping it interactive for both agent and human use.", success: PreviewAutomationRecordingStatus, failure: PreviewAutomationError, - dependencies: [McpInvocationContext], + dependencies, }).annotate(Tool.Title, "Start browser recording"), ); @@ -149,7 +155,7 @@ export const PreviewRecordingStopTool = safeBrowserTool( description: "Stop the active browser recording and save it as a local evidence artifact.", success: PreviewAutomationRecordingArtifact, failure: PreviewAutomationError, - dependencies: [McpInvocationContext], + dependencies, }).annotate(Tool.Title, "Stop browser recording"), ); diff --git a/apps/server/src/preview/Layers/Manager.test.ts b/apps/server/src/preview/Manager.test.ts similarity index 89% rename from apps/server/src/preview/Layers/Manager.test.ts rename to apps/server/src/preview/Manager.test.ts index 97a64ec844f..a910e27470d 100644 --- a/apps/server/src/preview/Layers/Manager.test.ts +++ b/apps/server/src/preview/Manager.test.ts @@ -3,8 +3,7 @@ import { type PreviewEvent, ThreadId } from "@t3tools/contracts"; import { Effect, PubSub } from "effect"; import { expect } from "vite-plus/test"; -import { PreviewManager } from "../Services/Manager.ts"; -import { PreviewManagerLive } from "./Manager.ts"; +import * as PreviewManager from "./Manager.ts"; const DRAIN_LIMIT = 100; @@ -27,7 +26,7 @@ const freshThreadId = () => ThreadId.make(`thread-${++nextThreadId}`); * no event can land between subscribe and the consumer drain. */ const collectEvents = Effect.gen(function* () { - const manager = yield* PreviewManager; + const manager = yield* PreviewManager.PreviewManager; const subscription = yield* manager.subscribeEvents; const collector: EventCollector = { drain: PubSub.takeUpTo(subscription, DRAIN_LIMIT), @@ -35,11 +34,11 @@ const collectEvents = Effect.gen(function* () { return collector; }).pipe(Effect.withSpan("preview.test.collectEvents")); -it.layer(PreviewManagerLive)("PreviewManager", (it) => { +it.layer(PreviewManager.layer)("PreviewManager", (it) => { it.effect("opens a session and emits opened with normalized URL", () => Effect.gen(function* () { const threadId = freshThreadId(); - const manager = yield* PreviewManager; + const manager = yield* PreviewManager.PreviewManager; const collector = yield* collectEvents; const snapshot = yield* manager.open({ threadId, url: "localhost:5173" }); @@ -61,7 +60,7 @@ it.layer(PreviewManagerLive)("PreviewManager", (it) => { it.effect("opens an Idle tab when no URL is supplied", () => Effect.gen(function* () { const threadId = freshThreadId(); - const manager = yield* PreviewManager; + const manager = yield* PreviewManager.PreviewManager; const snapshot = yield* manager.open({ threadId }); expect(snapshot.navStatus._tag).toBe("Idle"); }), @@ -70,7 +69,7 @@ it.layer(PreviewManagerLive)("PreviewManager", (it) => { it.effect("treats bare hosts as https", () => Effect.gen(function* () { const threadId = freshThreadId(); - const manager = yield* PreviewManager; + const manager = yield* PreviewManager.PreviewManager; const snapshot = yield* manager.open({ threadId, url: "example.com" }); if (snapshot.navStatus._tag === "Loading") { expect(snapshot.navStatus.url).toBe("https://example.com/"); @@ -81,7 +80,7 @@ it.layer(PreviewManagerLive)("PreviewManager", (it) => { it.effect("rejects empty URL with PreviewInvalidUrlError", () => Effect.gen(function* () { const threadId = freshThreadId(); - const manager = yield* PreviewManager; + const manager = yield* PreviewManager.PreviewManager; const error = yield* Effect.flip(manager.open({ threadId, url: " " })); expect(error._tag).toBe("PreviewInvalidUrlError"); }), @@ -90,7 +89,7 @@ it.layer(PreviewManagerLive)("PreviewManager", (it) => { it.effect("navigate updates snapshot and emits navigated", () => Effect.gen(function* () { const threadId = freshThreadId(); - const manager = yield* PreviewManager; + const manager = yield* PreviewManager.PreviewManager; const collector = yield* collectEvents; const opened = yield* manager.open({ threadId, url: "http://localhost:5173" }); @@ -114,7 +113,7 @@ it.layer(PreviewManagerLive)("PreviewManager", (it) => { it.effect("navigate fails for unknown tab", () => Effect.gen(function* () { const threadId = freshThreadId(); - const manager = yield* PreviewManager; + const manager = yield* PreviewManager.PreviewManager; const error = yield* Effect.flip( manager.navigate({ threadId, @@ -129,7 +128,7 @@ it.layer(PreviewManagerLive)("PreviewManager", (it) => { it.effect("reportStatus emits failed for LoadFailed nav", () => Effect.gen(function* () { const threadId = freshThreadId(); - const manager = yield* PreviewManager; + const manager = yield* PreviewManager.PreviewManager; const collector = yield* collectEvents; const opened = yield* manager.open({ threadId, url: "http://localhost:5173" }); @@ -160,7 +159,7 @@ it.layer(PreviewManagerLive)("PreviewManager", (it) => { it.effect("close removes the session and emits closed", () => Effect.gen(function* () { const threadId = freshThreadId(); - const manager = yield* PreviewManager; + const manager = yield* PreviewManager.PreviewManager; const collector = yield* collectEvents; yield* manager.open({ threadId, url: "http://localhost:5173" }); @@ -177,7 +176,7 @@ it.layer(PreviewManagerLive)("PreviewManager", (it) => { it.effect("close is idempotent for unknown threads", () => Effect.gen(function* () { const threadId = freshThreadId(); - const manager = yield* PreviewManager; + const manager = yield* PreviewManager.PreviewManager; yield* manager.close({ threadId }); const result = yield* manager.list({ threadId }); expect(result.sessions).toHaveLength(0); @@ -187,7 +186,7 @@ it.layer(PreviewManagerLive)("PreviewManager", (it) => { it.effect("list returns every snapshot for the thread sorted by updatedAt", () => Effect.gen(function* () { const threadId = freshThreadId(); - const manager = yield* PreviewManager; + const manager = yield* PreviewManager.PreviewManager; const first = yield* manager.open({ threadId, url: "http://localhost:5173" }); const second = yield* manager.open({ threadId, url: "http://localhost:3000" }); const result = yield* manager.list({ threadId }); @@ -201,7 +200,7 @@ it.layer(PreviewManagerLive)("PreviewManager", (it) => { it.effect("open creates an independent tab on every call", () => Effect.gen(function* () { const threadId = freshThreadId(); - const manager = yield* PreviewManager; + const manager = yield* PreviewManager.PreviewManager; const collector = yield* collectEvents; const a = yield* manager.open({ threadId, url: "http://localhost:5173" }); @@ -219,7 +218,7 @@ it.layer(PreviewManagerLive)("PreviewManager", (it) => { it.effect("close with mismatching tabId is a no-op", () => Effect.gen(function* () { const threadId = freshThreadId(); - const manager = yield* PreviewManager; + const manager = yield* PreviewManager.PreviewManager; yield* manager.open({ threadId, url: "http://localhost:5173" }); yield* manager.close({ threadId, tabId: "tab_missing" }); @@ -231,7 +230,7 @@ it.layer(PreviewManagerLive)("PreviewManager", (it) => { it.effect("close with explicit tabId removes only that tab", () => Effect.gen(function* () { const threadId = freshThreadId(); - const manager = yield* PreviewManager; + const manager = yield* PreviewManager.PreviewManager; const a = yield* manager.open({ threadId, url: "http://localhost:5173" }); const b = yield* manager.open({ threadId, url: "http://localhost:3000" }); @@ -245,7 +244,7 @@ it.layer(PreviewManagerLive)("PreviewManager", (it) => { it.effect("multiple subscribers receive every event independently", () => Effect.gen(function* () { const threadId = freshThreadId(); - const manager = yield* PreviewManager; + const manager = yield* PreviewManager.PreviewManager; const aSub = yield* manager.subscribeEvents; const bSub = yield* manager.subscribeEvents; diff --git a/apps/server/src/preview/Layers/Manager.ts b/apps/server/src/preview/Manager.ts similarity index 63% rename from apps/server/src/preview/Layers/Manager.ts rename to apps/server/src/preview/Manager.ts index dc58b3f9f13..8a52dbcc8c3 100644 --- a/apps/server/src/preview/Layers/Manager.ts +++ b/apps/server/src/preview/Manager.ts @@ -10,9 +10,16 @@ * fail an in-progress `navigate()`). */ import { + type PreviewCloseInput, type PreviewEvent, + type PreviewError, PreviewInvalidUrlError, + type PreviewListInput, type PreviewListResult, + type PreviewNavigateInput, + type PreviewOpenInput, + type PreviewRefreshInput, + type PreviewReportStatusInput, PreviewSessionLookupError, type PreviewSessionSnapshot, } from "@t3tools/contracts"; @@ -21,9 +28,33 @@ import { normalizePreviewUrl, PreviewUrlNormalizationError, } from "@t3tools/shared/preview"; -import { DateTime, Effect, Layer, PubSub, Stream, SynchronizedRef } from "effect"; +import { + Context, + DateTime, + Effect, + Layer, + PubSub, + type Scope, + Stream, + SynchronizedRef, +} from "effect"; + +export interface PreviewManagerShape { + readonly open: (input: PreviewOpenInput) => Effect.Effect<PreviewSessionSnapshot, PreviewError>; + readonly navigate: ( + input: PreviewNavigateInput, + ) => Effect.Effect<PreviewSessionSnapshot, PreviewError>; + readonly reportStatus: (input: PreviewReportStatusInput) => Effect.Effect<void, PreviewError>; + readonly refresh: (input: PreviewRefreshInput) => Effect.Effect<void, PreviewError>; + readonly close: (input: PreviewCloseInput) => Effect.Effect<void, PreviewError>; + readonly list: (input: PreviewListInput) => Effect.Effect<PreviewListResult>; + readonly events: Stream.Stream<PreviewEvent>; + readonly subscribeEvents: Effect.Effect<PubSub.Subscription<PreviewEvent>, never, Scope.Scope>; +} -import { PreviewManager, type PreviewManagerShape } from "../Services/Manager.ts"; +export class PreviewManager extends Context.Service<PreviewManager, PreviewManagerShape>()( + "t3/preview/Manager/PreviewManager", +) {} interface PreviewSessionState { readonly threadId: string; @@ -96,7 +127,7 @@ const buildIdleSnapshot = (input: { updatedAt: input.updatedAt, }); -export const makePreviewManager = Effect.gen(function* () { +const make = Effect.gen(function* () { const stateRef = yield* SynchronizedRef.make<ManagerState>(initialState); // Unbounded PubSub is fine here — events are tiny and we don't want to // block publishers if a subscriber is slow. WS clients backpressure on @@ -134,8 +165,8 @@ export const makePreviewManager = Effect.gen(function* () { ] as readonly [ModifyResult, ManagerState]); } return mutator(session).pipe( - Effect.flatMap(({ next, emit, result }) => - Effect.gen(function* () { + Effect.flatMap( + Effect.fn("PreviewManager.commitMutation")(function* ({ next, emit, result }) { if (emit) yield* PubSub.publish(eventsPubSub, emit); const sessions = new Map(state.sessions); sessions.set(compositeKey(threadId, tabId), next); @@ -153,43 +184,44 @@ export const makePreviewManager = Effect.gen(function* () { ); }; - const open: PreviewManagerShape["open"] = (input) => - Effect.gen(function* () { - const tabId = newPreviewTabId(); - const updatedAt = yield* currentIsoTimestamp; - const snapshot = input.url - ? buildLoadingSnapshot({ - threadId: input.threadId, - tabId, - url: yield* normalizeUrl(input.url), - title: "", - updatedAt, - }) - : buildIdleSnapshot({ threadId: input.threadId, tabId, updatedAt }); - yield* SynchronizedRef.update(stateRef, (state) => { - const sessions = new Map(state.sessions); - sessions.set(compositeKey(input.threadId, tabId), { + const open: PreviewManagerShape["open"] = Effect.fn("PreviewManager.open")(function* (input) { + const tabId = newPreviewTabId(); + const updatedAt = yield* currentIsoTimestamp; + const snapshot = input.url + ? buildLoadingSnapshot({ threadId: input.threadId, tabId, - snapshot, - }); - return { sessions }; - }); - yield* PubSub.publish(eventsPubSub, { - type: "opened", + url: yield* normalizeUrl(input.url), + title: "", + updatedAt, + }) + : buildIdleSnapshot({ threadId: input.threadId, tabId, updatedAt }); + yield* SynchronizedRef.update(stateRef, (state) => { + const sessions = new Map(state.sessions); + sessions.set(compositeKey(input.threadId, tabId), { threadId: input.threadId, tabId, - createdAt: snapshot.updatedAt, snapshot, }); - return snapshot; + return { sessions }; }); + yield* PubSub.publish(eventsPubSub, { + type: "opened", + threadId: input.threadId, + tabId, + createdAt: snapshot.updatedAt, + snapshot, + }); + return snapshot; + }); - const navigate: PreviewManagerShape["navigate"] = (input) => - Effect.gen(function* () { + const navigate: PreviewManagerShape["navigate"] = Effect.fn("PreviewManager.navigate")( + function* (input) { const url = yield* normalizeUrl(input.url); - return yield* mutateExistingSession(input.threadId, input.tabId, (session) => - Effect.gen(function* () { + return yield* mutateExistingSession( + input.threadId, + input.tabId, + Effect.fn("PreviewManager.navigateSession")(function* (session) { const updatedAt = yield* currentIsoTimestamp; const previousTitle = session.snapshot.navStatus._tag === "Idle" ? "" : session.snapshot.navStatus.title; @@ -215,11 +247,16 @@ export const makePreviewManager = Effect.gen(function* () { }; }), ); - }); + }, + ); - const reportStatus: PreviewManagerShape["reportStatus"] = (input) => - mutateExistingSession(input.threadId, input.tabId, (session) => - Effect.gen(function* () { + const reportStatus: PreviewManagerShape["reportStatus"] = Effect.fn( + "PreviewManager.reportStatus", + )(function* (input) { + yield* mutateExistingSession( + input.threadId, + input.tabId, + Effect.fn("PreviewManager.reportSessionStatus")(function* (session) { const updatedAt = yield* currentIsoTimestamp; const snapshot: PreviewSessionSnapshot = { threadId: session.threadId, @@ -255,48 +292,51 @@ export const makePreviewManager = Effect.gen(function* () { }; }), ); + }); - const refresh: PreviewManagerShape["refresh"] = (input) => - // Verify the session exists; the desktop bridge handles the actual reload - // and will report progress back via `reportStatus`. No event emitted. - mutateExistingSession(input.threadId, input.tabId, (session) => - Effect.succeed({ next: session, emit: null, result: undefined as void }), - ); + const refresh: PreviewManagerShape["refresh"] = Effect.fn("PreviewManager.refresh")( + function* (input) { + // Verify the session exists; the desktop bridge handles the actual reload + // and will report progress back via `reportStatus`. No event emitted. + yield* mutateExistingSession(input.threadId, input.tabId, (session) => + Effect.succeed({ next: session, emit: null, result: undefined as void }), + ); + }, + ); - const close: PreviewManagerShape["close"] = (input) => - Effect.gen(function* () { - const createdAt = yield* currentIsoTimestamp; - const events = yield* SynchronizedRef.modify(stateRef, (state) => { - const eventsToEmit: PreviewEvent[] = []; - const sessions = new Map(state.sessions); - const targets = input.tabId - ? [state.sessions.get(compositeKey(input.threadId, input.tabId))].filter( - (entry): entry is PreviewSessionState => entry !== undefined, - ) - : sessionsForThread(state, input.threadId); - for (const target of targets) { - sessions.delete(compositeKey(target.threadId, target.tabId)); - eventsToEmit.push({ - type: "closed", - threadId: target.threadId, - tabId: target.tabId, - createdAt, - }); - } - if (eventsToEmit.length === 0) { - return [eventsToEmit, state] as const; - } - return [eventsToEmit, { sessions }] as const; - }); - if (events.length > 0) { - yield* Effect.forEach(events, (event) => PubSub.publish(eventsPubSub, event), { - discard: true, + const close: PreviewManagerShape["close"] = Effect.fn("PreviewManager.close")(function* (input) { + const createdAt = yield* currentIsoTimestamp; + const events = yield* SynchronizedRef.modify(stateRef, (state) => { + const eventsToEmit: PreviewEvent[] = []; + const sessions = new Map(state.sessions); + const targets = input.tabId + ? [state.sessions.get(compositeKey(input.threadId, input.tabId))].filter( + (entry): entry is PreviewSessionState => entry !== undefined, + ) + : sessionsForThread(state, input.threadId); + for (const target of targets) { + sessions.delete(compositeKey(target.threadId, target.tabId)); + eventsToEmit.push({ + type: "closed", + threadId: target.threadId, + tabId: target.tabId, + createdAt, }); } + if (eventsToEmit.length === 0) { + return [eventsToEmit, state] as const; + } + return [eventsToEmit, { sessions }] as const; }); + if (events.length > 0) { + yield* Effect.forEach(events, (event) => PubSub.publish(eventsPubSub, event), { + discard: true, + }); + } + }); - const list: PreviewManagerShape["list"] = (input) => - SynchronizedRef.get(stateRef).pipe( + const list: PreviewManagerShape["list"] = Effect.fn("PreviewManager.list")(function* (input) { + return yield* SynchronizedRef.get(stateRef).pipe( Effect.map( (state): PreviewListResult => ({ sessions: sessionsForThread(state, input.threadId) @@ -305,6 +345,7 @@ export const makePreviewManager = Effect.gen(function* () { }), ), ); + }); return { open, @@ -318,4 +359,4 @@ export const makePreviewManager = Effect.gen(function* () { } satisfies PreviewManagerShape; }); -export const PreviewManagerLive = Layer.effect(PreviewManager, makePreviewManager); +export const layer = Layer.effect(PreviewManager, make); diff --git a/apps/server/src/preview/Layers/PortScanner.test.ts b/apps/server/src/preview/PortScanner.test.ts similarity index 94% rename from apps/server/src/preview/Layers/PortScanner.test.ts rename to apps/server/src/preview/PortScanner.test.ts index f0e78aeedbc..0f75a538ec6 100644 --- a/apps/server/src/preview/Layers/PortScanner.test.ts +++ b/apps/server/src/preview/PortScanner.test.ts @@ -5,16 +5,15 @@ import { ThreadId } from "@t3tools/contracts"; import { Effect, Layer } from "effect"; import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; -import { COMMON_DEV_PORTS, PortDiscovery } from "../Services/PortScanner.ts"; -import { ProcessRunner } from "../../processRunner.ts"; -import { __testing, PortDiscoveryLive } from "./PortScanner.ts"; +import { ProcessRunner } from "../processRunner.ts"; +import * as PortScanner from "./PortScanner.ts"; const { parseLsofOutput, parsePortFromLsofName, parseWindowsListenerOutput, serversEqual } = - __testing; + PortScanner.__testing; const TestProcessRunner = Layer.succeed(ProcessRunner, { run: () => Effect.die("ProcessRunner should not be used by Windows TCP probe tests"), }); -const TestPortDiscoveryLive = PortDiscoveryLive.pipe(Layer.provide(TestProcessRunner)); +const TestPortDiscoveryLive = PortScanner.layer.pipe(Layer.provide(TestProcessRunner)); describe("parsePortFromLsofName", () => { it("parses *:port", () => { @@ -189,7 +188,7 @@ describe("PortDiscovery integration (TCP probe fallback)", () => { beforeEach(async () => { originalPlatform = process.platform; Object.defineProperty(process, "platform", { value: "win32", configurable: true }); - for (const candidate of COMMON_DEV_PORTS) { + for (const candidate of PortScanner.COMMON_DEV_PORTS) { const candidateServer = net.createServer(); const listening = await new Promise<boolean>((resolve) => { candidateServer.once("error", () => resolve(false)); @@ -215,7 +214,7 @@ describe("PortDiscovery integration (TCP probe fallback)", () => { effectIt.effect("scan() returns a server we just opened on a curated dev port", () => Effect.gen(function* () { - const scanner = yield* PortDiscovery; + const scanner = yield* PortScanner.PortDiscovery; const result = yield* scanner.scan(); const found = result.find((server) => server.port === port); expect(found).toBeDefined(); @@ -226,7 +225,7 @@ describe("PortDiscovery integration (TCP probe fallback)", () => { effectIt.effect("retain() drives an immediate broadcast to subscribers", () => { const received: number[] = []; return Effect.gen(function* () { - const scanner = yield* PortDiscovery; + const scanner = yield* PortScanner.PortDiscovery; const unsubscribe = yield* scanner.subscribe((servers) => Effect.sync(() => { for (const server of servers) received.push(server.port); diff --git a/apps/server/src/preview/Layers/PortScanner.ts b/apps/server/src/preview/PortScanner.ts similarity index 69% rename from apps/server/src/preview/Layers/PortScanner.ts rename to apps/server/src/preview/PortScanner.ts index 4e4d3b758a3..008de8ae798 100644 --- a/apps/server/src/preview/Layers/PortScanner.ts +++ b/apps/server/src/preview/PortScanner.ts @@ -15,14 +15,34 @@ import * as net from "node:net"; import { ThreadId, type DiscoveredLocalServer } from "@t3tools/contracts"; import { LSOF_LOCAL_HOST_TOKENS } from "@t3tools/shared/preview"; -import { Cause, Duration, Effect, Layer, Ref, Schedule } from "effect"; +import { Cause, Context, Duration, Effect, Layer, Ref, Schedule } from "effect"; -import { ProcessRunner } from "../../processRunner.ts"; -import { - COMMON_DEV_PORTS, - PortDiscovery, - type PortDiscoveryShape, -} from "../Services/PortScanner.ts"; +import { ProcessRunner } from "../processRunner.ts"; + +export interface PortDiscoveryShape { + readonly scan: () => Effect.Effect<ReadonlyArray<DiscoveredLocalServer>>; + readonly subscribe: ( + listener: (servers: ReadonlyArray<DiscoveredLocalServer>) => Effect.Effect<void>, + ) => Effect.Effect<() => void>; + readonly retain: () => Effect.Effect<() => void>; + readonly registerTerminalProcesses: (input: { + readonly threadId: string; + readonly terminalId: string; + readonly processIds: ReadonlyArray<number>; + }) => Effect.Effect<void>; + readonly unregisterTerminal: (input: { + readonly threadId: string; + readonly terminalId: string; + }) => Effect.Effect<void>; +} + +export class PortDiscovery extends Context.Service<PortDiscovery, PortDiscoveryShape>()( + "t3/preview/PortScanner/PortDiscovery", +) {} + +export const COMMON_DEV_PORTS: ReadonlyArray<number> = Object.freeze([ + 3000, 3001, 3333, 4173, 4200, 4321, 5000, 5173, 5174, 5175, 5500, 8000, 8080, 8081, 8888, 9000, +]); const POLL_INTERVAL = Duration.seconds(3); const TCP_PROBE_TIMEOUT_MS = 200; @@ -186,7 +206,7 @@ const serversEqual = ( return true; }; -export const makePortDiscovery = Effect.gen(function* () { +const make = Effect.gen(function* () { const processRunner = yield* ProcessRunner; const stateRef = yield* Ref.make<ScannerState>({ lastSnapshot: [], @@ -204,47 +224,46 @@ export const makePortDiscovery = Effect.gen(function* () { // be a synchronous side-effect-only function. let retainCount = 0; - const scanOnce = (): Effect.Effect<ReadonlyArray<DiscoveredLocalServer>> => - Effect.gen(function* () { - const terminalByProcessId = new Map<number, TerminalProcessOwner>(); - for (const registration of terminalProcesses.values()) { - for (const processId of registration.processIds) { - terminalByProcessId.set(processId, registration.owner); - } - } - if (process.platform === "win32") { - const command = - 'Get-NetTCPConnection -State Listen -ErrorAction Stop | ForEach-Object { $processName = (Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue).ProcessName; Write-Output "$($_.LocalAddress)|$($_.LocalPort)|$($_.OwningProcess)|$processName" }'; - const listeners = yield* processRunner - .run({ - command: "powershell.exe", - args: ["-NoProfile", "-NonInteractive", "-Command", command], - timeout: Duration.millis(WINDOWS_LISTENER_TIMEOUT_MS), - maxOutputBytes: 1024 * 1024, - outputMode: "truncate", - }) - .pipe( - Effect.map((result) => parseWindowsListenerOutput(result.stdout, terminalByProcessId)), - Effect.catchCause(() => Effect.succeed(null)), - ); - if (listeners !== null) return listeners; - return yield* probeCommonPorts(); + const scanOnce = Effect.fn("PortDiscovery.scan")(function* () { + const terminalByProcessId = new Map<number, TerminalProcessOwner>(); + for (const registration of terminalProcesses.values()) { + for (const processId of registration.processIds) { + terminalByProcessId.set(processId, registration.owner); } - const lsofResult = yield* processRunner + } + if (process.platform === "win32") { + const command = + 'Get-NetTCPConnection -State Listen -ErrorAction Stop | ForEach-Object { $processName = (Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue).ProcessName; Write-Output "$($_.LocalAddress)|$($_.LocalPort)|$($_.OwningProcess)|$processName" }'; + const listeners = yield* processRunner .run({ - command: "lsof", - args: ["-iTCP", "-sTCP:LISTEN", "-P", "-n", "-F", "pcn"], - timeout: Duration.millis(LSOF_TIMEOUT_MS), + command: "powershell.exe", + args: ["-NoProfile", "-NonInteractive", "-Command", command], + timeout: Duration.millis(WINDOWS_LISTENER_TIMEOUT_MS), maxOutputBytes: 1024 * 1024, outputMode: "truncate", }) .pipe( - Effect.map((result) => parseLsofOutput(result.stdout, terminalByProcessId)), + Effect.map((result) => parseWindowsListenerOutput(result.stdout, terminalByProcessId)), Effect.catchCause(() => Effect.succeed(null)), ); - if (lsofResult !== null) return lsofResult; + if (listeners !== null) return listeners; return yield* probeCommonPorts(); - }); + } + const lsofResult = yield* processRunner + .run({ + command: "lsof", + args: ["-iTCP", "-sTCP:LISTEN", "-P", "-n", "-F", "pcn"], + timeout: Duration.millis(LSOF_TIMEOUT_MS), + maxOutputBytes: 1024 * 1024, + outputMode: "truncate", + }) + .pipe( + Effect.map((result) => parseLsofOutput(result.stdout, terminalByProcessId)), + Effect.catchCause(() => Effect.succeed(null)), + ); + if (lsofResult !== null) return lsofResult; + return yield* probeCommonPorts(); + }); const broadcast = (servers: ReadonlyArray<DiscoveredLocalServer>): Effect.Effect<void> => Effect.forEach(Array.from(listeners), (listener) => listener(servers), { discard: true }); @@ -266,33 +285,37 @@ export const makePortDiscovery = Effect.gen(function* () { // currently retained, so the cost is one Ref.get every POLL_INTERVAL. yield* Effect.forkScoped(pollTick.pipe(Effect.repeat(Schedule.spaced(POLL_INTERVAL)))); - const retain: PortDiscoveryShape["retain"] = () => - Effect.gen(function* () { - const wasIdle = retainCount === 0; - retainCount += 1; - if (wasIdle) { - // Run an immediate scan + broadcast so the new retainer doesn't have - // to wait up to POLL_INTERVAL for the first emission. - yield* pollTick; - } - let released = false; - return () => { - if (released) return; - released = true; - retainCount = Math.max(0, retainCount - 1); - }; - }); + const retain: PortDiscoveryShape["retain"] = Effect.fn("PortDiscovery.retain")(function* () { + const wasIdle = retainCount === 0; + retainCount += 1; + if (wasIdle) { + // Run an immediate scan + broadcast so the new retainer doesn't have + // to wait up to POLL_INTERVAL for the first emission. + yield* pollTick; + } + let released = false; + return () => { + if (released) return; + released = true; + retainCount = Math.max(0, retainCount - 1); + }; + }); - const subscribe: PortDiscoveryShape["subscribe"] = (listener) => - Effect.sync(() => { - listeners.add(listener); - return () => { - listeners.delete(listener); - }; - }); + const subscribe: PortDiscoveryShape["subscribe"] = Effect.fn("PortDiscovery.subscribe")( + function* (listener) { + return yield* Effect.sync(() => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }); + }, + ); - const registerTerminalProcesses: PortDiscoveryShape["registerTerminalProcesses"] = (input) => - Effect.sync(() => { + const registerTerminalProcesses: PortDiscoveryShape["registerTerminalProcesses"] = Effect.fn( + "PortDiscovery.registerTerminalProcesses", + )(function* (input) { + yield* Effect.sync(() => { const owner = { threadId: ThreadId.make(input.threadId), terminalId: input.terminalId, @@ -307,11 +330,15 @@ export const makePortDiscovery = Effect.gen(function* () { } terminalProcesses.set(key, { owner, processIds }); }); + }); - const unregisterTerminal: PortDiscoveryShape["unregisterTerminal"] = (input) => - Effect.sync(() => { + const unregisterTerminal: PortDiscoveryShape["unregisterTerminal"] = Effect.fn( + "PortDiscovery.unregisterTerminal", + )(function* (input) { + yield* Effect.sync(() => { terminalProcesses.delete(terminalOwnerKey(input)); }); + }); return { scan: scanOnce, @@ -322,7 +349,7 @@ export const makePortDiscovery = Effect.gen(function* () { } satisfies PortDiscoveryShape; }); -export const PortDiscoveryLive = Layer.effect(PortDiscovery, makePortDiscovery); +export const layer = Layer.effect(PortDiscovery, make); /** Exposed for tests. */ export const __testing = { diff --git a/apps/server/src/preview/Services/Manager.ts b/apps/server/src/preview/Services/Manager.ts deleted file mode 100644 index 7c80c21c542..00000000000 --- a/apps/server/src/preview/Services/Manager.ts +++ /dev/null @@ -1,94 +0,0 @@ -/** - * PreviewManager - Per-thread browser preview session orchestration. - * - * Owns metadata-only preview sessions keyed by `(threadId, tabId)`. A thread - * can host multiple concurrent tabs (browser-style). The actual Chromium - * <webview> lives in the desktop renderer; this service tracks the canonical - * URL/title/nav state per tab so sessions survive reconnects and are visible - * to multiple connected clients. - * - * Event delivery uses Effect's `PubSub` so listener failures (e.g. a - * disconnected WS subscriber's queue being closed) cannot propagate back - * into the mutating call's failure channel — matches the codebase - * convention used by `SessionCredentialService`. - * - * @module PreviewManager - */ -import { - type PreviewCloseInput, - type PreviewError, - type PreviewEvent, - type PreviewListInput, - type PreviewListResult, - type PreviewNavigateInput, - type PreviewOpenInput, - type PreviewRefreshInput, - type PreviewReportStatusInput, - type PreviewSessionSnapshot, -} from "@t3tools/contracts"; -import { Context, type Effect, type PubSub, type Scope, type Stream } from "effect"; - -export interface PreviewManagerShape { - /** - * Open a brand new preview tab for `threadId`. When `url` is omitted the - * tab starts in the `Idle` state so the user can type into the URL bar; - * otherwise it transitions straight to `Loading`. Always emits `opened`. - */ - readonly open: (input: PreviewOpenInput) => Effect.Effect<PreviewSessionSnapshot, PreviewError>; - - /** - * Update the session's URL/title from the renderer (after the <webview> - * resolved navigation, including redirects). Emits a `navigated` event. - */ - readonly navigate: ( - input: PreviewNavigateInput, - ) => Effect.Effect<PreviewSessionSnapshot, PreviewError>; - - /** - * Renderer reports current nav status (Loading/Success/LoadFailed) and - * back/forward availability. Drives `failed` and `navigated` events. - */ - readonly reportStatus: (input: PreviewReportStatusInput) => Effect.Effect<void, PreviewError>; - - /** - * Renderer requested a reload. Server records intent (no state change) so - * subscribers can reconcile if needed; the desktop bridge actually reloads. - */ - readonly refresh: (input: PreviewRefreshInput) => Effect.Effect<void, PreviewError>; - - /** - * Close the session (drop server-side state, emit `closed`). When `tabId` - * is omitted, closes every session for the thread. - */ - readonly close: (input: PreviewCloseInput) => Effect.Effect<void, PreviewError>; - - /** - * List active preview sessions for a thread. Returns an empty array when - * the thread has no sessions. - */ - readonly list: (input: PreviewListInput) => Effect.Effect<PreviewListResult>; - - /** - * Stream of preview events. Each subscriber gets its own buffered stream; - * subscriber failures are isolated and do not affect other subscribers - * or publishers. - * - * NOTE: `Stream.fromPubSub` defers `PubSub.subscribe` until the stream - * starts running, so `Stream.runForEach(...).pipe(Effect.forkScoped)` may - * miss a publish landing between fork and stream-start. Callers that - * cannot tolerate this gap should use `subscribeEvents` below. - */ - readonly events: Stream.Stream<PreviewEvent>; - - /** - * Acquire a PubSub subscription synchronously in the caller's fiber so - * no publish can land in the narrow gap between subscribe and consumer - * loop start. The subscription's lifetime is bound to the provided - * `Scope`; release happens automatically on scope close. - */ - readonly subscribeEvents: Effect.Effect<PubSub.Subscription<PreviewEvent>, never, Scope.Scope>; -} - -export class PreviewManager extends Context.Service<PreviewManager, PreviewManagerShape>()( - "t3/preview/Services/Manager/PreviewManager", -) {} diff --git a/apps/server/src/preview/Services/PortScanner.ts b/apps/server/src/preview/Services/PortScanner.ts deleted file mode 100644 index af69596ba5a..00000000000 --- a/apps/server/src/preview/Services/PortScanner.ts +++ /dev/null @@ -1,46 +0,0 @@ -/** - * PortDiscovery - Discovers listening localhost ports and attributes them to - * registered terminal process families. - * - * Reference-counted polling: the scanner only runs when at least one client - * has called `retain()`. This keeps idle desktops from running `lsof` every - * 3 seconds for nothing. - * - * @module PortDiscovery - */ -import type { DiscoveredLocalServer } from "@t3tools/contracts"; -import { Context, type Effect } from "effect"; - -export interface PortDiscoveryShape { - /** One-shot snapshot of currently listening localhost ports. */ - readonly scan: () => Effect.Effect<ReadonlyArray<DiscoveredLocalServer>>; - /** Subscribe to changes. Listener invoked on every diff. Returns unsubscribe fn. */ - readonly subscribe: ( - listener: (servers: ReadonlyArray<DiscoveredLocalServer>) => Effect.Effect<void>, - ) => Effect.Effect<() => void>; - /** - * Hint that at least one client is interested → starts polling. Returns - * release fn. When release count hits 0, polling stops. - */ - readonly retain: () => Effect.Effect<() => void>; - /** Associate a terminal with its current process family for port attribution. */ - readonly registerTerminalProcesses: (input: { - readonly threadId: string; - readonly terminalId: string; - readonly processIds: ReadonlyArray<number>; - }) => Effect.Effect<void>; - /** Remove process attribution for a terminal that stopped or closed. */ - readonly unregisterTerminal: (input: { - readonly threadId: string; - readonly terminalId: string; - }) => Effect.Effect<void>; -} - -export class PortDiscovery extends Context.Service<PortDiscovery, PortDiscoveryShape>()( - "t3/preview/Services/PortScanner/PortDiscovery", -) {} - -/** Curated list of common dev-server ports for the Windows TCP-probe fallback. */ -export const COMMON_DEV_PORTS: ReadonlyArray<number> = Object.freeze([ - 3000, 3001, 3333, 4173, 4200, 4321, 5000, 5173, 5174, 5175, 5500, 8000, 8080, 8081, 8888, 9000, -]); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index b7cdd931ca6..c91f305b174 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -69,7 +69,7 @@ import * as Stream from "effect/Stream"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; -import { readMcpProviderSession } from "../../mcp/Services/McpProviderSession.ts"; +import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; import { getClaudeModelCapabilities, @@ -3446,7 +3446,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(fastMode ? { fastMode: true } : {}), ...(ultracode ? { ultracode: true } : {}), }; - const mcpSession = readMcpProviderSession(input.threadId); + const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); const queryOptions: ClaudeQueryOptions = { ...(input.cwd ? { cwd: input.cwd } : {}), ...(apiModelId ? { model: apiModelId } : {}), diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index edb2bccdbcf..270126e934b 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -39,7 +39,7 @@ import * as EffectCodexSchema from "effect-codex-app-server/schema"; import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import { getCodexServiceTierOptionValue } from "../../codexModelOptions.ts"; -import { readMcpProviderSession } from "../../mcp/Services/McpProviderSession.ts"; +import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import { ProviderAdapterRequestError, @@ -1386,7 +1386,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( input.modelSelection?.instanceId === boundInstanceId ? getCodexServiceTierOptionValue(input.modelSelection) : undefined; - const mcpSession = readMcpProviderSession(input.threadId); + const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); const runtimeInput: CodexSessionRuntimeOptions = { threadId: input.threadId, providerInstanceId: boundInstanceId, diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index c9af2de3557..1560332ad7f 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -42,7 +42,7 @@ import type * as EffectAcpSchema from "effect-acp/schema"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; -import { readMcpProviderSession } from "../../mcp/Services/McpProviderSession.ts"; +import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import { ProviderAdapterProcessError, ProviderAdapterRequestError, @@ -531,7 +531,7 @@ export function makeCursorAdapter( ? yield* options.resolveSettings : cursorSettings; - const mcpSession = readMcpProviderSession(input.threadId); + const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); const acp = yield* makeCursorAcpRuntime({ cursorSettings: effectiveCursorSettings, ...(options?.environment ? { environment: options.environment } : {}), diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index 35cf7fb98e9..a21a2bb9fc7 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -33,7 +33,7 @@ import type * as EffectAcpSchema from "effect-acp/schema"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; -import { readMcpProviderSession } from "../../mcp/Services/McpProviderSession.ts"; +import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import { ProviderAdapterProcessError, ProviderAdapterRequestError, @@ -375,7 +375,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte threadId: input.threadId, }); - const mcpSession = readMcpProviderSession(input.threadId); + const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); const acp = yield* makeGrokAcpRuntime({ grokSettings, ...(options?.environment ? { environment: options.environment } : {}), diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 7361638924d..1eb6e47bc19 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -26,7 +26,7 @@ import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; -import { readMcpProviderSession } from "../../mcp/Services/McpProviderSession.ts"; +import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; import { ProviderAdapterProcessError, @@ -1054,7 +1054,7 @@ export function makeOpenCodeAdapter( directory, ...(server.external && serverPassword ? { serverPassword } : {}), }); - const mcpSession = readMcpProviderSession(input.threadId); + const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); if (mcpSession && !server.external) { yield* runOpenCodeSdk("mcp.add", () => client.mcp.add({ diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 7d653419b27..ecb1dd2dbd3 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -56,16 +56,8 @@ import { import { type EventNdjsonLogger } from "./EventNdjsonLogger.ts"; import { ProviderEventLoggers } from "./ProviderEventLoggers.ts"; import { AnalyticsService } from "../../telemetry/Services/AnalyticsService.ts"; -import { - issueActiveMcpCredential, - revokeActiveMcpThread, - revokeAllActiveMcpCredentials, -} from "../../mcp/Layers/McpSessionRegistry.ts"; -import { - clearAllMcpProviderSessions, - clearMcpProviderSession, - setMcpProviderSession, -} from "../../mcp/Services/McpProviderSession.ts"; +import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; +import * as McpSessionRegistry from "../../mcp/McpSessionRegistry.ts"; const isModelSelection = Schema.is(ModelSelection); /** @@ -223,14 +215,16 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( const runtimeEventPubSub = yield* PubSub.unbounded<ProviderRuntimeEvent>(); const nowIso = Effect.map(DateTime.now, DateTime.formatIso); const prepareMcpSession = (threadId: ThreadId, providerInstanceId: ProviderInstanceId) => - issueActiveMcpCredential({ threadId, providerInstanceId }).pipe( + McpSessionRegistry.issueActiveMcpCredential({ threadId, providerInstanceId }).pipe( Effect.tap((credential) => - credential ? Effect.sync(() => setMcpProviderSession(credential.config)) : Effect.void, + credential + ? Effect.sync(() => McpProviderSession.setMcpProviderSession(credential.config)) + : Effect.void, ), ); const clearMcpSession = (threadId: ThreadId) => - revokeActiveMcpThread(threadId).pipe( - Effect.tap(() => Effect.sync(() => clearMcpProviderSession(threadId))), + McpSessionRegistry.revokeActiveMcpThread(threadId).pipe( + Effect.tap(() => Effect.sync(() => McpProviderSession.clearMcpProviderSession(threadId))), ); const publishRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect<void> => @@ -1027,8 +1021,8 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ), ).pipe(Effect.asVoid); yield* Effect.forEach(currentAdapters, ([, adapter]) => adapter.stopAll()).pipe(Effect.asVoid); - yield* revokeAllActiveMcpCredentials(); - clearAllMcpProviderSessions(); + yield* McpSessionRegistry.revokeAllActiveMcpCredentials(); + McpProviderSession.clearAllMcpProviderSessions(); const bindings = yield* directory.listBindings().pipe(Effect.orElseSucceed(() => [])); yield* Effect.forEach(bindings, (binding) => Effect.gen(function* () { diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index e6d0f35cea7..b2166d6f0b2 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -99,8 +99,8 @@ import { ServerLifecycleEvents, type ServerLifecycleEventsShape } from "./server import { ServerRuntimeStartup, type ServerRuntimeStartupShape } from "./serverRuntimeStartup.ts"; import { ServerSettingsService, type ServerSettingsShape } from "./serverSettings.ts"; import { TerminalManager, type TerminalManagerShape } from "./terminal/Services/Manager.ts"; -import { PreviewManager } from "./preview/Services/Manager.ts"; -import { PortDiscovery } from "./preview/Services/PortScanner.ts"; +import * as PreviewManager from "./preview/Manager.ts"; +import * as PortScanner from "./preview/PortScanner.ts"; import { BrowserTraceCollector, type BrowserTraceCollectorShape, @@ -669,7 +669,7 @@ const buildAppUnderTest = (options?: { ), Layer.provide( Layer.mergeAll( - Layer.mock(PreviewManager)({ + Layer.mock(PreviewManager.PreviewManager)({ open: () => Effect.die("PreviewManager not stubbed in this test"), navigate: () => Effect.die("PreviewManager not stubbed in this test"), reportStatus: () => Effect.void, @@ -681,7 +681,7 @@ const buildAppUnderTest = (options?: { PubSub.subscribe(pubsub), ), }), - Layer.mock(PortDiscovery)({ + Layer.mock(PortScanner.PortDiscovery)({ scan: () => Effect.succeed([]), subscribe: () => Effect.succeed(() => {}), retain: () => Effect.succeed(() => {}), diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index c1a1298a1ae..c565c91f1a4 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -35,10 +35,10 @@ import * as GitLabCli from "./sourceControl/GitLabCli.ts"; import * as TextGeneration from "./textGeneration/TextGeneration.ts"; import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/ProviderInstanceRegistryHydration.ts"; import { TerminalManagerLive } from "./terminal/Layers/Manager.ts"; -import { PreviewManagerLive } from "./preview/Layers/Manager.ts"; -import { PortDiscoveryLive } from "./preview/Layers/PortScanner.ts"; -import { McpHttpServerLive } from "./mcp/Layers/McpHttpServer.ts"; -import { McpSessionRegistryLive } from "./mcp/Layers/McpSessionRegistry.ts"; +import * as McpHttpServer from "./mcp/McpHttpServer.ts"; +import * as McpSessionRegistry from "./mcp/McpSessionRegistry.ts"; +import * as PreviewManager from "./preview/Manager.ts"; +import * as PortScanner from "./preview/PortScanner.ts"; import * as ProcessRunner from "./processRunner.ts"; import * as GitManager from "./git/GitManager.ts"; import { KeybindingsLive } from "./keybindings.ts"; @@ -236,14 +236,17 @@ const CheckpointingLayerLive = Layer.empty.pipe( Layer.provideMerge(CheckpointStoreLive.pipe(Layer.provide(VcsDriverRegistryLayerLive))), ); -const PortScannerLayerLive = PortDiscoveryLive.pipe(Layer.provide(ProcessRunner.layer)); +const PortScannerLayerLive = PortScanner.layer.pipe(Layer.provide(ProcessRunner.layer)); const TerminalLayerLive = TerminalManagerLive.pipe( Layer.provide(PtyAdapterLive), Layer.provide(PortScannerLayerLive), ); -const PreviewLayerLive = Layer.mergeAll(PreviewManagerLive, PortScannerLayerLive); +const PreviewLayerLive = Layer.empty.pipe( + Layer.provideMerge(PreviewManager.layer), + Layer.provideMerge(PortScannerLayerLive), +); const WorkspaceEntriesLayerLive = WorkspaceEntriesLive.pipe( Layer.provide(WorkspacePathsLive), @@ -353,7 +356,7 @@ export const makeRoutesLayer = Layer.mergeAll( staticAndDevRouteLayer, websocketRpcRouteLayer, ), - McpHttpServerLive.pipe(Layer.provide(McpSessionRegistryLive)), + McpHttpServer.layer.pipe(Layer.provide(McpSessionRegistry.layer)), ).pipe(Layer.provide(browserApiCorsLayer)); export const makeServerLayer = Layer.unwrap( diff --git a/apps/server/src/terminal/Layers/Manager.ts b/apps/server/src/terminal/Layers/Manager.ts index acde17fc85f..f2b466a4390 100644 --- a/apps/server/src/terminal/Layers/Manager.ts +++ b/apps/server/src/terminal/Layers/Manager.ts @@ -33,7 +33,7 @@ import { terminalSessionsTotal, } from "../../observability/Metrics.ts"; import * as ProcessRunner from "../../processRunner.ts"; -import { PortDiscovery } from "../../preview/Services/PortScanner.ts"; +import * as PortScanner from "../../preview/PortScanner.ts"; import { TerminalCwdError, TerminalHistoryError, @@ -996,7 +996,7 @@ interface TerminalManagerOptions { const makeTerminalManager = Effect.fn("makeTerminalManager")(function* () { const { terminalLogsDir } = yield* ServerConfig; const ptyAdapter = yield* PtyAdapter; - const portDiscovery = yield* PortDiscovery; + const portDiscovery = yield* PortScanner.PortDiscovery; return yield* makeTerminalManagerWithOptions({ logsDir: terminalLogsDir, ptyAdapter, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 04ac8f5a62b..c8c3c5de46e 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -71,9 +71,9 @@ import { ServerLifecycleEvents } from "./serverLifecycleEvents.ts"; import { ServerRuntimeStartup } from "./serverRuntimeStartup.ts"; import { redactServerSettingsForClient, ServerSettingsService } from "./serverSettings.ts"; import { TerminalManager } from "./terminal/Services/Manager.ts"; -import { PreviewManager } from "./preview/Services/Manager.ts"; -import { PortDiscovery } from "./preview/Services/PortScanner.ts"; -import { previewAutomationBroker } from "./mcp/Layers/PreviewAutomationBroker.ts"; +import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; +import * as PreviewManager from "./preview/Manager.ts"; +import * as PortScanner from "./preview/PortScanner.ts"; import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries.ts"; import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem.ts"; import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePaths.ts"; @@ -256,8 +256,9 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => const vcsProvisioning = yield* VcsProvisioningService; const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; const terminalManager = yield* TerminalManager; - const previewManager = yield* PreviewManager; - const portDiscovery = yield* PortDiscovery; + const previewAutomationBroker = yield* PreviewAutomationBroker.PreviewAutomationBroker; + const previewManager = yield* PreviewManager.PreviewManager; + const portDiscovery = yield* PortScanner.PortDiscovery; const providerRegistry = yield* ProviderRegistry; const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner; const config = yield* ServerConfig; @@ -1573,6 +1574,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( Effect.provide( makeWsRpcLayer(session).pipe( Layer.provideMerge(RpcSerialization.layerJson), + Layer.provide(PreviewAutomationBroker.layer), Layer.provide(ProviderMaintenanceRunner.layer), Layer.provide( SourceControlDiscoveryLayer.layer.pipe( From b702b4d668fc61a3ee93ecadd7643620c0ce7548 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Fri, 12 Jun 2026 17:17:00 -0700 Subject: [PATCH 13/25] Refactor desktop preview IPC onto shared manager - Move preview session and IPC wiring into the new preview module - Tighten IPC validation with schema-based handlers - Update preview asset paths and tests for the browser preview port --- .../scripts/build-preview-annotation-css.mjs | 6 +- apps/desktop/src/app/DesktopApp.ts | 2 +- apps/desktop/src/ipc/DesktopIpcHandlers.ts | 13 +- apps/desktop/src/ipc/methods/preview.test.ts | 32 +- apps/desktop/src/ipc/methods/preview.ts | 571 +++++--- apps/desktop/src/main.ts | 12 +- apps/desktop/src/preview-pick-preload.ts | 1263 +---------------- .../Annotation.css} | 0 .../AnnotationStyles.generated.ts} | 0 apps/desktop/src/preview/BrowserSession.ts | 93 ++ apps/desktop/src/preview/GuestProtocol.ts | 6 + .../Manager.test.ts} | 12 +- .../Manager.ts} | 390 +++-- .../PickLabelPosition.ts} | 2 +- .../PickPreload.test.ts} | 2 +- apps/desktop/src/preview/PickPreload.ts | 1263 +++++++++++++++++ .../PickedElementPayload.test.ts} | 2 +- .../PickedElementPayload.ts} | 2 +- .../PlaywrightInjectedRuntime.test.ts} | 2 +- .../PlaywrightInjectedRuntime.ts} | 0 .../WebviewPreferences.test.ts} | 2 +- .../WebviewPreferences.ts} | 4 +- apps/desktop/src/window/DesktopWindow.test.ts | 7 + apps/desktop/src/window/DesktopWindow.ts | 15 +- apps/server/src/mcp/McpHttpServer.ts | 240 ++-- .../server/src/mcp/McpSessionRegistry.test.ts | 20 +- apps/server/src/mcp/McpSessionRegistry.ts | 7 +- .../src/mcp/PreviewAutomationBroker.test.ts | 6 +- .../server/src/mcp/PreviewAutomationBroker.ts | 25 +- apps/server/src/preview/Manager.ts | 4 +- apps/server/src/preview/PortScanner.test.ts | 113 +- apps/server/src/preview/PortScanner.ts | 92 +- packages/contracts/src/ipc.ts | 253 +++- 33 files changed, 2617 insertions(+), 1844 deletions(-) rename apps/desktop/src/{preview-annotation.css => preview/Annotation.css} (100%) rename apps/desktop/src/{preview-annotation-styles.generated.ts => preview/AnnotationStyles.generated.ts} (100%) create mode 100644 apps/desktop/src/preview/BrowserSession.ts create mode 100644 apps/desktop/src/preview/GuestProtocol.ts rename apps/desktop/src/{preview-view-manager.test.ts => preview/Manager.test.ts} (94%) rename apps/desktop/src/{preview-view-manager.ts => preview/Manager.ts} (80%) rename apps/desktop/src/{preview-pick-label-position.ts => preview/PickLabelPosition.ts} (95%) rename apps/desktop/src/{preview-pick-preload.test.ts => preview/PickPreload.test.ts} (97%) create mode 100644 apps/desktop/src/preview/PickPreload.ts rename apps/desktop/src/{picked-element-payload.test.ts => preview/PickedElementPayload.test.ts} (99%) rename apps/desktop/src/{picked-element-payload.ts => preview/PickedElementPayload.ts} (98%) rename apps/desktop/src/{playwright-injected-runtime.test.ts => preview/PlaywrightInjectedRuntime.test.ts} (94%) rename apps/desktop/src/{playwright-injected-runtime.ts => preview/PlaywrightInjectedRuntime.ts} (100%) rename apps/desktop/src/{preview-webview-preferences.test.ts => preview/WebviewPreferences.test.ts} (97%) rename apps/desktop/src/{preview-webview-preferences.ts => preview/WebviewPreferences.ts} (93%) diff --git a/apps/desktop/scripts/build-preview-annotation-css.mjs b/apps/desktop/scripts/build-preview-annotation-css.mjs index e9dcff227d7..c45f81268a6 100644 --- a/apps/desktop/scripts/build-preview-annotation-css.mjs +++ b/apps/desktop/scripts/build-preview-annotation-css.mjs @@ -7,9 +7,9 @@ import { compile } from "tailwindcss"; const directory = dirname(fileURLToPath(import.meta.url)); const appRoot = join(directory, ".."); -const sourcePath = join(appRoot, "src", "preview-annotation.css"); -const preloadPath = join(appRoot, "src", "preview-pick-preload.ts"); -const outputPath = join(appRoot, "src", "preview-annotation-styles.generated.ts"); +const sourcePath = join(appRoot, "src", "preview", "Annotation.css"); +const preloadPath = join(appRoot, "src", "preview", "PickPreload.ts"); +const outputPath = join(appRoot, "src", "preview", "AnnotationStyles.generated.ts"); const require = createRequire(import.meta.url); const tailwindRoot = dirname(require.resolve("tailwindcss/package.json")); diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 052a25e4b97..4da1ce63bdf 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -176,7 +176,7 @@ const bootstrap = Effect.gen(function* () { ); } - yield* installDesktopIpcHandlers; + yield* installDesktopIpcHandlers(); yield* logBootstrapInfo("bootstrap ipc handlers registered"); if (!(yield* Ref.get(state.quitting))) { diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 0cb8976ed3b..a6c8428efa9 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -1,7 +1,5 @@ import * as Effect from "effect/Effect"; -import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; -import { previewViewManager } from "../preview-view-manager.ts"; import * as DesktopIpc from "./DesktopIpc.ts"; import { clearCloudAuthToken, @@ -50,12 +48,11 @@ import { setTheme, showContextMenu, } from "./methods/window.ts"; -import { previewMethods } from "./methods/preview.ts"; +import * as PreviewIpc from "./methods/preview.ts"; -export const installDesktopIpcHandlers = Effect.gen(function* () { +export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers")(function* () { const ipc = yield* DesktopIpc.DesktopIpc; - const environment = yield* DesktopEnvironment.DesktopEnvironment; - previewViewManager.configureArtifactDirectory(environment.browserArtifactsDir); + yield* PreviewIpc.installPreviewEventForwarding(); yield* ipc.handleSync(getAppBranding); yield* ipc.handleSync(getLocalEnvironmentBootstrap); @@ -97,7 +94,7 @@ export const installDesktopIpcHandlers = Effect.gen(function* () { yield* ipc.handle(downloadUpdate); yield* ipc.handle(installUpdate); yield* ipc.handle(checkForUpdate); - for (const previewMethod of previewMethods) { + for (const previewMethod of PreviewIpc.methods) { yield* ipc.handle(previewMethod); } -}).pipe(Effect.withSpan("desktop.ipc.installHandlers")); +}); diff --git a/apps/desktop/src/ipc/methods/preview.test.ts b/apps/desktop/src/ipc/methods/preview.test.ts index 8332d5b80ca..92336cc7362 100644 --- a/apps/desktop/src/ipc/methods/preview.test.ts +++ b/apps/desktop/src/ipc/methods/preview.test.ts @@ -1,8 +1,19 @@ +import { it as effectIt } from "@effect/vitest"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; -const fromPartition = vi.fn(() => { - throw new Error("Session can only be received when app is ready"); -}); +import * as PreviewManager from "../../preview/Manager.ts"; +import * as PreviewIpc from "./preview.ts"; + +const { fromPartition } = vi.hoisted(() => ({ + fromPartition: vi.fn(() => { + throw new Error("Session can only be received when app is ready"); + }), +})); vi.mock("electron", () => ({ BrowserWindow: { @@ -25,4 +36,19 @@ describe("preview IPC methods", () => { await expect(import("./preview.ts")).resolves.toBeDefined(); expect(fromPartition).not.toHaveBeenCalled(); }); + + effectIt.effect("rejects invalid webContents ids before resolving the preview service", () => + Effect.map( + PreviewIpc.registerWebview + .handler({ tabId: "tab-1", webContentsId: 0 }) + .pipe(Effect.provideService(PreviewManager.PreviewManager, null as never), Effect.exit), + (exit) => { + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isSuccess(exit)) return; + const error = Cause.findErrorOption(exit.cause); + expect(Option.isSome(error) && Schema.isSchemaError(error.value)).toBe(true); + expect(fromPartition).not.toHaveBeenCalled(); + }, + ), + ); }); diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index c9b8c58fcbd..395cbad1ced 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -1,251 +1,378 @@ // @effect-diagnostics nodeBuiltinImport:off -import * as Data from "effect/Data"; -import * as Effect from "effect/Effect"; -import type { DesktopPreviewAnnotationTheme } from "@t3tools/contracts"; +import { + DesktopPreviewAnnotationThemeInputSchema, + DesktopPreviewArtifactInputSchema, + DesktopPreviewAutomationClickInputSchema, + DesktopPreviewAutomationEvaluateInputSchema, + DesktopPreviewAutomationPressInputSchema, + DesktopPreviewAutomationScrollInputSchema, + DesktopPreviewAutomationTypeInputSchema, + DesktopPreviewAutomationWaitForInputSchema, + DesktopPreviewConfigInputSchema, + DesktopPreviewNavigateInputSchema, + DesktopPreviewRecordingArtifactSchema, + DesktopPreviewRecordingSaveInputSchema, + DesktopPreviewRegisterWebviewInputSchema, + DesktopPreviewScreenshotArtifactSchema, + DesktopPreviewTabInputSchema, + DesktopPreviewWebviewConfigSchema, + PreviewAnnotationPayloadSchema, + PreviewAutomationSnapshot, + PreviewAutomationStatus, +} from "@t3tools/contracts"; import { BrowserWindow } from "electron"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; import { pathToFileURL } from "node:url"; -import { previewViewManager } from "../../preview-view-manager.ts"; -import { PREVIEW_WEBVIEW_PREFERENCES } from "../../preview-webview-preferences.ts"; +import * as PreviewManager from "../../preview/Manager.ts"; +import { PREVIEW_WEBVIEW_PREFERENCES } from "../../preview/WebviewPreferences.ts"; import * as IpcChannels from "../channels.ts"; -import type { DesktopIpcMethod } from "../DesktopIpc.ts"; +import { makeIpcMethod } from "../DesktopIpc.ts"; -previewViewManager.onStateChange((tabId, state) => { +const broadcast = (channel: string, ...args: ReadonlyArray<unknown>): void => { for (const window of BrowserWindow.getAllWindows()) { if (!window.isDestroyed()) { - window.webContents.send(IpcChannels.PREVIEW_STATE_CHANGE_CHANNEL, tabId, state); + window.webContents.send(channel, ...args); } } -}); +}; -previewViewManager.onRecordingFrame((frame) => { - for (const window of BrowserWindow.getAllWindows()) { - if (!window.isDestroyed()) { - window.webContents.send(IpcChannels.PREVIEW_RECORDING_FRAME_CHANNEL, frame); - } - } +export const installPreviewEventForwarding = Effect.fn( + "desktop.ipc.preview.installEventForwarding", +)(function* () { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.subscribeStateChanges((tabId, state) => { + broadcast(IpcChannels.PREVIEW_STATE_CHANGE_CHANNEL, tabId, state); + }); + yield* manager.subscribeRecordingFrames((frame) => { + broadcast(IpcChannels.PREVIEW_RECORDING_FRAME_CHANNEL, frame); + }); + yield* manager.subscribePointerEvents((event) => { + broadcast(IpcChannels.PREVIEW_POINTER_EVENT_CHANNEL, event); + }); }); -previewViewManager.onPointerEvent((event) => { - for (const window of BrowserWindow.getAllWindows()) { - if (!window.isDestroyed()) { - window.webContents.send(IpcChannels.PREVIEW_POINTER_EVENT_CHANNEL, event); - } - } +export const createTab = makeIpcMethod({ + channel: IpcChannels.PREVIEW_CREATE_TAB_CHANNEL, + payload: DesktopPreviewTabInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.createTab")(function* ({ tabId }) { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.createTab(tabId); + }), }); -const tabIdFrom = (raw: unknown): string => { - if (typeof raw !== "object" || raw === null || !("tabId" in raw)) { - throw new Error("preview tab id is required"); - } - const tabId = raw.tabId; - if (typeof tabId !== "string" || tabId.trim().length === 0) { - throw new Error("preview tab id must be a non-empty string"); - } - return tabId; -}; - -const inputFrom = (raw: unknown): unknown => { - if (typeof raw !== "object" || raw === null || !("input" in raw)) { - throw new Error("preview automation input is required"); - } - return raw.input; -}; +export const closeTab = makeIpcMethod({ + channel: IpcChannels.PREVIEW_CLOSE_TAB_CHANNEL, + payload: DesktopPreviewTabInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.closeTab")(function* ({ tabId }) { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.closeTab(tabId); + }), +}); -const annotationThemeFrom = (raw: unknown): DesktopPreviewAnnotationTheme => { - if (typeof raw !== "object" || raw === null || !("theme" in raw)) { - throw new Error("preview annotation theme is required"); - } - const theme = raw.theme; - if (typeof theme !== "object" || theme === null) { - throw new Error("preview annotation theme must be an object"); - } - const record = theme as Record<string, unknown>; - const stringKeys = [ - "radius", - "background", - "foreground", - "popover", - "popoverForeground", - "primary", - "primaryForeground", - "muted", - "mutedForeground", - "accent", - "accentForeground", - "border", - "input", - "ring", - "fontSans", - "fontMono", - ] as const; - for (const key of stringKeys) { - if (typeof record[key] !== "string" || record[key].length === 0) { - throw new Error(`preview annotation theme ${key} must be a non-empty string`); - } - } - if (record["colorScheme"] !== "light" && record["colorScheme"] !== "dark") { - throw new Error("preview annotation theme colorScheme must be light or dark"); - } - return record as unknown as DesktopPreviewAnnotationTheme; -}; +export const registerWebview = makeIpcMethod({ + channel: IpcChannels.PREVIEW_REGISTER_WEBVIEW_CHANNEL, + payload: DesktopPreviewRegisterWebviewInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.registerWebview")(function* ({ tabId, webContentsId }) { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.registerWebview(tabId, webContentsId); + }), +}); -class PreviewIpcError extends Data.TaggedError("PreviewIpcError")<{ - readonly cause: unknown; -}> {} +export const navigate = makeIpcMethod({ + channel: IpcChannels.PREVIEW_NAVIGATE_CHANNEL, + payload: DesktopPreviewNavigateInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.navigate")(function* ({ tabId, url }) { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.navigate(tabId, url); + }), +}); -const method = ( +const tabMethod = ( channel: string, - handler: (raw: unknown) => unknown | Promise<unknown>, -): DesktopIpcMethod<PreviewIpcError, never> => ({ - channel, - handler: (raw) => - Effect.tryPromise({ - try: () => Promise.resolve(handler(raw)), - catch: (cause) => new PreviewIpcError({ cause }), + name: string, + invoke: ( + manager: PreviewManager.PreviewManagerShape, + tabId: string, + ) => Effect.Effect<void, PreviewManager.PreviewManagerError>, +) => + makeIpcMethod({ + channel, + payload: DesktopPreviewTabInputSchema, + result: Schema.Void, + handler: Effect.fn(name)(function* ({ tabId }) { + const manager = yield* PreviewManager.PreviewManager; + yield* invoke(manager, tabId); }), + }); + +export const goBack = tabMethod( + IpcChannels.PREVIEW_GO_BACK_CHANNEL, + "desktop.ipc.preview.goBack", + (manager, tabId) => manager.goBack(tabId), +); +export const goForward = tabMethod( + IpcChannels.PREVIEW_GO_FORWARD_CHANNEL, + "desktop.ipc.preview.goForward", + (manager, tabId) => manager.goForward(tabId), +); +export const refresh = tabMethod( + IpcChannels.PREVIEW_REFRESH_CHANNEL, + "desktop.ipc.preview.refresh", + (manager, tabId) => manager.refresh(tabId), +); +export const zoomIn = tabMethod( + IpcChannels.PREVIEW_ZOOM_IN_CHANNEL, + "desktop.ipc.preview.zoomIn", + (manager, tabId) => manager.zoomIn(tabId), +); +export const zoomOut = tabMethod( + IpcChannels.PREVIEW_ZOOM_OUT_CHANNEL, + "desktop.ipc.preview.zoomOut", + (manager, tabId) => manager.zoomOut(tabId), +); +export const resetZoom = tabMethod( + IpcChannels.PREVIEW_RESET_ZOOM_CHANNEL, + "desktop.ipc.preview.resetZoom", + (manager, tabId) => manager.resetZoom(tabId), +); +export const hardReload = tabMethod( + IpcChannels.PREVIEW_HARD_RELOAD_CHANNEL, + "desktop.ipc.preview.hardReload", + (manager, tabId) => manager.hardReload(tabId), +); +export const openDevTools = tabMethod( + IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, + "desktop.ipc.preview.openDevTools", + (manager, tabId) => manager.openDevTools(tabId), +); +export const cancelPickElement = tabMethod( + IpcChannels.PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL, + "desktop.ipc.preview.cancelPickElement", + (manager, tabId) => manager.cancelPickElement(tabId), +); +export const startRecording = tabMethod( + IpcChannels.PREVIEW_RECORDING_START_CHANNEL, + "desktop.ipc.preview.startRecording", + (manager, tabId) => manager.startRecording(tabId), +); +export const stopRecording = tabMethod( + IpcChannels.PREVIEW_RECORDING_STOP_CHANNEL, + "desktop.ipc.preview.stopRecording", + (manager, tabId) => manager.stopRecording(tabId), +); + +export const clearCookies = makeIpcMethod({ + channel: IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL, + payload: Schema.Void, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.clearCookies")(function* () { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.clearCookies(); + }), }); -export const previewMethods = [ - method(IpcChannels.PREVIEW_CREATE_TAB_CHANNEL, (raw) => - previewViewManager.createTab(tabIdFrom(raw)), - ), - method(IpcChannels.PREVIEW_CLOSE_TAB_CHANNEL, (raw) => - previewViewManager.closeTab(tabIdFrom(raw)), - ), - method(IpcChannels.PREVIEW_REGISTER_WEBVIEW_CHANNEL, (raw) => { - const tabId = tabIdFrom(raw); - const webContentsId = - typeof raw === "object" && raw !== null && "webContentsId" in raw ? raw.webContentsId : null; - if ( - typeof webContentsId !== "number" || - !Number.isInteger(webContentsId) || - webContentsId <= 0 - ) { - throw new Error("preview webContentsId must be a positive integer"); - } - return previewViewManager.registerWebview(tabId, webContentsId); - }), - method(IpcChannels.PREVIEW_NAVIGATE_CHANNEL, (raw) => { - const tabId = tabIdFrom(raw); - const url = typeof raw === "object" && raw !== null && "url" in raw ? raw.url : null; - if (typeof url !== "string") throw new Error("preview url must be a string"); - return previewViewManager.navigate(tabId, url); - }), - method(IpcChannels.PREVIEW_GO_BACK_CHANNEL, (raw) => previewViewManager.goBack(tabIdFrom(raw))), - method(IpcChannels.PREVIEW_GO_FORWARD_CHANNEL, (raw) => - previewViewManager.goForward(tabIdFrom(raw)), - ), - method(IpcChannels.PREVIEW_REFRESH_CHANNEL, (raw) => previewViewManager.refresh(tabIdFrom(raw))), - method(IpcChannels.PREVIEW_ZOOM_IN_CHANNEL, (raw) => previewViewManager.zoomIn(tabIdFrom(raw))), - method(IpcChannels.PREVIEW_ZOOM_OUT_CHANNEL, (raw) => previewViewManager.zoomOut(tabIdFrom(raw))), - method(IpcChannels.PREVIEW_RESET_ZOOM_CHANNEL, (raw) => - previewViewManager.resetZoom(tabIdFrom(raw)), - ), - method(IpcChannels.PREVIEW_HARD_RELOAD_CHANNEL, (raw) => - previewViewManager.hardReload(tabIdFrom(raw)), - ), - method(IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, (raw) => - previewViewManager.openDevTools(tabIdFrom(raw)), - ), - method(IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL, () => previewViewManager.clearCookies()), - method(IpcChannels.PREVIEW_CLEAR_CACHE_CHANNEL, () => previewViewManager.clearCache()), - method(IpcChannels.PREVIEW_GET_CONFIG_CHANNEL, (raw) => { - const environmentId = - typeof raw === "object" && raw !== null && "environmentId" in raw ? raw.environmentId : null; - if (typeof environmentId !== "string" || environmentId.length === 0) { - throw new Error("preview environment id is required"); - } - previewViewManager.getBrowserSession(environmentId); - const preloadPath = `${__dirname}/preview-pick-preload.cjs`; +export const clearCache = makeIpcMethod({ + channel: IpcChannels.PREVIEW_CLEAR_CACHE_CHANNEL, + payload: Schema.Void, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.clearCache")(function* () { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.clearCache(); + }), +}); + +export const getPreviewConfig = makeIpcMethod({ + channel: IpcChannels.PREVIEW_GET_CONFIG_CHANNEL, + payload: DesktopPreviewConfigInputSchema, + result: DesktopPreviewWebviewConfigSchema, + handler: Effect.fn("desktop.ipc.preview.getConfig")(function* ({ environmentId }) { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.getBrowserSession(environmentId); return { - partition: previewViewManager.getBrowserPartition(environmentId), + partition: manager.getBrowserPartition(environmentId), webPreferences: PREVIEW_WEBVIEW_PREFERENCES, - preloadUrl: pathToFileURL(preloadPath).href, + preloadUrl: pathToFileURL(`${__dirname}/preview-pick-preload.cjs`).href, }; }), - method(IpcChannels.PREVIEW_SET_ANNOTATION_THEME_CHANNEL, (raw) => - previewViewManager.setAnnotationTheme(annotationThemeFrom(raw)), - ), - method(IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL, (raw) => - previewViewManager.pickElement(tabIdFrom(raw)), - ), - method(IpcChannels.PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL, (raw) => - previewViewManager.cancelPickElement(tabIdFrom(raw)), - ), - method(IpcChannels.PREVIEW_CAPTURE_SCREENSHOT_CHANNEL, (raw) => - previewViewManager.captureScreenshot(tabIdFrom(raw)), - ), - method(IpcChannels.PREVIEW_REVEAL_ARTIFACT_CHANNEL, (raw) => { - const path = typeof raw === "object" && raw !== null && "path" in raw ? raw.path : null; - if (typeof path !== "string" || path.trim().length === 0) { - throw new Error("preview artifact path is required"); - } - return previewViewManager.revealArtifact(path); +}); + +export const setAnnotationTheme = makeIpcMethod({ + channel: IpcChannels.PREVIEW_SET_ANNOTATION_THEME_CHANNEL, + payload: DesktopPreviewAnnotationThemeInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.setAnnotationTheme")(function* ({ theme }) { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.setAnnotationTheme(theme); }), - method(IpcChannels.PREVIEW_COPY_ARTIFACT_CHANNEL, (raw) => { - const path = typeof raw === "object" && raw !== null && "path" in raw ? raw.path : null; - if (typeof path !== "string" || path.trim().length === 0) { - throw new Error("preview artifact path is required"); - } - return previewViewManager.copyArtifactToClipboard(path); - }), - method(IpcChannels.PREVIEW_AUTOMATION_STATUS_CHANNEL, (raw) => - previewViewManager.automationStatus(tabIdFrom(raw)), - ), - method(IpcChannels.PREVIEW_AUTOMATION_SNAPSHOT_CHANNEL, (raw) => - previewViewManager.automationSnapshot(tabIdFrom(raw)), - ), - method(IpcChannels.PREVIEW_AUTOMATION_CLICK_CHANNEL, (raw) => - previewViewManager.automationClick( - tabIdFrom(raw), - inputFrom(raw) as Parameters<typeof previewViewManager.automationClick>[1], - ), - ), - method(IpcChannels.PREVIEW_AUTOMATION_TYPE_CHANNEL, (raw) => - previewViewManager.automationType( - tabIdFrom(raw), - inputFrom(raw) as Parameters<typeof previewViewManager.automationType>[1], - ), - ), - method(IpcChannels.PREVIEW_AUTOMATION_PRESS_CHANNEL, (raw) => - previewViewManager.automationPress( - tabIdFrom(raw), - inputFrom(raw) as Parameters<typeof previewViewManager.automationPress>[1], - ), - ), - method(IpcChannels.PREVIEW_AUTOMATION_SCROLL_CHANNEL, (raw) => - previewViewManager.automationScroll( - tabIdFrom(raw), - inputFrom(raw) as Parameters<typeof previewViewManager.automationScroll>[1], - ), - ), - method(IpcChannels.PREVIEW_AUTOMATION_EVALUATE_CHANNEL, (raw) => - previewViewManager.automationEvaluate( - tabIdFrom(raw), - inputFrom(raw) as Parameters<typeof previewViewManager.automationEvaluate>[1], - ), - ), - method(IpcChannels.PREVIEW_AUTOMATION_WAIT_FOR_CHANNEL, (raw) => - previewViewManager.automationWaitFor( - tabIdFrom(raw), - inputFrom(raw) as Parameters<typeof previewViewManager.automationWaitFor>[1], - ), - ), - method(IpcChannels.PREVIEW_RECORDING_START_CHANNEL, (raw) => - previewViewManager.startRecording(tabIdFrom(raw)), - ), - method(IpcChannels.PREVIEW_RECORDING_STOP_CHANNEL, (raw) => - previewViewManager.stopRecording(tabIdFrom(raw)), - ), - method(IpcChannels.PREVIEW_RECORDING_SAVE_CHANNEL, (raw) => { - const tabId = tabIdFrom(raw); - if (typeof raw !== "object" || raw === null) throw new Error("recording payload is required"); - const mimeType = "mimeType" in raw ? raw.mimeType : null; - const data = "data" in raw ? raw.data : null; - if (typeof mimeType !== "string" || !(data instanceof Uint8Array)) { - throw new Error("recording mimeType and bytes are required"); - } - return previewViewManager.saveRecording(tabId, mimeType, data); +}); + +export const pickElement = makeIpcMethod({ + channel: IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL, + payload: DesktopPreviewTabInputSchema, + result: Schema.NullOr(PreviewAnnotationPayloadSchema), + handler: Effect.fn("desktop.ipc.preview.pickElement")(function* ({ tabId }) { + const manager = yield* PreviewManager.PreviewManager; + return yield* manager.pickElement(tabId); + }), +}); + +export const captureScreenshot = makeIpcMethod({ + channel: IpcChannels.PREVIEW_CAPTURE_SCREENSHOT_CHANNEL, + payload: DesktopPreviewTabInputSchema, + result: DesktopPreviewScreenshotArtifactSchema, + handler: Effect.fn("desktop.ipc.preview.captureScreenshot")(function* ({ tabId }) { + const manager = yield* PreviewManager.PreviewManager; + return yield* manager.captureScreenshot(tabId); + }), +}); + +export const revealArtifact = makeIpcMethod({ + channel: IpcChannels.PREVIEW_REVEAL_ARTIFACT_CHANNEL, + payload: DesktopPreviewArtifactInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.revealArtifact")(function* ({ path }) { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.revealArtifact(path); + }), +}); + +export const copyArtifactToClipboard = makeIpcMethod({ + channel: IpcChannels.PREVIEW_COPY_ARTIFACT_CHANNEL, + payload: DesktopPreviewArtifactInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.copyArtifactToClipboard")(function* ({ path }) { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.copyArtifactToClipboard(path); + }), +}); + +export const automationStatus = makeIpcMethod({ + channel: IpcChannels.PREVIEW_AUTOMATION_STATUS_CHANNEL, + payload: DesktopPreviewTabInputSchema, + result: PreviewAutomationStatus, + handler: Effect.fn("desktop.ipc.preview.automationStatus")(function* ({ tabId }) { + const manager = yield* PreviewManager.PreviewManager; + return yield* manager.automationStatus(tabId); + }), +}); + +export const automationSnapshot = makeIpcMethod({ + channel: IpcChannels.PREVIEW_AUTOMATION_SNAPSHOT_CHANNEL, + payload: DesktopPreviewTabInputSchema, + result: PreviewAutomationSnapshot, + handler: Effect.fn("desktop.ipc.preview.automationSnapshot")(function* ({ tabId }) { + const manager = yield* PreviewManager.PreviewManager; + return yield* manager.automationSnapshot(tabId); + }), +}); + +export const automationClick = makeIpcMethod({ + channel: IpcChannels.PREVIEW_AUTOMATION_CLICK_CHANNEL, + payload: DesktopPreviewAutomationClickInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.automationClick")(function* ({ tabId, input }) { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.automationClick(tabId, input); + }), +}); + +export const automationType = makeIpcMethod({ + channel: IpcChannels.PREVIEW_AUTOMATION_TYPE_CHANNEL, + payload: DesktopPreviewAutomationTypeInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.automationType")(function* ({ tabId, input }) { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.automationType(tabId, input); }), +}); + +export const automationPress = makeIpcMethod({ + channel: IpcChannels.PREVIEW_AUTOMATION_PRESS_CHANNEL, + payload: DesktopPreviewAutomationPressInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.automationPress")(function* ({ tabId, input }) { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.automationPress(tabId, input); + }), +}); + +export const automationScroll = makeIpcMethod({ + channel: IpcChannels.PREVIEW_AUTOMATION_SCROLL_CHANNEL, + payload: DesktopPreviewAutomationScrollInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.automationScroll")(function* ({ tabId, input }) { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.automationScroll(tabId, input); + }), +}); + +export const automationEvaluate = makeIpcMethod({ + channel: IpcChannels.PREVIEW_AUTOMATION_EVALUATE_CHANNEL, + payload: DesktopPreviewAutomationEvaluateInputSchema, + result: Schema.Unknown, + handler: Effect.fn("desktop.ipc.preview.automationEvaluate")(function* ({ tabId, input }) { + const manager = yield* PreviewManager.PreviewManager; + return yield* manager.automationEvaluate(tabId, input); + }), +}); + +export const automationWaitFor = makeIpcMethod({ + channel: IpcChannels.PREVIEW_AUTOMATION_WAIT_FOR_CHANNEL, + payload: DesktopPreviewAutomationWaitForInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.automationWaitFor")(function* ({ tabId, input }) { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.automationWaitFor(tabId, input); + }), +}); + +export const saveRecording = makeIpcMethod({ + channel: IpcChannels.PREVIEW_RECORDING_SAVE_CHANNEL, + payload: DesktopPreviewRecordingSaveInputSchema, + result: DesktopPreviewRecordingArtifactSchema, + handler: Effect.fn("desktop.ipc.preview.saveRecording")(function* ({ tabId, mimeType, data }) { + const manager = yield* PreviewManager.PreviewManager; + return yield* manager.saveRecording(tabId, mimeType, data); + }), +}); + +export const methods = [ + createTab, + closeTab, + registerWebview, + navigate, + goBack, + goForward, + refresh, + zoomIn, + zoomOut, + resetZoom, + hardReload, + openDevTools, + clearCookies, + clearCache, + getPreviewConfig, + setAnnotationTheme, + pickElement, + cancelPickElement, + captureScreenshot, + revealArtifact, + copyArtifactToClipboard, + automationStatus, + automationSnapshot, + automationClick, + automationType, + automationPress, + automationScroll, + automationEvaluate, + automationWaitFor, + startRecording, + stopRecording, + saveRecording, ] as const; diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 9356eef441b..c7a16a5c7f5 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -44,6 +44,8 @@ import * as DesktopSshEnvironment from "./ssh/DesktopSshEnvironment.ts"; import * as DesktopSshPasswordPrompts from "./ssh/DesktopSshPasswordPrompts.ts"; import * as DesktopState from "./app/DesktopState.ts"; import * as DesktopUpdates from "./updates/DesktopUpdates.ts"; +import * as PreviewBrowserSession from "./preview/BrowserSession.ts"; +import * as PreviewManager from "./preview/Manager.ts"; import * as DesktopWindow from "./window/DesktopWindow.ts"; const desktopEnvironmentLayer = Layer.unwrap( @@ -127,7 +129,15 @@ const desktopServerExposureLayer = DesktopServerExposure.layer.pipe( Layer.provideMerge(desktopFoundationLayer), ); -const desktopWindowLayer = DesktopWindow.layer.pipe(Layer.provideMerge(desktopServerExposureLayer)); +const desktopPreviewLayer = PreviewManager.layer.pipe( + Layer.provideMerge(PreviewBrowserSession.layer), + Layer.provideMerge(desktopFoundationLayer), +); + +const desktopWindowLayer = DesktopWindow.layer.pipe( + Layer.provideMerge(desktopServerExposureLayer), + Layer.provideMerge(desktopPreviewLayer), +); const desktopBackendLayer = DesktopBackendManager.layer.pipe( Layer.provideMerge(DesktopAppIdentity.layer), diff --git a/apps/desktop/src/preview-pick-preload.ts b/apps/desktop/src/preview-pick-preload.ts index 2fda1abd720..84e6abb29ee 100644 --- a/apps/desktop/src/preview-pick-preload.ts +++ b/apps/desktop/src/preview-pick-preload.ts @@ -1,1262 +1 @@ -// @effect-diagnostics globalDate:off -import { ipcRenderer } from "electron"; -import { getElementContext } from "react-grab/primitives"; -import type { - DesktopPreviewAnnotationTheme, - PickedElementPayload, - PickedElementStackFrame, - PreviewAnnotationPayload, - PreviewAnnotationPoint, - PreviewAnnotationRect, - PreviewAnnotationRegionTarget, - PreviewAnnotationStrokeTarget, - PreviewAnnotationStyleChange, -} from "@t3tools/contracts"; - -import { previewAnnotationStyles } from "./preview-annotation-styles.generated.ts"; - -const START_PICK_CHANNEL = "preview:start-pick"; -const CANCEL_PICK_CHANNEL = "preview:cancel-pick"; -const ELEMENT_PICKED_CHANNEL = "preview:element-picked"; -const ANNOTATION_CAPTURED_CHANNEL = "preview:annotation-captured"; -const ANNOTATION_THEME_CHANNEL = "preview:annotation-theme"; -const HUMAN_INPUT_CHANNEL = "preview:human-input"; -const OVERLAY_ATTRIBUTE = "data-t3code-annotation-ui"; -const Z_INDEX_OVERLAY = 2147483646; -const PRIMARY = "var(--t3-primary)"; -const PRIMARY_FILL = "color-mix(in srgb, var(--t3-primary) 10%, transparent)"; -const MAX_MARQUEE_ELEMENTS = 20; -const CONTENT_LAYER_Z_INDEX = 1; -const CHROME_LAYER_Z_INDEX = 10; - -type AnnotationTool = "select" | "marquee" | "draw" | "erase"; - -interface SelectedElement { - id: string; - element: Element; - outline: HTMLDivElement; - label: HTMLDivElement; - baselineStyles: Map<string, string>; -} - -interface AnnotationSession { - teardown: (notifyMain: boolean) => void; - applyTheme: (theme: DesktopPreviewAnnotationTheme) => void; -} - -let activeSession: AnnotationSession | null = null; -let idSequence = 0; -let annotationTheme: DesktopPreviewAnnotationTheme | null = null; - -const applyAnnotationTheme = ( - host: HTMLElement, - theme: DesktopPreviewAnnotationTheme | null, -): void => { - if (!theme) return; - host.style.colorScheme = theme.colorScheme; - const variables = { - "--t3-radius": theme.radius, - "--t3-background": theme.background, - "--t3-foreground": theme.foreground, - "--t3-popover": theme.popover, - "--t3-popover-foreground": theme.popoverForeground, - "--t3-primary": theme.primary, - "--t3-primary-foreground": theme.primaryForeground, - "--t3-muted": theme.muted, - "--t3-muted-foreground": theme.mutedForeground, - "--t3-accent": theme.accent, - "--t3-accent-foreground": theme.accentForeground, - "--t3-border": theme.border, - "--t3-input": theme.input, - "--t3-ring": theme.ring, - "--t3-font-sans": theme.fontSans, - "--t3-font-mono": theme.fontMono, - }; - for (const [name, value] of Object.entries(variables)) { - host.style.setProperty(name, value); - } -}; - -const reportHumanPointerInput = (event: PointerEvent): void => { - if (!event.isTrusted) return; - ipcRenderer.send(HUMAN_INPUT_CHANNEL, { - kind: "pointer", - x: event.clientX, - y: event.clientY, - button: event.button, - }); -}; - -const reportHumanKeyInput = (event: KeyboardEvent): void => { - if (!event.isTrusted) return; - ipcRenderer.send(HUMAN_INPUT_CHANNEL, { - kind: "key", - key: event.key, - code: event.code, - }); -}; - -window.addEventListener("pointerdown", reportHumanPointerInput, true); -window.addEventListener("keydown", reportHumanKeyInput, true); - -const nextId = (prefix: string): string => { - idSequence += 1; - return `${prefix}_${idSequence.toString(36)}`; -}; - -const rectFromDomRect = (rect: DOMRect): PreviewAnnotationRect => ({ - x: rect.left, - y: rect.top, - width: rect.width, - height: rect.height, -}); - -const normalizeRect = ( - startX: number, - startY: number, - endX: number, - endY: number, -): PreviewAnnotationRect => ({ - x: Math.min(startX, endX), - y: Math.min(startY, endY), - width: Math.abs(endX - startX), - height: Math.abs(endY - startY), -}); - -const isUsableRect = (rect: PreviewAnnotationRect): boolean => rect.width >= 3 && rect.height >= 3; - -function unionRects( - rects: ReadonlyArray<PreviewAnnotationRect>, - padding = 20, -): PreviewAnnotationRect | null { - if (rects.length === 0) return null; - const left = Math.min(...rects.map((rect) => rect.x)); - const top = Math.min(...rects.map((rect) => rect.y)); - const right = Math.max(...rects.map((rect) => rect.x + rect.width)); - const bottom = Math.max(...rects.map((rect) => rect.y + rect.height)); - const x = Math.max(0, left - padding); - const y = Math.max(0, top - padding); - const maxWidth = Math.max(1, window.innerWidth - x); - const maxHeight = Math.max(1, window.innerHeight - y); - return { - x, - y, - width: Math.min(maxWidth, right - left + padding * 2), - height: Math.min(maxHeight, bottom - top + padding * 2), - }; -} - -function isAnnotationNode(element: Element): boolean { - return element instanceof Element && element.closest(`[${OVERLAY_ATTRIBUTE}]`) !== null; -} - -function pickFromPoint(clientX: number, clientY: number): Element | null { - for (const candidate of document.elementsFromPoint(clientX, clientY)) { - if (!(candidate instanceof Element)) continue; - if (isAnnotationNode(candidate)) continue; - if (candidate === document.documentElement || candidate === document.body) continue; - return candidate; - } - return null; -} - -function describeRawElement(element: Element): string { - const tag = element.tagName.toLowerCase(); - const id = element.id ? `#${element.id}` : ""; - const classes = - element instanceof HTMLElement && typeof element.className === "string" - ? element.className - .trim() - .split(/\s+/) - .filter(Boolean) - .slice(0, 2) - .map((name) => `.${name}`) - .join("") - : ""; - return `${tag}${id}${classes}`; -} - -function createBox(color: string, fill: string): HTMLDivElement { - const node = document.createElement("div"); - node.setAttribute(OVERLAY_ATTRIBUTE, ""); - node.style.cssText = [ - "position:fixed", - "pointer-events:none", - `border:2px solid ${color}`, - `background:${fill}`, - "border-radius:3px", - "box-sizing:border-box", - "display:none", - `z-index:${CONTENT_LAYER_Z_INDEX}`, - ].join(";"); - return node; -} - -function positionBox(node: HTMLElement, rect: PreviewAnnotationRect): void { - node.style.display = "block"; - node.style.transform = `translate(${rect.x}px, ${rect.y}px)`; - node.style.width = `${rect.width}px`; - node.style.height = `${rect.height}px`; -} - -function createLabel(): HTMLDivElement { - const label = document.createElement("div"); - label.setAttribute(OVERLAY_ATTRIBUTE, ""); - label.className = - "fixed z-1 max-w-70 overflow-hidden rounded-md bg-primary px-2 py-1 font-sans text-xs font-semibold text-primary-foreground shadow-md"; - label.style.cssText = [ - "position:fixed", - "pointer-events:none", - "white-space:nowrap", - "text-overflow:ellipsis", - `z-index:${CONTENT_LAYER_Z_INDEX}`, - ].join(";"); - return label; -} - -function updateSelectedVisual(target: SelectedElement): void { - if (!target.element.isConnected) { - target.outline.style.display = "none"; - target.label.style.display = "none"; - return; - } - const rect = target.element.getBoundingClientRect(); - positionBox(target.outline, rectFromDomRect(rect)); - target.label.textContent = describeRawElement(target.element); - target.label.style.display = "block"; - target.label.style.transform = `translate(${Math.max(4, rect.left)}px, ${Math.max(4, rect.top - 22)}px)`; -} - -function toStackFrame(frame: { - functionName?: string; - fileName?: string; - lineNumber?: number; - columnNumber?: number; -}): PickedElementStackFrame { - return { - functionName: frame.functionName ?? null, - fileName: frame.fileName ?? null, - lineNumber: frame.lineNumber ?? null, - columnNumber: frame.columnNumber ?? null, - }; -} - -async function captureElement(element: Element): Promise<PickedElementPayload | null> { - try { - const context = await getElementContext(element); - const stack = (context.stack ?? []).map(toStackFrame); - return { - pageUrl: location.href, - pageTitle: document.title?.trim() || null, - tagName: element.tagName.toLowerCase(), - selector: context.selector, - htmlPreview: context.htmlPreview ?? "", - componentName: context.componentName, - source: stack[0] ?? null, - stack, - styles: context.styles ?? "", - pickedAt: new Date().toISOString(), - }; - } catch { - return null; - } -} - -function createButton(label: string, title: string): HTMLButtonElement { - const button = document.createElement("button"); - button.type = "button"; - button.textContent = label; - button.title = title; - button.className = - "inline-flex h-7 cursor-pointer items-center justify-center rounded-md border border-transparent px-2 font-sans text-xs font-medium text-foreground outline-none hover:bg-accent disabled:pointer-events-none disabled:opacity-60"; - return button; -} - -function styleControl(input: HTMLInputElement | HTMLSelectElement): void { - input.setAttribute("aria-label", input.getAttribute("aria-label") ?? "Style value"); - input.className = - "h-7 min-w-0 w-full appearance-none rounded-md border border-input bg-background px-2 font-mono text-xs text-foreground shadow-xs outline-none"; -} - -function createUnitControl(input: HTMLInputElement): HTMLElement { - const wrapper = document.createElement("div"); - wrapper.style.cssText = "position:relative;min-width:0"; - const unit = document.createElement("span"); - unit.textContent = input.dataset.unit ?? ""; - unit.className = - "pointer-events-none absolute top-1/2 right-2 -translate-y-1/2 font-mono text-xs text-muted-foreground"; - wrapper.append(input, unit); - return wrapper; -} - -function createField( - labelText: string, - input: HTMLInputElement | HTMLSelectElement, -): HTMLLabelElement { - const label = document.createElement("label"); - label.className = - "grid min-h-7 grid-cols-[82px_minmax(0,1fr)] items-center gap-2 font-sans text-xs font-medium text-muted-foreground"; - const text = document.createElement("span"); - text.textContent = labelText; - styleControl(input); - label.append( - text, - input instanceof HTMLInputElement && input.dataset.unit ? createUnitControl(input) : input, - ); - return label; -} - -function createStyleSection(): HTMLElement { - const section = document.createElement("section"); - section.className = "grid gap-1 border-t border-border py-2"; - return section; -} - -function createUnitInput(unit: string, placeholder = "0"): HTMLInputElement { - const input = document.createElement("input"); - input.type = "number"; - input.placeholder = placeholder; - input.style.paddingRight = "30px"; - input.dataset.unit = unit; - return input; -} - -function pathFromPoints(points: ReadonlyArray<PreviewAnnotationPoint>): string { - if (points.length === 0) return ""; - if (points.length === 1) return `M ${points[0]!.x} ${points[0]!.y} l 0.01 0.01`; - let path = `M ${points[0]!.x} ${points[0]!.y}`; - for (let index = 1; index < points.length - 1; index += 1) { - const current = points[index]!; - const next = points[index + 1]!; - path += ` Q ${current.x} ${current.y} ${(current.x + next.x) / 2} ${(current.y + next.y) / 2}`; - } - const last = points[points.length - 1]!; - path += ` L ${last.x} ${last.y}`; - return path; -} - -function strokeBounds( - points: ReadonlyArray<PreviewAnnotationPoint>, - width: number, -): PreviewAnnotationRect { - const xs = points.map((point) => point.x); - const ys = points.map((point) => point.y); - const padding = width + 3; - const left = Math.min(...xs) - padding; - const top = Math.min(...ys) - padding; - const right = Math.max(...xs) + padding; - const bottom = Math.max(...ys) + padding; - return { x: left, y: top, width: right - left, height: bottom - top }; -} - -function startAnnotation(): void { - activeSession?.teardown(false); - let finished = false; - const host = document.createElement("div"); - host.setAttribute(OVERLAY_ATTRIBUTE, ""); - host.style.cssText = `position:fixed;inset:0;z-index:${Z_INDEX_OVERLAY};pointer-events:none`; - applyAnnotationTheme(host, annotationTheme); - const shadowRoot = host.attachShadow({ mode: "closed" }); - const themeStyle = document.createElement("style"); - themeStyle.textContent = previewAnnotationStyles; - shadowRoot.appendChild(themeStyle); - - const root = document.createElement("div"); - root.setAttribute(OVERLAY_ATTRIBUTE, ""); - root.className = "fixed inset-0 font-sans text-foreground"; - root.style.cssText = "pointer-events:none"; - const cursorStyle = document.createElement("style"); - cursorStyle.setAttribute(OVERLAY_ATTRIBUTE, ""); - cursorStyle.textContent = `html[data-t3code-annotation-tool] body, html[data-t3code-annotation-tool] body * { cursor: crosshair !important; } [${OVERLAY_ATTRIBUTE}], [${OVERLAY_ATTRIBUTE}] * { cursor: default !important; } [${OVERLAY_ATTRIBUTE}] input[type=number]::-webkit-inner-spin-button, [${OVERLAY_ATTRIBUTE}] input[type=number]::-webkit-outer-spin-button { appearance:none; margin:0; }`; - document.documentElement.appendChild(cursorStyle); - shadowRoot.appendChild(root); - - const hoverOutline = createBox(PRIMARY, PRIMARY_FILL); - const marqueeBox = createBox(PRIMARY, PRIMARY_FILL); - root.append(hoverOutline, marqueeBox); - - const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); - svg.setAttribute(OVERLAY_ATTRIBUTE, ""); - svg.setAttribute("width", "100%"); - svg.setAttribute("height", "100%"); - svg.setAttribute("viewBox", `0 0 ${window.innerWidth} ${window.innerHeight}`); - svg.style.cssText = "position:fixed;inset:0;overflow:visible;pointer-events:none"; - svg.style.zIndex = String(CONTENT_LAYER_Z_INDEX); - root.appendChild(svg); - - const toolbar = document.createElement("div"); - toolbar.setAttribute(OVERLAY_ATTRIBUTE, ""); - toolbar.className = - "pointer-events-auto fixed top-2.5 left-1/2 flex -translate-x-1/2 gap-0.5 rounded-lg border border-border bg-popover/95 p-1 text-popover-foreground shadow-lg backdrop-blur-xl"; - toolbar.style.zIndex = String(CHROME_LAYER_Z_INDEX); - root.appendChild(toolbar); - - const editor = document.createElement("div"); - editor.setAttribute(OVERLAY_ATTRIBUTE, ""); - editor.className = - "pointer-events-auto fixed hidden max-h-[calc(100vh-16px)] w-[min(360px,calc(100vw-16px))] flex-col overflow-hidden rounded-xl border border-border bg-popover/96 text-popover-foreground shadow-2xl backdrop-blur-xl"; - editor.style.zIndex = String(CHROME_LAYER_Z_INDEX); - root.appendChild(editor); - - const composerRow = document.createElement("div"); - composerRow.className = "flex items-start gap-2 p-2"; - - const adjust = createButton("", "Expand annotation editor"); - adjust.setAttribute("aria-label", "Expand annotation editor"); - adjust.setAttribute("aria-expanded", "false"); - adjust.className += - " h-8 w-8 shrink-0 bg-muted p-0 text-muted-foreground hover:bg-accent hover:text-accent-foreground"; - adjust.innerHTML = - '<svg viewBox="0 0 20 20" width="15" height="15" aria-hidden="true"><path d="M4 5h12M4 10h12M4 15h12M7 3v4M13 8v4M9 13v4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>'; - composerRow.appendChild(adjust); - - const comment = document.createElement("textarea"); - comment.placeholder = "Describe the change…"; - comment.rows = 1; - comment.className = - "min-h-8 max-h-24 min-w-0 flex-1 resize-none overflow-y-hidden border-0 border-b border-b-transparent bg-transparent px-0 py-1.5 font-sans text-sm leading-5 text-foreground outline-none ring-0 placeholder:text-muted-foreground focus:border-b-primary focus:outline-none focus:ring-0"; - composerRow.appendChild(comment); - - const dragHandle = document.createElement("button"); - dragHandle.type = "button"; - dragHandle.textContent = "⠿"; - dragHandle.title = "Drag annotation editor"; - dragHandle.className = - "hidden h-8 w-6 shrink-0 cursor-grab select-none border-0 bg-transparent p-0 font-sans text-lg font-bold leading-5 text-muted-foreground"; - composerRow.appendChild(dragHandle); - - const submit = createButton("Attach", "Attach annotation and screenshot"); - submit.className += - " h-8 shrink-0 border-primary bg-primary px-3 text-primary-foreground shadow-sm hover:bg-primary/90"; - composerRow.appendChild(submit); - editor.appendChild(composerRow); - - const stylePanel = document.createElement("div"); - stylePanel.className = - "hidden max-h-[min(176px,calc(100vh-180px))] overflow-auto border-t border-border bg-muted/40 px-3"; - editor.appendChild(stylePanel); - - const selected = new Map<Element, SelectedElement>(); - const regions: PreviewAnnotationRegionTarget[] = []; - const strokes: PreviewAnnotationStrokeTarget[] = []; - const styleChanges = new Map<string, PreviewAnnotationStyleChange>(); - const toolButtons = new Map<AnnotationTool, HTMLButtonElement>(); - let tool: AnnotationTool = "select"; - let dragStart: PreviewAnnotationPoint | null = null; - let activeStroke: { target: PreviewAnnotationStrokeTarget; path: SVGPathElement } | null = null; - let pendingCapture = false; - let editorExpanded = false; - let editorWasShown = false; - let editorPosition: { left: number; top: number } | null = null; - let editorDrag: { pointerId: number; offsetX: number; offsetY: number } | null = null; - let editorLayoutFrame: number | null = null; - - const resizeComment = (): void => { - const maxHeight = 96; - comment.style.height = "auto"; - const nextHeight = Math.min(comment.scrollHeight, maxHeight); - comment.style.height = `${nextHeight}px`; - comment.style.overflowY = comment.scrollHeight > maxHeight ? "auto" : "hidden"; - queueEditorLayout(); - }; - comment.addEventListener("input", resizeComment); - - const updateStatus = (): void => { - const hasTargets = selected.size > 0 || regions.length > 0 || strokes.length > 0; - editor.style.display = hasTargets ? "flex" : "none"; - submit.disabled = !hasTargets; - submit.style.opacity = hasTargets ? "1" : "0.45"; - adjust.disabled = !hasTargets; - stylePanel.style.display = editorExpanded && selected.size > 0 ? "grid" : "none"; - queueEditorLayout(); - if (hasTargets && !editorWasShown) { - editorWasShown = true; - window.setTimeout(() => comment.focus({ preventScroll: true }), 0); - } - }; - - const refreshToolButtons = (): void => { - for (const [candidate, button] of toolButtons) { - const active = candidate === tool; - button.classList.toggle("bg-primary/10", active); - button.classList.toggle("text-primary", active); - button.classList.toggle("text-foreground", !active); - } - if (tool !== "select") hoverOutline.style.display = "none"; - if (tool !== "marquee") marqueeBox.style.display = "none"; - document.documentElement.setAttribute("data-t3code-annotation-tool", tool); - }; - - const removeSelected = (target: SelectedElement): void => { - if (target.element instanceof HTMLElement || target.element instanceof SVGElement) { - for (const [property, baseline] of target.baselineStyles) { - if (baseline) target.element.style.setProperty(property, baseline); - else target.element.style.removeProperty(property); - } - } - selected.delete(target.element); - target.outline.remove(); - target.label.remove(); - for (const [key, change] of styleChanges) { - if (change.targetId === target.id) styleChanges.delete(key); - } - updateStatus(); - }; - - const addSelected = (element: Element): void => { - if (selected.has(element)) return; - const target: SelectedElement = { - id: nextId("element"), - element, - outline: createBox(PRIMARY, PRIMARY_FILL), - label: createLabel(), - baselineStyles: new Map(), - }; - selected.set(element, target); - root.append(target.outline, target.label); - updateSelectedVisual(target); - updateStatus(); - if (editorExpanded) { - stylePanel.style.display = "grid"; - syncStyleControls(); - } - }; - - const toggleSelected = (element: Element, additive: boolean): void => { - const existing = selected.get(element); - if (existing) { - removeSelected(existing); - return; - } - if (!additive) { - for (const target of Array.from(selected.values())) removeSelected(target); - } - addSelected(element); - }; - - const setStyleForSelected = (property: string, value: string): void => { - for (const target of selected.values()) { - if (!(target.element instanceof HTMLElement || target.element instanceof SVGElement)) - continue; - if (!target.baselineStyles.has(property)) { - target.baselineStyles.set(property, target.element.style.getPropertyValue(property)); - } - const key = `${target.id}:${property}`; - const previousValue = - styleChanges.get(key)?.previousValue ?? - getComputedStyle(target.element).getPropertyValue(property).trim(); - target.element.style.setProperty(property, value, "important"); - styleChanges.set(key, { - targetId: target.id, - selector: null, - property, - previousValue, - value, - }); - updateSelectedVisual(target); - } - }; - - const textSection = createStyleSection(); - const colorsSection = createStyleSection(); - const bordersSection = createStyleSection(); - const sizingSection = createStyleSection(); - stylePanel.append(textSection, colorsSection, bordersSection, sizingSection); - - const fontFamily = document.createElement("select"); - for (const value of ["inherit", "system-ui", "sans-serif", "serif", "monospace"]) { - const option = document.createElement("option"); - option.value = value; - option.textContent = value; - fontFamily.appendChild(option); - } - fontFamily.addEventListener("change", () => setStyleForSelected("font-family", fontFamily.value)); - textSection.appendChild(createField("Font", fontFamily)); - - const fontSize = createUnitInput("px", "16"); - fontSize.min = "1"; - fontSize.max = "300"; - fontSize.addEventListener("input", () => { - if (fontSize.value) setStyleForSelected("font-size", `${fontSize.value}px`); - }); - textSection.appendChild(createField("Font size", fontSize)); - - const fontWeight = document.createElement("select"); - for (const value of ["300", "400", "500", "600", "700", "800", "900"]) { - const option = document.createElement("option"); - option.value = value; - option.textContent = value; - fontWeight.appendChild(option); - } - fontWeight.addEventListener("change", () => setStyleForSelected("font-weight", fontWeight.value)); - textSection.appendChild(createField("Font weight", fontWeight)); - - const lineHeight = document.createElement("input"); - lineHeight.type = "text"; - lineHeight.placeholder = "normal / 1.4"; - lineHeight.addEventListener("change", () => { - if (lineHeight.value.trim()) setStyleForSelected("line-height", lineHeight.value.trim()); - }); - textSection.appendChild(createField("Line height", lineHeight)); - - const createColorRow = ( - labelText: string, - property: string, - section: HTMLElement, - ): { row: HTMLLabelElement; color: HTMLInputElement; text: HTMLInputElement } => { - const row = document.createElement("label"); - row.className = - "grid min-h-7 grid-cols-[82px_minmax(0,1fr)] items-center gap-2 font-sans text-xs font-medium text-muted-foreground"; - const label = document.createElement("span"); - label.textContent = labelText; - const control = document.createElement("div"); - control.className = - "grid h-7 grid-cols-[22px_minmax(0,1fr)] items-center gap-1 rounded-md border border-input bg-background px-1 shadow-xs"; - const color = document.createElement("input"); - color.type = "color"; - color.setAttribute("aria-label", labelText); - color.style.cssText = - "width:20px;height:20px;padding:0;border:0;border-radius:5px;overflow:hidden;background:transparent;cursor:pointer"; - const text = document.createElement("input"); - text.type = "text"; - text.setAttribute("aria-label", `${labelText} value`); - text.className = - "min-w-0 w-full border-0 bg-transparent font-mono text-xs text-foreground outline-none"; - color.addEventListener("input", () => { - text.value = color.value; - setStyleForSelected(property, color.value); - }); - text.addEventListener("change", () => { - const value = text.value.trim(); - if (!value) return; - setStyleForSelected(property, value); - if (/^#[0-9a-f]{6}$/i.test(value)) color.value = value; - }); - control.append(color, text); - row.append(label, control); - section.appendChild(row); - return { row, color, text }; - }; - - const textColor = createColorRow("Text color", "color", colorsSection); - const backgroundColor = createColorRow("Background", "background-color", colorsSection); - - const opacity = document.createElement("input"); - opacity.type = "range"; - opacity.min = "0"; - opacity.max = "1"; - opacity.step = "0.05"; - opacity.value = "1"; - opacity.style.accentColor = PRIMARY; - opacity.addEventListener("input", () => setStyleForSelected("opacity", opacity.value)); - colorsSection.appendChild(createField("Opacity", opacity)); - - const radius = createUnitInput("px", "0"); - radius.min = "0"; - radius.max = "300"; - radius.addEventListener("input", () => { - if (radius.value) setStyleForSelected("border-radius", `${radius.value}px`); - }); - bordersSection.appendChild(createField("Radius", radius)); - - const borderColor = createColorRow("Border color", "border-color", bordersSection); - - const borderWidth = createUnitInput("px", "0"); - borderWidth.min = "0"; - borderWidth.max = "100"; - borderWidth.addEventListener("input", () => { - if (borderWidth.value) { - setStyleForSelected("border-style", "solid"); - setStyleForSelected("border-width", `${borderWidth.value}px`); - } - }); - bordersSection.appendChild(createField("Border width", borderWidth)); - - const dimensions = document.createElement("div"); - dimensions.style.cssText = - "display:grid;grid-template-columns:82px minmax(0,1fr);gap:8px;align-items:center"; - const dimensionLabel = document.createElement("div"); - dimensionLabel.className = "grid gap-2 font-sans text-xs font-medium text-muted-foreground"; - dimensionLabel.innerHTML = "<span>Width</span><span>Height</span>"; - const dimensionControls = document.createElement("div"); - dimensionControls.style.cssText = "position:relative;display:grid;gap:3px;padding-left:22px"; - const widthInput = createUnitInput("px", "auto"); - const heightInput = createUnitInput("px", "auto"); - styleControl(widthInput); - styleControl(heightInput); - const aspectLock = createButton("", "Lock aspect ratio"); - aspectLock.setAttribute("aria-pressed", "true"); - aspectLock.style.cssText += - ";position:absolute;left:0;top:50%;transform:translateY(-50%);width:18px;height:38px;padding:0"; - aspectLock.className += " bg-primary/10 text-primary"; - dimensionControls.append( - createUnitControl(widthInput), - createUnitControl(heightInput), - aspectLock, - ); - dimensions.append(dimensionLabel, dimensionControls); - sizingSection.appendChild(dimensions); - - let aspectLocked = true; - let aspectRatio = 1; - const refreshAspectButton = (): void => { - aspectLock.innerHTML = aspectLocked - ? '<svg viewBox="0 0 20 20" width="14" height="14" aria-hidden="true"><path d="M8 6.5 9.5 5A3.5 3.5 0 0 1 14.5 10l-1.5 1.5M12 13.5 10.5 15A3.5 3.5 0 0 1 5.5 10L7 8.5M7.5 12.5l5-5" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>' - : '<svg viewBox="0 0 20 20" width="14" height="14" aria-hidden="true"><path d="m6 6 8 8M8 6.5 9.5 5A3.5 3.5 0 0 1 14 9M12 13.5 10.5 15A3.5 3.5 0 0 1 6 11" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>'; - aspectLock.setAttribute("aria-pressed", String(aspectLocked)); - aspectLock.classList.toggle("bg-primary/10", aspectLocked); - aspectLock.classList.toggle("text-primary", aspectLocked); - aspectLock.classList.toggle("bg-muted", !aspectLocked); - aspectLock.classList.toggle("text-muted-foreground", !aspectLocked); - }; - aspectLock.addEventListener("click", () => { - aspectLocked = !aspectLocked; - refreshAspectButton(); - }); - widthInput.addEventListener("input", () => { - const width = Number(widthInput.value); - if (!Number.isFinite(width) || width <= 0) return; - setStyleForSelected("width", `${width}px`); - if (aspectLocked && aspectRatio > 0) { - const height = Math.max(1, Math.round(width / aspectRatio)); - heightInput.value = String(height); - setStyleForSelected("height", `${height}px`); - } - }); - heightInput.addEventListener("input", () => { - const height = Number(heightInput.value); - if (!Number.isFinite(height) || height <= 0) return; - setStyleForSelected("height", `${height}px`); - if (aspectLocked && aspectRatio > 0) { - const width = Math.max(1, Math.round(height * aspectRatio)); - widthInput.value = String(width); - setStyleForSelected("width", `${width}px`); - } - }); - refreshAspectButton(); - - const addSpacingField = ( - label: string, - property: string, - placeholder: string, - ): HTMLInputElement => { - const input = document.createElement("input"); - input.type = "text"; - input.placeholder = placeholder; - input.addEventListener("change", () => { - if (input.value.trim()) setStyleForSelected(property, input.value.trim()); - }); - sizingSection.appendChild(createField(label, input)); - return input; - }; - const padding = addSpacingField("Padding", "padding", "0 0 0 0"); - const margin = addSpacingField("Margin", "margin", "0 0 0 0"); - const gap = addSpacingField("Gap", "gap", "0px"); - - const syncStyleControls = (): void => { - const first = selected.values().next().value as SelectedElement | undefined; - if (!first) return; - const computed = getComputedStyle(first.element); - const rect = first.element.getBoundingClientRect(); - aspectRatio = rect.height > 0 ? rect.width / rect.height : 1; - widthInput.value = String(Math.round(rect.width)); - heightInput.value = String(Math.round(rect.height)); - fontSize.value = String(Math.round(Number.parseFloat(computed.fontSize) || 16)); - fontWeight.value = computed.fontWeight.match(/^[0-9]+$/) ? computed.fontWeight : "400"; - lineHeight.value = computed.lineHeight; - fontFamily.value = Array.from(fontFamily.options).some( - (option) => option.value === computed.fontFamily, - ) - ? computed.fontFamily - : "inherit"; - textColor.text.value = computed.color; - backgroundColor.text.value = computed.backgroundColor; - borderColor.text.value = computed.borderColor; - opacity.value = computed.opacity; - radius.value = String(Math.round(Number.parseFloat(computed.borderRadius) || 0)); - borderWidth.value = String(Math.round(Number.parseFloat(computed.borderWidth) || 0)); - padding.value = computed.padding; - margin.value = computed.margin; - gap.value = computed.gap === "normal" ? "0px" : computed.gap; - }; - - const tools: ReadonlyArray<[AnnotationTool, string, string]> = [ - ["select", "Select", "Select elements (V)"], - ["marquee", "Region", "Draw a region or marquee-select elements (R)"], - ["draw", "Draw", "Draw freehand (D)"], - ["erase", "Erase", "Remove an annotation target (E)"], - ]; - for (const [candidate, label, title] of tools) { - const button = createButton(label, title); - button.className += " h-8 px-2.5 text-sm"; - button.addEventListener("click", () => { - tool = candidate; - refreshToolButtons(); - }); - toolButtons.set(candidate, button); - toolbar.appendChild(button); - } - - const clampEditorPosition = (left: number, top: number): { left: number; top: number } => { - const margin = 8; - const rect = editor.getBoundingClientRect(); - return { - left: Math.min( - Math.max(margin, left), - Math.max(margin, window.innerWidth - rect.width - margin), - ), - top: Math.min( - Math.max(margin, top), - Math.max(margin, window.innerHeight - rect.height - margin), - ), - }; - }; - - const applyEditorPosition = (position: { left: number; top: number }): void => { - const clamped = clampEditorPosition(position.left, position.top); - editor.style.left = `${clamped.left}px`; - editor.style.top = `${clamped.top}px`; - editor.style.right = "auto"; - editor.style.bottom = "auto"; - if (editorExpanded) editorPosition = clamped; - }; - - const getAnnotationBounds = (): PreviewAnnotationRect | null => - unionRects( - [ - ...Array.from(selected.values(), (target) => - rectFromDomRect(target.element.getBoundingClientRect()), - ), - ...regions.map((region) => region.rect), - ...strokes.map((stroke) => stroke.bounds), - ], - 0, - ); - - const positionCompactEditor = (): void => { - const bounds = getAnnotationBounds(); - if (!bounds) return; - const editorRect = editor.getBoundingClientRect(); - const gap = 8; - const candidates = [ - { left: bounds.x + bounds.width + gap, top: bounds.y }, - { left: bounds.x - editorRect.width - gap, top: bounds.y }, - { - left: bounds.x + bounds.width - editorRect.width, - top: bounds.y + bounds.height + gap, - }, - { - left: bounds.x + bounds.width - editorRect.width, - top: bounds.y - editorRect.height - gap, - }, - ]; - const overflow = (position: { left: number; top: number }): number => - Math.max(0, -position.left) + - Math.max(0, -position.top) + - Math.max(0, position.left + editorRect.width - window.innerWidth) + - Math.max(0, position.top + editorRect.height - window.innerHeight); - const best = candidates.reduce((current, candidate) => - overflow(candidate) < overflow(current) ? candidate : current, - ); - applyEditorPosition(best); - }; - - function queueEditorLayout(): void { - if (editorLayoutFrame !== null) window.cancelAnimationFrame(editorLayoutFrame); - editorLayoutFrame = window.requestAnimationFrame(() => { - editorLayoutFrame = null; - if (editor.style.display === "none") return; - if (editorExpanded && editorPosition) applyEditorPosition(editorPosition); - else positionCompactEditor(); - }); - } - - adjust.addEventListener("click", () => { - if (selected.size === 0) return; - if (!editorExpanded) { - const rect = editor.getBoundingClientRect(); - editorExpanded = true; - editorPosition = { left: rect.left, top: rect.top }; - stylePanel.style.display = selected.size > 0 ? "grid" : "none"; - dragHandle.style.display = "block"; - adjust.setAttribute("aria-expanded", "true"); - adjust.title = "Collapse annotation editor"; - adjust.setAttribute("aria-label", "Collapse annotation editor"); - if (selected.size > 0) syncStyleControls(); - } else { - editorExpanded = false; - editorPosition = null; - stylePanel.style.display = "none"; - dragHandle.style.display = "none"; - adjust.setAttribute("aria-expanded", "false"); - adjust.title = "Expand annotation editor"; - adjust.setAttribute("aria-label", "Expand annotation editor"); - } - queueEditorLayout(); - }); - - const onEditorPointerDown = (event: PointerEvent): void => { - if (event.button !== 0 || !editorExpanded) return; - const rect = editor.getBoundingClientRect(); - editorDrag = { - pointerId: event.pointerId, - offsetX: event.clientX - rect.left, - offsetY: event.clientY - rect.top, - }; - dragHandle.setPointerCapture(event.pointerId); - dragHandle.style.cursor = "grabbing"; - event.preventDefault(); - event.stopPropagation(); - }; - - const onEditorPointerMove = (event: PointerEvent): void => { - if (!editorDrag || editorDrag.pointerId !== event.pointerId) return; - applyEditorPosition({ - left: event.clientX - editorDrag.offsetX, - top: event.clientY - editorDrag.offsetY, - }); - event.preventDefault(); - event.stopPropagation(); - }; - - const onEditorPointerUp = (event: PointerEvent): void => { - if (!editorDrag || editorDrag.pointerId !== event.pointerId) return; - editorDrag = null; - dragHandle.style.cursor = "grab"; - if (dragHandle.hasPointerCapture(event.pointerId)) - dragHandle.releasePointerCapture(event.pointerId); - event.preventDefault(); - event.stopPropagation(); - }; - dragHandle.addEventListener("pointerdown", onEditorPointerDown); - dragHandle.addEventListener("pointermove", onEditorPointerMove); - dragHandle.addEventListener("pointerup", onEditorPointerUp); - dragHandle.addEventListener("pointercancel", onEditorPointerUp); - - const repaint = (): void => { - for (const target of selected.values()) updateSelectedVisual(target); - queueEditorLayout(); - }; - - const removeTargetAtPoint = (x: number, y: number): boolean => { - for (const target of Array.from(selected.values()).toReversed()) { - const rect = target.element.getBoundingClientRect(); - if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) { - removeSelected(target); - return true; - } - } - const regionIndex = regions.findIndex( - (region) => - x >= region.rect.x && - x <= region.rect.x + region.rect.width && - y >= region.rect.y && - y <= region.rect.y + region.rect.height, - ); - if (regionIndex >= 0) { - const [removed] = regions.splice(regionIndex, 1); - root.querySelector(`[data-region-id="${removed?.id}"]`)?.remove(); - updateStatus(); - return true; - } - const strokeIndex = strokes.findIndex( - (stroke) => - x >= stroke.bounds.x && - x <= stroke.bounds.x + stroke.bounds.width && - y >= stroke.bounds.y && - y <= stroke.bounds.y + stroke.bounds.height, - ); - if (strokeIndex >= 0) { - const [removed] = strokes.splice(strokeIndex, 1); - svg.querySelector(`[data-stroke-id="${removed?.id}"]`)?.remove(); - updateStatus(); - return true; - } - return false; - }; - - const selectElementsInRect = (rect: PreviewAnnotationRect): number => { - const candidates = Array.from(document.querySelectorAll("body *")) - .filter((element) => !isAnnotationNode(element)) - .map((element) => ({ element, rect: element.getBoundingClientRect() })) - .filter(({ rect: candidate }) => { - if (candidate.width < 2 || candidate.height < 2) return false; - return !( - candidate.right < rect.x || - candidate.left > rect.x + rect.width || - candidate.bottom < rect.y || - candidate.top > rect.y + rect.height - ); - }) - .filter(({ element, rect: candidate }) => { - const centerX = candidate.left + candidate.width / 2; - const centerY = candidate.top + candidate.height / 2; - return ( - centerX >= rect.x && - centerX <= rect.x + rect.width && - centerY >= rect.y && - centerY <= rect.y + rect.height && - (element.children.length === 0 || - element instanceof HTMLButtonElement || - element instanceof HTMLAnchorElement || - element.getAttribute("role") === "button") - ); - }) - .sort( - (left, right) => left.rect.width * left.rect.height - right.rect.width * right.rect.height, - ) - .slice(0, MAX_MARQUEE_ELEMENTS); - for (const candidate of candidates) addSelected(candidate.element); - return candidates.length; - }; - - const clearHoverOutline = (): void => { - hoverOutline.style.display = "none"; - }; - - const onPointerMove = (event: PointerEvent): void => { - if (isAnnotationNode(event.target as Element)) { - clearHoverOutline(); - return; - } - if (tool === "select" && dragStart === null) { - const target = pickFromPoint(event.clientX, event.clientY); - if (target) positionBox(hoverOutline, rectFromDomRect(target.getBoundingClientRect())); - else clearHoverOutline(); - return; - } - clearHoverOutline(); - if (tool === "marquee" && dragStart) { - positionBox( - marqueeBox, - normalizeRect(dragStart.x, dragStart.y, event.clientX, event.clientY), - ); - return; - } - if (tool === "draw" && activeStroke) { - activeStroke.target.points = [ - ...activeStroke.target.points, - { x: event.clientX, y: event.clientY }, - ]; - activeStroke.target.bounds = strokeBounds( - activeStroke.target.points, - activeStroke.target.width, - ); - activeStroke.path.setAttribute("d", pathFromPoints(activeStroke.target.points)); - } - }; - - const onPointerDown = (event: PointerEvent): void => { - if (event.button !== 0 || isAnnotationNode(event.target as Element)) return; - event.preventDefault(); - event.stopPropagation(); - if (tool === "select") { - const target = pickFromPoint(event.clientX, event.clientY); - if (target) toggleSelected(target, event.shiftKey); - return; - } - if (tool === "erase") { - removeTargetAtPoint(event.clientX, event.clientY); - return; - } - dragStart = { x: event.clientX, y: event.clientY }; - if (tool === "draw") { - const stroke: PreviewAnnotationStrokeTarget = { - id: nextId("stroke"), - color: annotationTheme?.primary ?? "#2563eb", - width: 4, - points: [dragStart], - bounds: { x: dragStart.x, y: dragStart.y, width: 1, height: 1 }, - }; - const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); - path.setAttribute(OVERLAY_ATTRIBUTE, ""); - path.setAttribute("data-stroke-id", stroke.id); - path.setAttribute("fill", "none"); - path.setAttribute("stroke", stroke.color); - path.setAttribute("stroke-width", String(stroke.width)); - path.setAttribute("stroke-linecap", "round"); - path.setAttribute("stroke-linejoin", "round"); - svg.appendChild(path); - activeStroke = { target: stroke, path }; - } - }; - - const onPointerUp = (event: PointerEvent): void => { - if (!dragStart) return; - event.preventDefault(); - event.stopPropagation(); - if (tool === "marquee") { - const rect = normalizeRect(dragStart.x, dragStart.y, event.clientX, event.clientY); - marqueeBox.style.display = "none"; - if (isUsableRect(rect)) { - const found = selectElementsInRect(rect); - if (found === 0) { - const region: PreviewAnnotationRegionTarget = { id: nextId("region"), rect }; - regions.push(region); - const regionBox = createBox( - PRIMARY, - "color-mix(in srgb, var(--t3-primary) 6%, transparent)", - ); - regionBox.setAttribute("data-region-id", region.id); - positionBox(regionBox, rect); - root.appendChild(regionBox); - } - } - } else if (tool === "draw" && activeStroke) { - if (activeStroke.target.points.length > 1) strokes.push(activeStroke.target); - else activeStroke.path.remove(); - activeStroke = null; - } - dragStart = null; - updateStatus(); - }; - - const onClick = (event: MouseEvent): void => { - if (isAnnotationNode(event.target as Element)) return; - event.preventDefault(); - event.stopPropagation(); - }; - - const onPointerOut = (event: PointerEvent): void => { - if (event.relatedTarget === null) clearHoverOutline(); - }; - - const onWindowBlur = (): void => { - clearHoverOutline(); - }; - - const restoreStyles = (): void => { - for (const target of selected.values()) { - if (!(target.element instanceof HTMLElement || target.element instanceof SVGElement)) - continue; - for (const [property, baseline] of target.baselineStyles) { - if (baseline) target.element.style.setProperty(property, baseline); - else target.element.style.removeProperty(property); - } - } - }; - - const teardown = (notifyMain: boolean): void => { - if (finished) return; - finished = true; - restoreStyles(); - window.removeEventListener("pointermove", onPointerMove, true); - window.removeEventListener("pointerdown", onPointerDown, true); - window.removeEventListener("pointerup", onPointerUp, true); - window.removeEventListener("pointerout", onPointerOut, true); - window.removeEventListener("click", onClick, true); - window.removeEventListener("blur", onWindowBlur); - window.removeEventListener("keydown", onKeyDown, true); - window.removeEventListener("scroll", repaint, true); - window.removeEventListener("resize", repaint); - dragHandle.removeEventListener("pointerdown", onEditorPointerDown); - dragHandle.removeEventListener("pointermove", onEditorPointerMove); - dragHandle.removeEventListener("pointerup", onEditorPointerUp); - dragHandle.removeEventListener("pointercancel", onEditorPointerUp); - if (editorLayoutFrame !== null) window.cancelAnimationFrame(editorLayoutFrame); - ipcRenderer.off(CANCEL_PICK_CHANNEL, onCancel); - ipcRenderer.off(ANNOTATION_CAPTURED_CHANNEL, onCaptured); - document.documentElement.removeAttribute("data-t3code-annotation-tool"); - cursorStyle.remove(); - host.remove(); - activeSession = null; - if (notifyMain) ipcRenderer.send(ELEMENT_PICKED_CHANNEL, null); - }; - - const onCancel = (): void => teardown(false); - const onCaptured = (): void => teardown(false); - const onKeyDown = (event: KeyboardEvent): void => { - if (isAnnotationNode(event.target as Element) && event.key !== "Escape") return; - if (event.key === "Escape") { - event.preventDefault(); - event.stopPropagation(); - teardown(true); - return; - } - if (event.key === "v") tool = "select"; - else if (event.key === "r") tool = "marquee"; - else if (event.key === "d") tool = "draw"; - else if (event.key === "e") tool = "erase"; - else return; - refreshToolButtons(); - }; - - submit.addEventListener("click", () => { - if (pendingCapture || (selected.size === 0 && regions.length === 0 && strokes.length === 0)) - return; - pendingCapture = true; - submit.disabled = true; - submit.textContent = "Capturing…"; - void Promise.all( - Array.from(selected.values()).map(async (target) => { - const element = await captureElement(target.element); - if (!element) return null; - for (const change of styleChanges.values()) { - if (change.targetId === target.id) change.selector = element.selector; - } - return { - id: target.id, - element, - rect: rectFromDomRect(target.element.getBoundingClientRect()), - }; - }), - ).then((captured) => { - const elements = captured.filter((target) => target !== null); - const annotation: PreviewAnnotationPayload = { - id: nextId("annotation"), - pageUrl: location.href, - pageTitle: document.title?.trim() || null, - comment: comment.value.trim(), - elements, - regions: [...regions], - strokes: [...strokes], - styleChanges: Array.from(styleChanges.values()), - screenshot: null, - createdAt: new Date().toISOString(), - }; - editor.style.display = "none"; - toolbar.style.display = "none"; - hoverOutline.style.display = "none"; - const screenshotRect = unionRects([ - ...elements.map((target) => target.rect), - ...regions.map((region) => region.rect), - ...strokes.map((stroke) => stroke.bounds), - ]); - ipcRenderer.send(ELEMENT_PICKED_CHANNEL, annotation, screenshotRect); - }); - }); - comment.addEventListener("keydown", (event) => { - if (event.key !== "Enter" || !(event.metaKey || event.ctrlKey)) return; - event.preventDefault(); - submit.click(); - }); - - window.addEventListener("pointermove", onPointerMove, { capture: true, passive: false }); - window.addEventListener("pointerdown", onPointerDown, { capture: true, passive: false }); - window.addEventListener("pointerup", onPointerUp, { capture: true, passive: false }); - window.addEventListener("pointerout", onPointerOut, { capture: true, passive: true }); - window.addEventListener("click", onClick, { capture: true, passive: false }); - window.addEventListener("blur", onWindowBlur); - window.addEventListener("keydown", onKeyDown, { capture: true }); - window.addEventListener("scroll", repaint, { capture: true, passive: true }); - window.addEventListener("resize", repaint, { passive: true }); - ipcRenderer.on(CANCEL_PICK_CHANNEL, onCancel); - ipcRenderer.on(ANNOTATION_CAPTURED_CHANNEL, onCaptured); - document.documentElement.appendChild(host); - refreshToolButtons(); - updateStatus(); - activeSession = { - teardown, - applyTheme: (theme) => applyAnnotationTheme(host, theme), - }; -} - -ipcRenderer.on(START_PICK_CHANNEL, (_event, theme: DesktopPreviewAnnotationTheme | undefined) => { - if (theme) annotationTheme = theme; - startAnnotation(); -}); -ipcRenderer.on(ANNOTATION_THEME_CHANNEL, (_event, theme: DesktopPreviewAnnotationTheme) => { - annotationTheme = theme; - activeSession?.applyTheme(theme); -}); -ipcRenderer.on(CANCEL_PICK_CHANNEL, () => activeSession?.teardown(false)); +import "./preview/PickPreload.ts"; diff --git a/apps/desktop/src/preview-annotation.css b/apps/desktop/src/preview/Annotation.css similarity index 100% rename from apps/desktop/src/preview-annotation.css rename to apps/desktop/src/preview/Annotation.css diff --git a/apps/desktop/src/preview-annotation-styles.generated.ts b/apps/desktop/src/preview/AnnotationStyles.generated.ts similarity index 100% rename from apps/desktop/src/preview-annotation-styles.generated.ts rename to apps/desktop/src/preview/AnnotationStyles.generated.ts diff --git a/apps/desktop/src/preview/BrowserSession.ts b/apps/desktop/src/preview/BrowserSession.ts new file mode 100644 index 00000000000..58ca30f3088 --- /dev/null +++ b/apps/desktop/src/preview/BrowserSession.ts @@ -0,0 +1,93 @@ +import type { Session } from "electron"; +import { session } from "electron"; +import { createHash } from "node:crypto"; +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +const PREVIEW_PARTITION_PREFIX = "persist:t3code-preview-"; + +export class BrowserSessionError extends Data.TaggedError("BrowserSessionError")<{ + readonly operation: string; + readonly cause: unknown; +}> { + override get message() { + return `Desktop preview browser session operation failed: ${this.operation}`; + } +} + +export interface BrowserSessionShape { + readonly getPartition: (scope?: string) => string; + readonly isPartition: (partition: string) => boolean; + readonly getSession: (scope?: string) => Effect.Effect<Session, BrowserSessionError>; + readonly clearCookies: () => Effect.Effect<void, BrowserSessionError>; + readonly clearCache: () => Effect.Effect<void, BrowserSessionError>; +} + +export class BrowserSession extends Context.Service<BrowserSession, BrowserSessionShape>()( + "@t3tools/desktop/preview/BrowserSession", +) {} + +const make = Effect.fn("BrowserSession.make")(() => + Effect.sync(() => { + const sessions = new Map<string, Session>(); + const getPartition = (scope = "shared"): string => { + const digest = createHash("sha256").update(scope).digest("hex").slice(0, 20); + return `${PREVIEW_PARTITION_PREFIX}${digest}`; + }; + + const getSession = Effect.fn("BrowserSession.getSession")(function* (scope = "shared") { + const partition = getPartition(scope); + const existing = sessions.get(partition); + if (existing) return existing; + return yield* Effect.try({ + try: () => { + const browserSession = session.fromPartition(partition); + const userAgent = browserSession + .getUserAgent() + .replace(/Electron\/[\d.]+ /, "") + .replace(/\s*t3code\/[\d.]+/, ""); + browserSession.setUserAgent(userAgent); + browserSession.setPermissionRequestHandler((_webContents, permission, callback) => { + const allowed = ["clipboard-read", "clipboard-write", "notifications", "geolocation"]; + callback(allowed.includes(permission)); + }); + sessions.set(partition, browserSession); + return browserSession; + }, + catch: (cause) => new BrowserSessionError({ operation: "getSession", cause }), + }); + }); + + return BrowserSession.of({ + getPartition, + isPartition: (partition) => partition.startsWith(PREVIEW_PARTITION_PREFIX), + getSession, + clearCookies: Effect.fn("BrowserSession.clearCookies")(function* () { + yield* Effect.tryPromise({ + try: () => + Promise.all( + [...sessions.values()].map((browserSession) => + browserSession.clearStorageData({ + storages: ["cookies", "localstorage", "indexdb", "websql", "serviceworkers"], + }), + ), + ), + catch: (cause) => new BrowserSessionError({ operation: "clearCookies", cause }), + }); + }), + clearCache: Effect.fn("BrowserSession.clearCache")(function* () { + yield* Effect.tryPromise({ + try: () => + Promise.all( + [...sessions.values()].map((browserSession) => browserSession.clearCache()), + ), + catch: (cause) => new BrowserSessionError({ operation: "clearCache", cause }), + }); + }), + }); + }), +); + +export const layer = Layer.effect(BrowserSession, make()); diff --git a/apps/desktop/src/preview/GuestProtocol.ts b/apps/desktop/src/preview/GuestProtocol.ts new file mode 100644 index 00000000000..00616c6a476 --- /dev/null +++ b/apps/desktop/src/preview/GuestProtocol.ts @@ -0,0 +1,6 @@ +export const START_PICK_CHANNEL = "preview:start-pick"; +export const CANCEL_PICK_CHANNEL = "preview:cancel-pick"; +export const ELEMENT_PICKED_CHANNEL = "preview:element-picked"; +export const ANNOTATION_CAPTURED_CHANNEL = "preview:annotation-captured"; +export const ANNOTATION_THEME_CHANNEL = "preview:annotation-theme"; +export const HUMAN_INPUT_CHANNEL = "preview:human-input"; diff --git a/apps/desktop/src/preview-view-manager.test.ts b/apps/desktop/src/preview/Manager.test.ts similarity index 94% rename from apps/desktop/src/preview-view-manager.test.ts rename to apps/desktop/src/preview/Manager.test.ts index a7aeabc0e0d..7e07d556783 100644 --- a/apps/desktop/src/preview-view-manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -40,7 +40,7 @@ describe("PreviewViewManager automation status", () => { }); it("reports an unregistered webview as temporarily unavailable", async () => { - const { PreviewViewManager } = await import("./preview-view-manager.ts"); + const { PreviewViewManager } = (await import("./Manager.ts")).__testing; const manager = new PreviewViewManager(); expect(manager.automationStatus("tab_1")).toEqual({ @@ -95,7 +95,7 @@ describe("PreviewViewManager automation status", () => { }, capturePage, } as never); - const { PreviewViewManager } = await import("./preview-view-manager.ts"); + const { PreviewViewManager } = (await import("./Manager.ts")).__testing; const manager = new PreviewViewManager(); manager.configureArtifactDirectory("/tmp/t3/dev/browser-artifacts"); manager.createTab("tab_1"); @@ -127,7 +127,7 @@ describe("PreviewViewManager automation status", () => { }); it("reveals only files inside the configured browser artifact directory", async () => { - const { PreviewViewManager } = await import("./preview-view-manager.ts"); + const { PreviewViewManager } = (await import("./Manager.ts")).__testing; const manager = new PreviewViewManager(); manager.configureArtifactDirectory("/tmp/t3/dev/browser-artifacts"); @@ -142,7 +142,7 @@ describe("PreviewViewManager automation status", () => { }); it("copies screenshot artifacts to the system clipboard", async () => { - const { PreviewViewManager } = await import("./preview-view-manager.ts"); + const { PreviewViewManager } = (await import("./Manager.ts")).__testing; const manager = new PreviewViewManager(); manager.configureArtifactDirectory("/tmp/t3/dev/browser-artifacts"); const artifactPath = "/tmp/t3/dev/browser-artifacts/browser-screenshot-test.png"; @@ -202,7 +202,7 @@ describe("PreviewViewManager automation status", () => { off: vi.fn(), }, } as never); - const { PreviewViewManager } = await import("./preview-view-manager.ts"); + const { PreviewViewManager } = (await import("./Manager.ts")).__testing; const manager = new PreviewViewManager(); manager.onPointerEvent((event) => activity.push(event.phase)); manager.createTab("tab_1"); @@ -271,7 +271,7 @@ describe("PreviewViewManager automation status", () => { off: vi.fn(), }, } as never); - const { PreviewViewManager } = await import("./preview-view-manager.ts"); + const { PreviewViewManager } = (await import("./Manager.ts")).__testing; const manager = new PreviewViewManager(); manager.createTab("tab_1"); manager.registerWebview("tab_1", 42); diff --git a/apps/desktop/src/preview-view-manager.ts b/apps/desktop/src/preview/Manager.ts similarity index 80% rename from apps/desktop/src/preview-view-manager.ts rename to apps/desktop/src/preview/Manager.ts index 5406ae731b0..5c62179bf6f 100644 --- a/apps/desktop/src/preview-view-manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -33,31 +33,30 @@ import { type Session, clipboard, nativeImage, - session, shell, webContents, } from "electron"; import { mkdir, writeFile } from "node:fs/promises"; import { isAbsolute, join, relative, resolve, sep } from "node:path"; -import { createHash } from "node:crypto"; import { setTimeout as sleep } from "node:timers/promises"; - -import { isPreviewAnnotationPayload } from "./picked-element-payload.ts"; -import { playwrightInjectedRuntimeInstallExpression } from "./playwright-injected-runtime.ts"; - -const PREVIEW_PARTITION_PREFIX = "persist:t3code-preview-"; -const START_PICK_CHANNEL = "preview:start-pick"; -const CANCEL_PICK_CHANNEL = "preview:cancel-pick"; -const ELEMENT_PICKED_CHANNEL = "preview:element-picked"; -const ANNOTATION_CAPTURED_CHANNEL = "preview:annotation-captured"; -const ANNOTATION_THEME_CHANNEL = "preview:annotation-theme"; -const HUMAN_INPUT_CHANNEL = "preview:human-input"; - -// Re-export the guest webview security posture from its dedicated module so -// the constant is unit-testable in isolation. See -// `preview-webview-preferences.ts` for the full security rationale. -export { PREVIEW_WEBVIEW_PREFERENCES } from "./preview-webview-preferences.ts"; -import { PREVIEW_WEBVIEW_PREFERENCES } from "./preview-webview-preferences.ts"; +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import type * as Scope from "effect/Scope"; + +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as BrowserSession from "./BrowserSession.ts"; +import { + ANNOTATION_CAPTURED_CHANNEL, + ANNOTATION_THEME_CHANNEL, + CANCEL_PICK_CHANNEL, + ELEMENT_PICKED_CHANNEL, + HUMAN_INPUT_CHANNEL, + START_PICK_CHANNEL, +} from "./GuestProtocol.ts"; +import { isPreviewAnnotationPayload } from "./PickedElementPayload.ts"; +import { playwrightInjectedRuntimeInstallExpression } from "./PlaywrightInjectedRuntime.ts"; export type PreviewNavStatus = | { kind: "Idle" } @@ -238,6 +237,7 @@ interface ManagedListeners { navigate: () => void; failed: (event: Event, code: number, description: string) => void; humanInput: (_event: unknown, signal?: unknown) => void; + beforeInput: (event: Electron.Event, input: Electron.Input) => void; } interface PickSession { @@ -249,6 +249,11 @@ interface BrowserControlSession { readonly webContentsId: number; tail: Promise<void>; initialized: Promise<void>; + readonly onMessage: ( + event: Electron.Event, + method: string, + params: Record<string, unknown>, + ) => void; } interface BrowserDiagnostics { @@ -318,13 +323,12 @@ const inputSignalsMatch = (left: PreviewInputSignal, right: PreviewInputSignal): ); }; -export class PreviewViewManager { +class PreviewViewManager { private annotationTheme = DEFAULT_ANNOTATION_THEME; private artifactDirectory: string | null = null; private mainWindow: BrowserWindow | null = null; private readonly tabs = new Map<string, PreviewTabState>(); private readonly attached = new Map<number, ManagedListeners>(); - private readonly browserSessions = new Map<string, Session>(); private readonly listeners = new Set<Listener>(); private readonly pointerEventListeners = new Set<PointerEventListener>(); private readonly recordingFrameListeners = new Set<RecordingFrameListener>(); @@ -394,43 +398,6 @@ export class PreviewViewManager { this.mainWindow = window; } - getBrowserPartition(scope = "shared"): string { - const digest = createHash("sha256").update(scope).digest("hex").slice(0, 20); - return `${PREVIEW_PARTITION_PREFIX}${digest}`; - } - - isBrowserPartition(partition: string): boolean { - return partition.startsWith(PREVIEW_PARTITION_PREFIX); - } - - /** - * Returns the canonical `<webview webpreferences="...">` string. Renderer - * fetches this via the desktop bridge so the security posture for guest - * surfaces lives in exactly one place (here) and any future guest webview - * (docs panel, OAuth popup, etc.) can opt in by calling the same getter. - */ - getWebviewPreferences(): string { - return PREVIEW_WEBVIEW_PREFERENCES; - } - - getBrowserSession(scope = "shared"): Session { - const partition = this.getBrowserPartition(scope); - const existing = this.browserSessions.get(partition); - if (existing) return existing; - const sess = session.fromPartition(partition); - 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.browserSessions.set(partition, sess); - return sess; - } - createTab(tabId: string): PreviewTabState { const existing = this.tabs.get(tabId); if (existing) return existing; @@ -574,25 +541,6 @@ export class PreviewViewManager { wc.openDevTools({ mode: "detach" }); } - /** - * Drop cookies/localStorage/etc. for the preview partition. Affects every - * preview tab since they all share `persist:t3code-preview`. - */ - async clearCookies(): Promise<void> { - await Promise.all( - [...this.browserSessions.values()].map((sess) => - sess.clearStorageData({ - storages: ["cookies", "localstorage", "indexdb", "websql", "serviceworkers"], - }), - ), - ); - } - - /** Drop the HTTP cache for the preview partition. */ - async clearCache(): Promise<void> { - await Promise.all([...this.browserSessions.values()].map((sess) => sess.clearCache())); - } - /** * Activate annotation mode for `tabId`. Resolves after the guest submits a * multi-target annotation and the desktop process captures its screenshot, @@ -1289,20 +1237,15 @@ export class PreviewViewManager { "Preview control cannot attach because another debugger owns this page.", ); } - const control: BrowserControlSession = { - webContentsId: wc.id, - tail: Promise.resolve(), - initialized: Promise.resolve(), - }; const diagnostics: BrowserDiagnostics = { consoleEntries: [], networkEntries: [], requests: new Map(), }; this.diagnostics.set(wc.id, diagnostics); - wc.debugger.on("message", (_event, method, params) => { + const onMessage: BrowserControlSession["onMessage"] = (_event, method, params) => { if (method === "Page.screencastFrame") { - const frame = params as Record<string, unknown>; + const frame = params; const sessionId = frame["sessionId"]; if (typeof sessionId === "number") { void wc.debugger @@ -1325,8 +1268,15 @@ export class PreviewViewManager { for (const listener of this.recordingFrameListeners) listener(payload); } } - this.captureDiagnosticMessage(diagnostics, method, params as Record<string, unknown>); - }); + this.captureDiagnosticMessage(diagnostics, method, params); + }; + const control: BrowserControlSession = { + webContentsId: wc.id, + tail: Promise.resolve(), + initialized: Promise.resolve(), + onMessage, + }; + wc.debugger.on("message", onMessage); control.initialized = (async () => { wc.debugger.attach("1.3"); await Promise.all([ @@ -1347,10 +1297,15 @@ export class PreviewViewManager { } private detachControlSession(webContentsId: number): void { + const control = this.controlSessions.get(webContentsId); this.controlSessions.delete(webContentsId); this.diagnostics.delete(webContentsId); const wc = webContents.fromId(webContentsId); - if (!wc || wc.isDestroyed() || !wc.debugger.isAttached()) return; + if (!wc || wc.isDestroyed()) return; + if (control) { + wc.debugger.off("message", control.onMessage); + } + if (!wc.debugger.isAttached()) return; try { wc.debugger.detach(); } catch { @@ -1617,7 +1572,7 @@ export class PreviewViewManager { // Forward app-level shortcuts to the main window so mod+shift+J etc. // still toggles the preview panel even when the webview has focus. - wc.on("before-input-event", (event, input) => { + const beforeInput = (event: Electron.Event, input: Electron.Input): void => { if (this.isAppShortcut(input) && this.mainWindow && !this.mainWindow.isDestroyed()) { event.preventDefault(); this.mainWindow.webContents.sendInputEvent({ @@ -1631,9 +1586,10 @@ export class PreviewViewManager { ], }); } - }); + }; + wc.on("before-input-event", beforeInput); - this.attached.set(wc.id, { navigate: sync, failed, humanInput }); + this.attached.set(wc.id, { navigate: sync, failed, humanInput, beforeInput }); } private detachListeners(webContentsId: number): void { @@ -1648,6 +1604,7 @@ export class PreviewViewManager { wc.off("did-start-loading", handlers.navigate); wc.off("did-stop-loading", handlers.navigate); wc.off("did-fail-load", handlers.failed as never); + wc.off("before-input-event", handlers.beforeInput); wc.ipc.off(HUMAN_INPUT_CHANNEL, handlers.humanInput); } @@ -1738,4 +1695,255 @@ export class PreviewWebviewNotInitializedError extends Error { } } -export const previewViewManager = new PreviewViewManager(); +export class PreviewManagerError extends Data.TaggedError("PreviewManagerError")<{ + readonly operation: string; + readonly cause: unknown; +}> { + override get message() { + return `Desktop preview operation failed: ${this.operation}`; + } +} + +export interface PreviewManagerShape { + readonly setMainWindow: (window: BrowserWindow) => Effect.Effect<void, PreviewManagerError>; + readonly getBrowserSession: (scope?: string) => Effect.Effect<Session, PreviewManagerError>; + readonly isBrowserPartition: (partition: string) => boolean; + readonly createTab: (tabId: string) => Effect.Effect<PreviewTabState, PreviewManagerError>; + readonly closeTab: (tabId: string) => Effect.Effect<void, PreviewManagerError>; + readonly registerWebview: ( + tabId: string, + webContentsId: number, + ) => Effect.Effect<void, PreviewManagerError>; + readonly navigate: (tabId: string, url: string) => Effect.Effect<void, PreviewManagerError>; + readonly goBack: (tabId: string) => Effect.Effect<void, PreviewManagerError>; + readonly goForward: (tabId: string) => Effect.Effect<void, PreviewManagerError>; + readonly refresh: (tabId: string) => Effect.Effect<void, PreviewManagerError>; + readonly zoomIn: (tabId: string) => Effect.Effect<void, PreviewManagerError>; + readonly zoomOut: (tabId: string) => Effect.Effect<void, PreviewManagerError>; + readonly resetZoom: (tabId: string) => Effect.Effect<void, PreviewManagerError>; + readonly hardReload: (tabId: string) => Effect.Effect<void, PreviewManagerError>; + readonly openDevTools: (tabId: string) => Effect.Effect<void, PreviewManagerError>; + readonly clearCookies: () => Effect.Effect<void, PreviewManagerError>; + readonly clearCache: () => Effect.Effect<void, PreviewManagerError>; + readonly getBrowserPartition: (scope?: string) => string; + readonly setAnnotationTheme: ( + theme: DesktopPreviewAnnotationTheme, + ) => Effect.Effect<void, PreviewManagerError>; + readonly pickElement: ( + tabId: string, + ) => Effect.Effect<PreviewAnnotationPayload | null, PreviewManagerError>; + readonly cancelPickElement: (tabId: string) => Effect.Effect<void, PreviewManagerError>; + readonly captureScreenshot: ( + tabId: string, + ) => Effect.Effect<DesktopPreviewScreenshotArtifact, PreviewManagerError>; + readonly revealArtifact: (path: string) => Effect.Effect<void, PreviewManagerError>; + readonly copyArtifactToClipboard: (path: string) => Effect.Effect<void, PreviewManagerError>; + readonly startRecording: (tabId: string) => Effect.Effect<void, PreviewManagerError>; + readonly stopRecording: (tabId: string) => Effect.Effect<void, PreviewManagerError>; + readonly saveRecording: ( + tabId: string, + mimeType: string, + data: Uint8Array, + ) => Effect.Effect<DesktopPreviewRecordingArtifact, PreviewManagerError>; + readonly automationStatus: ( + tabId: string, + ) => Effect.Effect<PreviewAutomationStatus, PreviewManagerError>; + readonly automationSnapshot: ( + tabId: string, + ) => Effect.Effect<PreviewAutomationSnapshot, PreviewManagerError>; + readonly automationClick: ( + tabId: string, + input: PreviewAutomationClickInput, + ) => Effect.Effect<void, PreviewManagerError>; + readonly automationType: ( + tabId: string, + input: PreviewAutomationTypeInput, + ) => Effect.Effect<void, PreviewManagerError>; + readonly automationPress: ( + tabId: string, + input: PreviewAutomationPressInput, + ) => Effect.Effect<void, PreviewManagerError>; + readonly automationScroll: ( + tabId: string, + input: PreviewAutomationScrollInput, + ) => Effect.Effect<void, PreviewManagerError>; + readonly automationEvaluate: ( + tabId: string, + input: PreviewAutomationEvaluateInput, + ) => Effect.Effect<unknown, PreviewManagerError>; + readonly automationWaitFor: ( + tabId: string, + input: PreviewAutomationWaitForInput, + ) => Effect.Effect<void, PreviewManagerError>; + readonly subscribeStateChanges: (listener: Listener) => Effect.Effect<void, never, Scope.Scope>; + readonly subscribePointerEvents: ( + listener: PointerEventListener, + ) => Effect.Effect<void, never, Scope.Scope>; + readonly subscribeRecordingFrames: ( + listener: RecordingFrameListener, + ) => Effect.Effect<void, never, Scope.Scope>; +} + +export class PreviewManager extends Context.Service<PreviewManager, PreviewManagerShape>()( + "@t3tools/desktop/preview/Manager/PreviewManager", +) {} + +const make = Effect.fn("PreviewManager.make")(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const browserSession = yield* BrowserSession.BrowserSession; + const manager = new PreviewViewManager(); + manager.configureArtifactDirectory(environment.browserArtifactsDir); + yield* Effect.addFinalizer(() => Effect.sync(() => manager.destroy())); + + const attempt = <A>( + operation: string, + evaluate: () => A, + ): Effect.Effect<A, PreviewManagerError> => + Effect.try({ + try: evaluate, + catch: (cause) => new PreviewManagerError({ operation, cause }), + }); + const attemptPromise = <A>( + operation: string, + evaluate: () => Promise<A>, + ): Effect.Effect<A, PreviewManagerError> => + Effect.tryPromise({ + try: evaluate, + catch: (cause) => new PreviewManagerError({ operation, cause }), + }); + const browserSessionEffect = <A>( + operation: string, + effect: Effect.Effect<A, BrowserSession.BrowserSessionError>, + ): Effect.Effect<A, PreviewManagerError> => + effect.pipe(Effect.mapError((cause) => new PreviewManagerError({ operation, cause }))); + + return PreviewManager.of({ + setMainWindow: Effect.fn("PreviewManager.setMainWindow")(function* (window) { + yield* attempt("setMainWindow", () => manager.setMainWindow(window)); + }), + getBrowserSession: Effect.fn("PreviewManager.getBrowserSession")(function* (scope) { + return yield* browserSessionEffect("getBrowserSession", browserSession.getSession(scope)); + }), + isBrowserPartition: browserSession.isPartition, + createTab: Effect.fn("PreviewManager.createTab")(function* (tabId) { + return yield* attempt("createTab", () => manager.createTab(tabId)); + }), + closeTab: Effect.fn("PreviewManager.closeTab")(function* (tabId) { + yield* attempt("closeTab", () => manager.closeTab(tabId)); + }), + registerWebview: Effect.fn("PreviewManager.registerWebview")(function* (tabId, webContentsId) { + yield* attempt("registerWebview", () => manager.registerWebview(tabId, webContentsId)); + }), + navigate: Effect.fn("PreviewManager.navigate")(function* (tabId, url) { + yield* attemptPromise("navigate", () => manager.navigate(tabId, url)); + }), + goBack: Effect.fn("PreviewManager.goBack")(function* (tabId) { + yield* attempt("goBack", () => manager.goBack(tabId)); + }), + goForward: Effect.fn("PreviewManager.goForward")(function* (tabId) { + yield* attempt("goForward", () => manager.goForward(tabId)); + }), + refresh: Effect.fn("PreviewManager.refresh")(function* (tabId) { + yield* attempt("refresh", () => manager.refresh(tabId)); + }), + zoomIn: Effect.fn("PreviewManager.zoomIn")(function* (tabId) { + yield* attempt("zoomIn", () => manager.zoomIn(tabId)); + }), + zoomOut: Effect.fn("PreviewManager.zoomOut")(function* (tabId) { + yield* attempt("zoomOut", () => manager.zoomOut(tabId)); + }), + resetZoom: Effect.fn("PreviewManager.resetZoom")(function* (tabId) { + yield* attempt("resetZoom", () => manager.resetZoom(tabId)); + }), + hardReload: Effect.fn("PreviewManager.hardReload")(function* (tabId) { + yield* attempt("hardReload", () => manager.hardReload(tabId)); + }), + openDevTools: Effect.fn("PreviewManager.openDevTools")(function* (tabId) { + yield* attempt("openDevTools", () => manager.openDevTools(tabId)); + }), + clearCookies: Effect.fn("PreviewManager.clearCookies")(function* () { + yield* browserSessionEffect("clearCookies", browserSession.clearCookies()); + }), + clearCache: Effect.fn("PreviewManager.clearCache")(function* () { + yield* browserSessionEffect("clearCache", browserSession.clearCache()); + }), + getBrowserPartition: browserSession.getPartition, + setAnnotationTheme: Effect.fn("PreviewManager.setAnnotationTheme")(function* (theme) { + yield* attempt("setAnnotationTheme", () => manager.setAnnotationTheme(theme)); + }), + pickElement: Effect.fn("PreviewManager.pickElement")(function* (tabId) { + return yield* attemptPromise("pickElement", () => manager.pickElement(tabId)); + }), + cancelPickElement: Effect.fn("PreviewManager.cancelPickElement")(function* (tabId) { + yield* attempt("cancelPickElement", () => manager.cancelPickElement(tabId)); + }), + captureScreenshot: Effect.fn("PreviewManager.captureScreenshot")(function* (tabId) { + return yield* attemptPromise("captureScreenshot", () => manager.captureScreenshot(tabId)); + }), + revealArtifact: Effect.fn("PreviewManager.revealArtifact")(function* (path) { + yield* attempt("revealArtifact", () => manager.revealArtifact(path)); + }), + copyArtifactToClipboard: Effect.fn("PreviewManager.copyArtifactToClipboard")(function* (path) { + yield* attempt("copyArtifactToClipboard", () => manager.copyArtifactToClipboard(path)); + }), + startRecording: Effect.fn("PreviewManager.startRecording")(function* (tabId) { + yield* attemptPromise("startRecording", () => manager.startRecording(tabId)); + }), + stopRecording: Effect.fn("PreviewManager.stopRecording")(function* (tabId) { + yield* attemptPromise("stopRecording", () => manager.stopRecording(tabId)); + }), + saveRecording: Effect.fn("PreviewManager.saveRecording")(function* (tabId, mimeType, data) { + return yield* attemptPromise("saveRecording", () => + manager.saveRecording(tabId, mimeType, data), + ); + }), + automationStatus: Effect.fn("PreviewManager.automationStatus")(function* (tabId) { + return yield* attempt("automationStatus", () => manager.automationStatus(tabId)); + }), + automationSnapshot: Effect.fn("PreviewManager.automationSnapshot")(function* (tabId) { + return yield* attemptPromise("automationSnapshot", () => manager.automationSnapshot(tabId)); + }), + automationClick: Effect.fn("PreviewManager.automationClick")(function* (tabId, input) { + yield* attemptPromise("automationClick", () => manager.automationClick(tabId, input)); + }), + automationType: Effect.fn("PreviewManager.automationType")(function* (tabId, input) { + yield* attemptPromise("automationType", () => manager.automationType(tabId, input)); + }), + automationPress: Effect.fn("PreviewManager.automationPress")(function* (tabId, input) { + yield* attemptPromise("automationPress", () => manager.automationPress(tabId, input)); + }), + automationScroll: Effect.fn("PreviewManager.automationScroll")(function* (tabId, input) { + yield* attemptPromise("automationScroll", () => manager.automationScroll(tabId, input)); + }), + automationEvaluate: Effect.fn("PreviewManager.automationEvaluate")(function* (tabId, input) { + return yield* attemptPromise("automationEvaluate", () => + manager.automationEvaluate(tabId, input), + ); + }), + automationWaitFor: Effect.fn("PreviewManager.automationWaitFor")(function* (tabId, input) { + yield* attemptPromise("automationWaitFor", () => manager.automationWaitFor(tabId, input)); + }), + subscribeStateChanges: (listener) => + Effect.acquireRelease( + Effect.sync(() => manager.onStateChange(listener)), + (unsubscribe) => Effect.sync(unsubscribe), + ).pipe(Effect.asVoid), + subscribePointerEvents: (listener) => + Effect.acquireRelease( + Effect.sync(() => manager.onPointerEvent(listener)), + (unsubscribe) => Effect.sync(unsubscribe), + ).pipe(Effect.asVoid), + subscribeRecordingFrames: (listener) => + Effect.acquireRelease( + Effect.sync(() => manager.onRecordingFrame(listener)), + (unsubscribe) => Effect.sync(unsubscribe), + ).pipe(Effect.asVoid), + }); +}); + +export const layer = Layer.effect(PreviewManager, make()); + +/** Exposed for tests. */ +export const __testing = { + PreviewViewManager, +}; diff --git a/apps/desktop/src/preview-pick-label-position.ts b/apps/desktop/src/preview/PickLabelPosition.ts similarity index 95% rename from apps/desktop/src/preview-pick-label-position.ts rename to apps/desktop/src/preview/PickLabelPosition.ts index e07f64f13d5..cf7f3c811f8 100644 --- a/apps/desktop/src/preview-pick-label-position.ts +++ b/apps/desktop/src/preview/PickLabelPosition.ts @@ -2,7 +2,7 @@ * Pure clamp/flip math for the floating label that follows the cursor while * the user is picking an element in the in-app browser. Lives in its own * electron-free module so the geometry can be unit-tested without spinning - * up an Electron preload context (`preview-pick-preload.ts` itself imports + * up an Electron preload context (`PickPreload.ts` itself imports * `electron` and `react-grab/primitives`, which can't load under vitest). * * - Horizontally pins the label to `targetLeft`, clamped into diff --git a/apps/desktop/src/preview-pick-preload.test.ts b/apps/desktop/src/preview/PickPreload.test.ts similarity index 97% rename from apps/desktop/src/preview-pick-preload.test.ts rename to apps/desktop/src/preview/PickPreload.test.ts index 9f239e1d2e8..5696fe50812 100644 --- a/apps/desktop/src/preview-pick-preload.test.ts +++ b/apps/desktop/src/preview/PickPreload.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { computeLabelPosition } from "./preview-pick-label-position.ts"; +import { computeLabelPosition } from "./PickLabelPosition.ts"; const VIEWPORT = { viewportWidth: 1280, viewportHeight: 800 }; diff --git a/apps/desktop/src/preview/PickPreload.ts b/apps/desktop/src/preview/PickPreload.ts new file mode 100644 index 00000000000..e8c31a8e1e5 --- /dev/null +++ b/apps/desktop/src/preview/PickPreload.ts @@ -0,0 +1,1263 @@ +// @effect-diagnostics globalDate:off +import { ipcRenderer } from "electron"; +import { getElementContext } from "react-grab/primitives"; +import type { + DesktopPreviewAnnotationTheme, + PickedElementPayload, + PickedElementStackFrame, + PreviewAnnotationPayload, + PreviewAnnotationPoint, + PreviewAnnotationRect, + PreviewAnnotationRegionTarget, + PreviewAnnotationStrokeTarget, + PreviewAnnotationStyleChange, +} from "@t3tools/contracts"; + +import { previewAnnotationStyles } from "./AnnotationStyles.generated.ts"; +import { + ANNOTATION_CAPTURED_CHANNEL, + ANNOTATION_THEME_CHANNEL, + CANCEL_PICK_CHANNEL, + ELEMENT_PICKED_CHANNEL, + HUMAN_INPUT_CHANNEL, + START_PICK_CHANNEL, +} from "./GuestProtocol.ts"; +const OVERLAY_ATTRIBUTE = "data-t3code-annotation-ui"; +const Z_INDEX_OVERLAY = 2147483646; +const PRIMARY = "var(--t3-primary)"; +const PRIMARY_FILL = "color-mix(in srgb, var(--t3-primary) 10%, transparent)"; +const MAX_MARQUEE_ELEMENTS = 20; +const CONTENT_LAYER_Z_INDEX = 1; +const CHROME_LAYER_Z_INDEX = 10; + +type AnnotationTool = "select" | "marquee" | "draw" | "erase"; + +interface SelectedElement { + id: string; + element: Element; + outline: HTMLDivElement; + label: HTMLDivElement; + baselineStyles: Map<string, string>; +} + +interface AnnotationSession { + teardown: (notifyMain: boolean) => void; + applyTheme: (theme: DesktopPreviewAnnotationTheme) => void; +} + +let activeSession: AnnotationSession | null = null; +let idSequence = 0; +let annotationTheme: DesktopPreviewAnnotationTheme | null = null; + +const applyAnnotationTheme = ( + host: HTMLElement, + theme: DesktopPreviewAnnotationTheme | null, +): void => { + if (!theme) return; + host.style.colorScheme = theme.colorScheme; + const variables = { + "--t3-radius": theme.radius, + "--t3-background": theme.background, + "--t3-foreground": theme.foreground, + "--t3-popover": theme.popover, + "--t3-popover-foreground": theme.popoverForeground, + "--t3-primary": theme.primary, + "--t3-primary-foreground": theme.primaryForeground, + "--t3-muted": theme.muted, + "--t3-muted-foreground": theme.mutedForeground, + "--t3-accent": theme.accent, + "--t3-accent-foreground": theme.accentForeground, + "--t3-border": theme.border, + "--t3-input": theme.input, + "--t3-ring": theme.ring, + "--t3-font-sans": theme.fontSans, + "--t3-font-mono": theme.fontMono, + }; + for (const [name, value] of Object.entries(variables)) { + host.style.setProperty(name, value); + } +}; + +const reportHumanPointerInput = (event: PointerEvent): void => { + if (!event.isTrusted) return; + ipcRenderer.send(HUMAN_INPUT_CHANNEL, { + kind: "pointer", + x: event.clientX, + y: event.clientY, + button: event.button, + }); +}; + +const reportHumanKeyInput = (event: KeyboardEvent): void => { + if (!event.isTrusted) return; + ipcRenderer.send(HUMAN_INPUT_CHANNEL, { + kind: "key", + key: event.key, + code: event.code, + }); +}; + +window.addEventListener("pointerdown", reportHumanPointerInput, true); +window.addEventListener("keydown", reportHumanKeyInput, true); + +const nextId = (prefix: string): string => { + idSequence += 1; + return `${prefix}_${idSequence.toString(36)}`; +}; + +const rectFromDomRect = (rect: DOMRect): PreviewAnnotationRect => ({ + x: rect.left, + y: rect.top, + width: rect.width, + height: rect.height, +}); + +const normalizeRect = ( + startX: number, + startY: number, + endX: number, + endY: number, +): PreviewAnnotationRect => ({ + x: Math.min(startX, endX), + y: Math.min(startY, endY), + width: Math.abs(endX - startX), + height: Math.abs(endY - startY), +}); + +const isUsableRect = (rect: PreviewAnnotationRect): boolean => rect.width >= 3 && rect.height >= 3; + +function unionRects( + rects: ReadonlyArray<PreviewAnnotationRect>, + padding = 20, +): PreviewAnnotationRect | null { + if (rects.length === 0) return null; + const left = Math.min(...rects.map((rect) => rect.x)); + const top = Math.min(...rects.map((rect) => rect.y)); + const right = Math.max(...rects.map((rect) => rect.x + rect.width)); + const bottom = Math.max(...rects.map((rect) => rect.y + rect.height)); + const x = Math.max(0, left - padding); + const y = Math.max(0, top - padding); + const maxWidth = Math.max(1, window.innerWidth - x); + const maxHeight = Math.max(1, window.innerHeight - y); + return { + x, + y, + width: Math.min(maxWidth, right - left + padding * 2), + height: Math.min(maxHeight, bottom - top + padding * 2), + }; +} + +function isAnnotationNode(element: Element): boolean { + return element instanceof Element && element.closest(`[${OVERLAY_ATTRIBUTE}]`) !== null; +} + +function pickFromPoint(clientX: number, clientY: number): Element | null { + for (const candidate of document.elementsFromPoint(clientX, clientY)) { + if (!(candidate instanceof Element)) continue; + if (isAnnotationNode(candidate)) continue; + if (candidate === document.documentElement || candidate === document.body) continue; + return candidate; + } + return null; +} + +function describeRawElement(element: Element): string { + const tag = element.tagName.toLowerCase(); + const id = element.id ? `#${element.id}` : ""; + const classes = + element instanceof HTMLElement && typeof element.className === "string" + ? element.className + .trim() + .split(/\s+/) + .filter(Boolean) + .slice(0, 2) + .map((name) => `.${name}`) + .join("") + : ""; + return `${tag}${id}${classes}`; +} + +function createBox(color: string, fill: string): HTMLDivElement { + const node = document.createElement("div"); + node.setAttribute(OVERLAY_ATTRIBUTE, ""); + node.style.cssText = [ + "position:fixed", + "pointer-events:none", + `border:2px solid ${color}`, + `background:${fill}`, + "border-radius:3px", + "box-sizing:border-box", + "display:none", + `z-index:${CONTENT_LAYER_Z_INDEX}`, + ].join(";"); + return node; +} + +function positionBox(node: HTMLElement, rect: PreviewAnnotationRect): void { + node.style.display = "block"; + node.style.transform = `translate(${rect.x}px, ${rect.y}px)`; + node.style.width = `${rect.width}px`; + node.style.height = `${rect.height}px`; +} + +function createLabel(): HTMLDivElement { + const label = document.createElement("div"); + label.setAttribute(OVERLAY_ATTRIBUTE, ""); + label.className = + "fixed z-1 max-w-70 overflow-hidden rounded-md bg-primary px-2 py-1 font-sans text-xs font-semibold text-primary-foreground shadow-md"; + label.style.cssText = [ + "position:fixed", + "pointer-events:none", + "white-space:nowrap", + "text-overflow:ellipsis", + `z-index:${CONTENT_LAYER_Z_INDEX}`, + ].join(";"); + return label; +} + +function updateSelectedVisual(target: SelectedElement): void { + if (!target.element.isConnected) { + target.outline.style.display = "none"; + target.label.style.display = "none"; + return; + } + const rect = target.element.getBoundingClientRect(); + positionBox(target.outline, rectFromDomRect(rect)); + target.label.textContent = describeRawElement(target.element); + target.label.style.display = "block"; + target.label.style.transform = `translate(${Math.max(4, rect.left)}px, ${Math.max(4, rect.top - 22)}px)`; +} + +function toStackFrame(frame: { + functionName?: string; + fileName?: string; + lineNumber?: number; + columnNumber?: number; +}): PickedElementStackFrame { + return { + functionName: frame.functionName ?? null, + fileName: frame.fileName ?? null, + lineNumber: frame.lineNumber ?? null, + columnNumber: frame.columnNumber ?? null, + }; +} + +async function captureElement(element: Element): Promise<PickedElementPayload | null> { + try { + const context = await getElementContext(element); + const stack = (context.stack ?? []).map(toStackFrame); + return { + pageUrl: location.href, + pageTitle: document.title?.trim() || null, + tagName: element.tagName.toLowerCase(), + selector: context.selector, + htmlPreview: context.htmlPreview ?? "", + componentName: context.componentName, + source: stack[0] ?? null, + stack, + styles: context.styles ?? "", + pickedAt: new Date().toISOString(), + }; + } catch { + return null; + } +} + +function createButton(label: string, title: string): HTMLButtonElement { + const button = document.createElement("button"); + button.type = "button"; + button.textContent = label; + button.title = title; + button.className = + "inline-flex h-7 cursor-pointer items-center justify-center rounded-md border border-transparent px-2 font-sans text-xs font-medium text-foreground outline-none hover:bg-accent disabled:pointer-events-none disabled:opacity-60"; + return button; +} + +function styleControl(input: HTMLInputElement | HTMLSelectElement): void { + input.setAttribute("aria-label", input.getAttribute("aria-label") ?? "Style value"); + input.className = + "h-7 min-w-0 w-full appearance-none rounded-md border border-input bg-background px-2 font-mono text-xs text-foreground shadow-xs outline-none"; +} + +function createUnitControl(input: HTMLInputElement): HTMLElement { + const wrapper = document.createElement("div"); + wrapper.style.cssText = "position:relative;min-width:0"; + const unit = document.createElement("span"); + unit.textContent = input.dataset.unit ?? ""; + unit.className = + "pointer-events-none absolute top-1/2 right-2 -translate-y-1/2 font-mono text-xs text-muted-foreground"; + wrapper.append(input, unit); + return wrapper; +} + +function createField( + labelText: string, + input: HTMLInputElement | HTMLSelectElement, +): HTMLLabelElement { + const label = document.createElement("label"); + label.className = + "grid min-h-7 grid-cols-[82px_minmax(0,1fr)] items-center gap-2 font-sans text-xs font-medium text-muted-foreground"; + const text = document.createElement("span"); + text.textContent = labelText; + styleControl(input); + label.append( + text, + input instanceof HTMLInputElement && input.dataset.unit ? createUnitControl(input) : input, + ); + return label; +} + +function createStyleSection(): HTMLElement { + const section = document.createElement("section"); + section.className = "grid gap-1 border-t border-border py-2"; + return section; +} + +function createUnitInput(unit: string, placeholder = "0"): HTMLInputElement { + const input = document.createElement("input"); + input.type = "number"; + input.placeholder = placeholder; + input.style.paddingRight = "30px"; + input.dataset.unit = unit; + return input; +} + +function pathFromPoints(points: ReadonlyArray<PreviewAnnotationPoint>): string { + if (points.length === 0) return ""; + if (points.length === 1) return `M ${points[0]!.x} ${points[0]!.y} l 0.01 0.01`; + let path = `M ${points[0]!.x} ${points[0]!.y}`; + for (let index = 1; index < points.length - 1; index += 1) { + const current = points[index]!; + const next = points[index + 1]!; + path += ` Q ${current.x} ${current.y} ${(current.x + next.x) / 2} ${(current.y + next.y) / 2}`; + } + const last = points[points.length - 1]!; + path += ` L ${last.x} ${last.y}`; + return path; +} + +function strokeBounds( + points: ReadonlyArray<PreviewAnnotationPoint>, + width: number, +): PreviewAnnotationRect { + const xs = points.map((point) => point.x); + const ys = points.map((point) => point.y); + const padding = width + 3; + const left = Math.min(...xs) - padding; + const top = Math.min(...ys) - padding; + const right = Math.max(...xs) + padding; + const bottom = Math.max(...ys) + padding; + return { x: left, y: top, width: right - left, height: bottom - top }; +} + +function startAnnotation(): void { + activeSession?.teardown(false); + let finished = false; + const host = document.createElement("div"); + host.setAttribute(OVERLAY_ATTRIBUTE, ""); + host.style.cssText = `position:fixed;inset:0;z-index:${Z_INDEX_OVERLAY};pointer-events:none`; + applyAnnotationTheme(host, annotationTheme); + const shadowRoot = host.attachShadow({ mode: "closed" }); + const themeStyle = document.createElement("style"); + themeStyle.textContent = previewAnnotationStyles; + shadowRoot.appendChild(themeStyle); + + const root = document.createElement("div"); + root.setAttribute(OVERLAY_ATTRIBUTE, ""); + root.className = "fixed inset-0 font-sans text-foreground"; + root.style.cssText = "pointer-events:none"; + const cursorStyle = document.createElement("style"); + cursorStyle.setAttribute(OVERLAY_ATTRIBUTE, ""); + cursorStyle.textContent = `html[data-t3code-annotation-tool] body, html[data-t3code-annotation-tool] body * { cursor: crosshair !important; } [${OVERLAY_ATTRIBUTE}], [${OVERLAY_ATTRIBUTE}] * { cursor: default !important; } [${OVERLAY_ATTRIBUTE}] input[type=number]::-webkit-inner-spin-button, [${OVERLAY_ATTRIBUTE}] input[type=number]::-webkit-outer-spin-button { appearance:none; margin:0; }`; + document.documentElement.appendChild(cursorStyle); + shadowRoot.appendChild(root); + + const hoverOutline = createBox(PRIMARY, PRIMARY_FILL); + const marqueeBox = createBox(PRIMARY, PRIMARY_FILL); + root.append(hoverOutline, marqueeBox); + + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.setAttribute(OVERLAY_ATTRIBUTE, ""); + svg.setAttribute("width", "100%"); + svg.setAttribute("height", "100%"); + svg.setAttribute("viewBox", `0 0 ${window.innerWidth} ${window.innerHeight}`); + svg.style.cssText = "position:fixed;inset:0;overflow:visible;pointer-events:none"; + svg.style.zIndex = String(CONTENT_LAYER_Z_INDEX); + root.appendChild(svg); + + const toolbar = document.createElement("div"); + toolbar.setAttribute(OVERLAY_ATTRIBUTE, ""); + toolbar.className = + "pointer-events-auto fixed top-2.5 left-1/2 flex -translate-x-1/2 gap-0.5 rounded-lg border border-border bg-popover/95 p-1 text-popover-foreground shadow-lg backdrop-blur-xl"; + toolbar.style.zIndex = String(CHROME_LAYER_Z_INDEX); + root.appendChild(toolbar); + + const editor = document.createElement("div"); + editor.setAttribute(OVERLAY_ATTRIBUTE, ""); + editor.className = + "pointer-events-auto fixed hidden max-h-[calc(100vh-16px)] w-[min(360px,calc(100vw-16px))] flex-col overflow-hidden rounded-xl border border-border bg-popover/96 text-popover-foreground shadow-2xl backdrop-blur-xl"; + editor.style.zIndex = String(CHROME_LAYER_Z_INDEX); + root.appendChild(editor); + + const composerRow = document.createElement("div"); + composerRow.className = "flex items-start gap-2 p-2"; + + const adjust = createButton("", "Expand annotation editor"); + adjust.setAttribute("aria-label", "Expand annotation editor"); + adjust.setAttribute("aria-expanded", "false"); + adjust.className += + " h-8 w-8 shrink-0 bg-muted p-0 text-muted-foreground hover:bg-accent hover:text-accent-foreground"; + adjust.innerHTML = + '<svg viewBox="0 0 20 20" width="15" height="15" aria-hidden="true"><path d="M4 5h12M4 10h12M4 15h12M7 3v4M13 8v4M9 13v4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>'; + composerRow.appendChild(adjust); + + const comment = document.createElement("textarea"); + comment.placeholder = "Describe the change…"; + comment.rows = 1; + comment.className = + "min-h-8 max-h-24 min-w-0 flex-1 resize-none overflow-y-hidden border-0 border-b border-b-transparent bg-transparent px-0 py-1.5 font-sans text-sm leading-5 text-foreground outline-none ring-0 placeholder:text-muted-foreground focus:border-b-primary focus:outline-none focus:ring-0"; + composerRow.appendChild(comment); + + const dragHandle = document.createElement("button"); + dragHandle.type = "button"; + dragHandle.textContent = "⠿"; + dragHandle.title = "Drag annotation editor"; + dragHandle.className = + "hidden h-8 w-6 shrink-0 cursor-grab select-none border-0 bg-transparent p-0 font-sans text-lg font-bold leading-5 text-muted-foreground"; + composerRow.appendChild(dragHandle); + + const submit = createButton("Attach", "Attach annotation and screenshot"); + submit.className += + " h-8 shrink-0 border-primary bg-primary px-3 text-primary-foreground shadow-sm hover:bg-primary/90"; + composerRow.appendChild(submit); + editor.appendChild(composerRow); + + const stylePanel = document.createElement("div"); + stylePanel.className = + "hidden max-h-[min(176px,calc(100vh-180px))] overflow-auto border-t border-border bg-muted/40 px-3"; + editor.appendChild(stylePanel); + + const selected = new Map<Element, SelectedElement>(); + const regions: PreviewAnnotationRegionTarget[] = []; + const strokes: PreviewAnnotationStrokeTarget[] = []; + const styleChanges = new Map<string, PreviewAnnotationStyleChange>(); + const toolButtons = new Map<AnnotationTool, HTMLButtonElement>(); + let tool: AnnotationTool = "select"; + let dragStart: PreviewAnnotationPoint | null = null; + let activeStroke: { target: PreviewAnnotationStrokeTarget; path: SVGPathElement } | null = null; + let pendingCapture = false; + let editorExpanded = false; + let editorWasShown = false; + let editorPosition: { left: number; top: number } | null = null; + let editorDrag: { pointerId: number; offsetX: number; offsetY: number } | null = null; + let editorLayoutFrame: number | null = null; + + const resizeComment = (): void => { + const maxHeight = 96; + comment.style.height = "auto"; + const nextHeight = Math.min(comment.scrollHeight, maxHeight); + comment.style.height = `${nextHeight}px`; + comment.style.overflowY = comment.scrollHeight > maxHeight ? "auto" : "hidden"; + queueEditorLayout(); + }; + comment.addEventListener("input", resizeComment); + + const updateStatus = (): void => { + const hasTargets = selected.size > 0 || regions.length > 0 || strokes.length > 0; + editor.style.display = hasTargets ? "flex" : "none"; + submit.disabled = !hasTargets; + submit.style.opacity = hasTargets ? "1" : "0.45"; + adjust.disabled = !hasTargets; + stylePanel.style.display = editorExpanded && selected.size > 0 ? "grid" : "none"; + queueEditorLayout(); + if (hasTargets && !editorWasShown) { + editorWasShown = true; + window.setTimeout(() => comment.focus({ preventScroll: true }), 0); + } + }; + + const refreshToolButtons = (): void => { + for (const [candidate, button] of toolButtons) { + const active = candidate === tool; + button.classList.toggle("bg-primary/10", active); + button.classList.toggle("text-primary", active); + button.classList.toggle("text-foreground", !active); + } + if (tool !== "select") hoverOutline.style.display = "none"; + if (tool !== "marquee") marqueeBox.style.display = "none"; + document.documentElement.setAttribute("data-t3code-annotation-tool", tool); + }; + + const removeSelected = (target: SelectedElement): void => { + if (target.element instanceof HTMLElement || target.element instanceof SVGElement) { + for (const [property, baseline] of target.baselineStyles) { + if (baseline) target.element.style.setProperty(property, baseline); + else target.element.style.removeProperty(property); + } + } + selected.delete(target.element); + target.outline.remove(); + target.label.remove(); + for (const [key, change] of styleChanges) { + if (change.targetId === target.id) styleChanges.delete(key); + } + updateStatus(); + }; + + const addSelected = (element: Element): void => { + if (selected.has(element)) return; + const target: SelectedElement = { + id: nextId("element"), + element, + outline: createBox(PRIMARY, PRIMARY_FILL), + label: createLabel(), + baselineStyles: new Map(), + }; + selected.set(element, target); + root.append(target.outline, target.label); + updateSelectedVisual(target); + updateStatus(); + if (editorExpanded) { + stylePanel.style.display = "grid"; + syncStyleControls(); + } + }; + + const toggleSelected = (element: Element, additive: boolean): void => { + const existing = selected.get(element); + if (existing) { + removeSelected(existing); + return; + } + if (!additive) { + for (const target of Array.from(selected.values())) removeSelected(target); + } + addSelected(element); + }; + + const setStyleForSelected = (property: string, value: string): void => { + for (const target of selected.values()) { + if (!(target.element instanceof HTMLElement || target.element instanceof SVGElement)) + continue; + if (!target.baselineStyles.has(property)) { + target.baselineStyles.set(property, target.element.style.getPropertyValue(property)); + } + const key = `${target.id}:${property}`; + const previousValue = + styleChanges.get(key)?.previousValue ?? + getComputedStyle(target.element).getPropertyValue(property).trim(); + target.element.style.setProperty(property, value, "important"); + styleChanges.set(key, { + targetId: target.id, + selector: null, + property, + previousValue, + value, + }); + updateSelectedVisual(target); + } + }; + + const textSection = createStyleSection(); + const colorsSection = createStyleSection(); + const bordersSection = createStyleSection(); + const sizingSection = createStyleSection(); + stylePanel.append(textSection, colorsSection, bordersSection, sizingSection); + + const fontFamily = document.createElement("select"); + for (const value of ["inherit", "system-ui", "sans-serif", "serif", "monospace"]) { + const option = document.createElement("option"); + option.value = value; + option.textContent = value; + fontFamily.appendChild(option); + } + fontFamily.addEventListener("change", () => setStyleForSelected("font-family", fontFamily.value)); + textSection.appendChild(createField("Font", fontFamily)); + + const fontSize = createUnitInput("px", "16"); + fontSize.min = "1"; + fontSize.max = "300"; + fontSize.addEventListener("input", () => { + if (fontSize.value) setStyleForSelected("font-size", `${fontSize.value}px`); + }); + textSection.appendChild(createField("Font size", fontSize)); + + const fontWeight = document.createElement("select"); + for (const value of ["300", "400", "500", "600", "700", "800", "900"]) { + const option = document.createElement("option"); + option.value = value; + option.textContent = value; + fontWeight.appendChild(option); + } + fontWeight.addEventListener("change", () => setStyleForSelected("font-weight", fontWeight.value)); + textSection.appendChild(createField("Font weight", fontWeight)); + + const lineHeight = document.createElement("input"); + lineHeight.type = "text"; + lineHeight.placeholder = "normal / 1.4"; + lineHeight.addEventListener("change", () => { + if (lineHeight.value.trim()) setStyleForSelected("line-height", lineHeight.value.trim()); + }); + textSection.appendChild(createField("Line height", lineHeight)); + + const createColorRow = ( + labelText: string, + property: string, + section: HTMLElement, + ): { row: HTMLLabelElement; color: HTMLInputElement; text: HTMLInputElement } => { + const row = document.createElement("label"); + row.className = + "grid min-h-7 grid-cols-[82px_minmax(0,1fr)] items-center gap-2 font-sans text-xs font-medium text-muted-foreground"; + const label = document.createElement("span"); + label.textContent = labelText; + const control = document.createElement("div"); + control.className = + "grid h-7 grid-cols-[22px_minmax(0,1fr)] items-center gap-1 rounded-md border border-input bg-background px-1 shadow-xs"; + const color = document.createElement("input"); + color.type = "color"; + color.setAttribute("aria-label", labelText); + color.style.cssText = + "width:20px;height:20px;padding:0;border:0;border-radius:5px;overflow:hidden;background:transparent;cursor:pointer"; + const text = document.createElement("input"); + text.type = "text"; + text.setAttribute("aria-label", `${labelText} value`); + text.className = + "min-w-0 w-full border-0 bg-transparent font-mono text-xs text-foreground outline-none"; + color.addEventListener("input", () => { + text.value = color.value; + setStyleForSelected(property, color.value); + }); + text.addEventListener("change", () => { + const value = text.value.trim(); + if (!value) return; + setStyleForSelected(property, value); + if (/^#[0-9a-f]{6}$/i.test(value)) color.value = value; + }); + control.append(color, text); + row.append(label, control); + section.appendChild(row); + return { row, color, text }; + }; + + const textColor = createColorRow("Text color", "color", colorsSection); + const backgroundColor = createColorRow("Background", "background-color", colorsSection); + + const opacity = document.createElement("input"); + opacity.type = "range"; + opacity.min = "0"; + opacity.max = "1"; + opacity.step = "0.05"; + opacity.value = "1"; + opacity.style.accentColor = PRIMARY; + opacity.addEventListener("input", () => setStyleForSelected("opacity", opacity.value)); + colorsSection.appendChild(createField("Opacity", opacity)); + + const radius = createUnitInput("px", "0"); + radius.min = "0"; + radius.max = "300"; + radius.addEventListener("input", () => { + if (radius.value) setStyleForSelected("border-radius", `${radius.value}px`); + }); + bordersSection.appendChild(createField("Radius", radius)); + + const borderColor = createColorRow("Border color", "border-color", bordersSection); + + const borderWidth = createUnitInput("px", "0"); + borderWidth.min = "0"; + borderWidth.max = "100"; + borderWidth.addEventListener("input", () => { + if (borderWidth.value) { + setStyleForSelected("border-style", "solid"); + setStyleForSelected("border-width", `${borderWidth.value}px`); + } + }); + bordersSection.appendChild(createField("Border width", borderWidth)); + + const dimensions = document.createElement("div"); + dimensions.style.cssText = + "display:grid;grid-template-columns:82px minmax(0,1fr);gap:8px;align-items:center"; + const dimensionLabel = document.createElement("div"); + dimensionLabel.className = "grid gap-2 font-sans text-xs font-medium text-muted-foreground"; + dimensionLabel.innerHTML = "<span>Width</span><span>Height</span>"; + const dimensionControls = document.createElement("div"); + dimensionControls.style.cssText = "position:relative;display:grid;gap:3px;padding-left:22px"; + const widthInput = createUnitInput("px", "auto"); + const heightInput = createUnitInput("px", "auto"); + styleControl(widthInput); + styleControl(heightInput); + const aspectLock = createButton("", "Lock aspect ratio"); + aspectLock.setAttribute("aria-pressed", "true"); + aspectLock.style.cssText += + ";position:absolute;left:0;top:50%;transform:translateY(-50%);width:18px;height:38px;padding:0"; + aspectLock.className += " bg-primary/10 text-primary"; + dimensionControls.append( + createUnitControl(widthInput), + createUnitControl(heightInput), + aspectLock, + ); + dimensions.append(dimensionLabel, dimensionControls); + sizingSection.appendChild(dimensions); + + let aspectLocked = true; + let aspectRatio = 1; + const refreshAspectButton = (): void => { + aspectLock.innerHTML = aspectLocked + ? '<svg viewBox="0 0 20 20" width="14" height="14" aria-hidden="true"><path d="M8 6.5 9.5 5A3.5 3.5 0 0 1 14.5 10l-1.5 1.5M12 13.5 10.5 15A3.5 3.5 0 0 1 5.5 10L7 8.5M7.5 12.5l5-5" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>' + : '<svg viewBox="0 0 20 20" width="14" height="14" aria-hidden="true"><path d="m6 6 8 8M8 6.5 9.5 5A3.5 3.5 0 0 1 14 9M12 13.5 10.5 15A3.5 3.5 0 0 1 6 11" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>'; + aspectLock.setAttribute("aria-pressed", String(aspectLocked)); + aspectLock.classList.toggle("bg-primary/10", aspectLocked); + aspectLock.classList.toggle("text-primary", aspectLocked); + aspectLock.classList.toggle("bg-muted", !aspectLocked); + aspectLock.classList.toggle("text-muted-foreground", !aspectLocked); + }; + aspectLock.addEventListener("click", () => { + aspectLocked = !aspectLocked; + refreshAspectButton(); + }); + widthInput.addEventListener("input", () => { + const width = Number(widthInput.value); + if (!Number.isFinite(width) || width <= 0) return; + setStyleForSelected("width", `${width}px`); + if (aspectLocked && aspectRatio > 0) { + const height = Math.max(1, Math.round(width / aspectRatio)); + heightInput.value = String(height); + setStyleForSelected("height", `${height}px`); + } + }); + heightInput.addEventListener("input", () => { + const height = Number(heightInput.value); + if (!Number.isFinite(height) || height <= 0) return; + setStyleForSelected("height", `${height}px`); + if (aspectLocked && aspectRatio > 0) { + const width = Math.max(1, Math.round(height * aspectRatio)); + widthInput.value = String(width); + setStyleForSelected("width", `${width}px`); + } + }); + refreshAspectButton(); + + const addSpacingField = ( + label: string, + property: string, + placeholder: string, + ): HTMLInputElement => { + const input = document.createElement("input"); + input.type = "text"; + input.placeholder = placeholder; + input.addEventListener("change", () => { + if (input.value.trim()) setStyleForSelected(property, input.value.trim()); + }); + sizingSection.appendChild(createField(label, input)); + return input; + }; + const padding = addSpacingField("Padding", "padding", "0 0 0 0"); + const margin = addSpacingField("Margin", "margin", "0 0 0 0"); + const gap = addSpacingField("Gap", "gap", "0px"); + + const syncStyleControls = (): void => { + const first = selected.values().next().value as SelectedElement | undefined; + if (!first) return; + const computed = getComputedStyle(first.element); + const rect = first.element.getBoundingClientRect(); + aspectRatio = rect.height > 0 ? rect.width / rect.height : 1; + widthInput.value = String(Math.round(rect.width)); + heightInput.value = String(Math.round(rect.height)); + fontSize.value = String(Math.round(Number.parseFloat(computed.fontSize) || 16)); + fontWeight.value = computed.fontWeight.match(/^[0-9]+$/) ? computed.fontWeight : "400"; + lineHeight.value = computed.lineHeight; + fontFamily.value = Array.from(fontFamily.options).some( + (option) => option.value === computed.fontFamily, + ) + ? computed.fontFamily + : "inherit"; + textColor.text.value = computed.color; + backgroundColor.text.value = computed.backgroundColor; + borderColor.text.value = computed.borderColor; + opacity.value = computed.opacity; + radius.value = String(Math.round(Number.parseFloat(computed.borderRadius) || 0)); + borderWidth.value = String(Math.round(Number.parseFloat(computed.borderWidth) || 0)); + padding.value = computed.padding; + margin.value = computed.margin; + gap.value = computed.gap === "normal" ? "0px" : computed.gap; + }; + + const tools: ReadonlyArray<[AnnotationTool, string, string]> = [ + ["select", "Select", "Select elements (V)"], + ["marquee", "Region", "Draw a region or marquee-select elements (R)"], + ["draw", "Draw", "Draw freehand (D)"], + ["erase", "Erase", "Remove an annotation target (E)"], + ]; + for (const [candidate, label, title] of tools) { + const button = createButton(label, title); + button.className += " h-8 px-2.5 text-sm"; + button.addEventListener("click", () => { + tool = candidate; + refreshToolButtons(); + }); + toolButtons.set(candidate, button); + toolbar.appendChild(button); + } + + const clampEditorPosition = (left: number, top: number): { left: number; top: number } => { + const margin = 8; + const rect = editor.getBoundingClientRect(); + return { + left: Math.min( + Math.max(margin, left), + Math.max(margin, window.innerWidth - rect.width - margin), + ), + top: Math.min( + Math.max(margin, top), + Math.max(margin, window.innerHeight - rect.height - margin), + ), + }; + }; + + const applyEditorPosition = (position: { left: number; top: number }): void => { + const clamped = clampEditorPosition(position.left, position.top); + editor.style.left = `${clamped.left}px`; + editor.style.top = `${clamped.top}px`; + editor.style.right = "auto"; + editor.style.bottom = "auto"; + if (editorExpanded) editorPosition = clamped; + }; + + const getAnnotationBounds = (): PreviewAnnotationRect | null => + unionRects( + [ + ...Array.from(selected.values(), (target) => + rectFromDomRect(target.element.getBoundingClientRect()), + ), + ...regions.map((region) => region.rect), + ...strokes.map((stroke) => stroke.bounds), + ], + 0, + ); + + const positionCompactEditor = (): void => { + const bounds = getAnnotationBounds(); + if (!bounds) return; + const editorRect = editor.getBoundingClientRect(); + const gap = 8; + const candidates = [ + { left: bounds.x + bounds.width + gap, top: bounds.y }, + { left: bounds.x - editorRect.width - gap, top: bounds.y }, + { + left: bounds.x + bounds.width - editorRect.width, + top: bounds.y + bounds.height + gap, + }, + { + left: bounds.x + bounds.width - editorRect.width, + top: bounds.y - editorRect.height - gap, + }, + ]; + const overflow = (position: { left: number; top: number }): number => + Math.max(0, -position.left) + + Math.max(0, -position.top) + + Math.max(0, position.left + editorRect.width - window.innerWidth) + + Math.max(0, position.top + editorRect.height - window.innerHeight); + const best = candidates.reduce((current, candidate) => + overflow(candidate) < overflow(current) ? candidate : current, + ); + applyEditorPosition(best); + }; + + function queueEditorLayout(): void { + if (editorLayoutFrame !== null) window.cancelAnimationFrame(editorLayoutFrame); + editorLayoutFrame = window.requestAnimationFrame(() => { + editorLayoutFrame = null; + if (editor.style.display === "none") return; + if (editorExpanded && editorPosition) applyEditorPosition(editorPosition); + else positionCompactEditor(); + }); + } + + adjust.addEventListener("click", () => { + if (selected.size === 0) return; + if (!editorExpanded) { + const rect = editor.getBoundingClientRect(); + editorExpanded = true; + editorPosition = { left: rect.left, top: rect.top }; + stylePanel.style.display = selected.size > 0 ? "grid" : "none"; + dragHandle.style.display = "block"; + adjust.setAttribute("aria-expanded", "true"); + adjust.title = "Collapse annotation editor"; + adjust.setAttribute("aria-label", "Collapse annotation editor"); + if (selected.size > 0) syncStyleControls(); + } else { + editorExpanded = false; + editorPosition = null; + stylePanel.style.display = "none"; + dragHandle.style.display = "none"; + adjust.setAttribute("aria-expanded", "false"); + adjust.title = "Expand annotation editor"; + adjust.setAttribute("aria-label", "Expand annotation editor"); + } + queueEditorLayout(); + }); + + const onEditorPointerDown = (event: PointerEvent): void => { + if (event.button !== 0 || !editorExpanded) return; + const rect = editor.getBoundingClientRect(); + editorDrag = { + pointerId: event.pointerId, + offsetX: event.clientX - rect.left, + offsetY: event.clientY - rect.top, + }; + dragHandle.setPointerCapture(event.pointerId); + dragHandle.style.cursor = "grabbing"; + event.preventDefault(); + event.stopPropagation(); + }; + + const onEditorPointerMove = (event: PointerEvent): void => { + if (!editorDrag || editorDrag.pointerId !== event.pointerId) return; + applyEditorPosition({ + left: event.clientX - editorDrag.offsetX, + top: event.clientY - editorDrag.offsetY, + }); + event.preventDefault(); + event.stopPropagation(); + }; + + const onEditorPointerUp = (event: PointerEvent): void => { + if (!editorDrag || editorDrag.pointerId !== event.pointerId) return; + editorDrag = null; + dragHandle.style.cursor = "grab"; + if (dragHandle.hasPointerCapture(event.pointerId)) + dragHandle.releasePointerCapture(event.pointerId); + event.preventDefault(); + event.stopPropagation(); + }; + dragHandle.addEventListener("pointerdown", onEditorPointerDown); + dragHandle.addEventListener("pointermove", onEditorPointerMove); + dragHandle.addEventListener("pointerup", onEditorPointerUp); + dragHandle.addEventListener("pointercancel", onEditorPointerUp); + + const repaint = (): void => { + for (const target of selected.values()) updateSelectedVisual(target); + queueEditorLayout(); + }; + + const removeTargetAtPoint = (x: number, y: number): boolean => { + for (const target of Array.from(selected.values()).toReversed()) { + const rect = target.element.getBoundingClientRect(); + if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) { + removeSelected(target); + return true; + } + } + const regionIndex = regions.findIndex( + (region) => + x >= region.rect.x && + x <= region.rect.x + region.rect.width && + y >= region.rect.y && + y <= region.rect.y + region.rect.height, + ); + if (regionIndex >= 0) { + const [removed] = regions.splice(regionIndex, 1); + root.querySelector(`[data-region-id="${removed?.id}"]`)?.remove(); + updateStatus(); + return true; + } + const strokeIndex = strokes.findIndex( + (stroke) => + x >= stroke.bounds.x && + x <= stroke.bounds.x + stroke.bounds.width && + y >= stroke.bounds.y && + y <= stroke.bounds.y + stroke.bounds.height, + ); + if (strokeIndex >= 0) { + const [removed] = strokes.splice(strokeIndex, 1); + svg.querySelector(`[data-stroke-id="${removed?.id}"]`)?.remove(); + updateStatus(); + return true; + } + return false; + }; + + const selectElementsInRect = (rect: PreviewAnnotationRect): number => { + const candidates = Array.from(document.querySelectorAll("body *")) + .filter((element) => !isAnnotationNode(element)) + .map((element) => ({ element, rect: element.getBoundingClientRect() })) + .filter(({ rect: candidate }) => { + if (candidate.width < 2 || candidate.height < 2) return false; + return !( + candidate.right < rect.x || + candidate.left > rect.x + rect.width || + candidate.bottom < rect.y || + candidate.top > rect.y + rect.height + ); + }) + .filter(({ element, rect: candidate }) => { + const centerX = candidate.left + candidate.width / 2; + const centerY = candidate.top + candidate.height / 2; + return ( + centerX >= rect.x && + centerX <= rect.x + rect.width && + centerY >= rect.y && + centerY <= rect.y + rect.height && + (element.children.length === 0 || + element instanceof HTMLButtonElement || + element instanceof HTMLAnchorElement || + element.getAttribute("role") === "button") + ); + }) + .sort( + (left, right) => left.rect.width * left.rect.height - right.rect.width * right.rect.height, + ) + .slice(0, MAX_MARQUEE_ELEMENTS); + for (const candidate of candidates) addSelected(candidate.element); + return candidates.length; + }; + + const clearHoverOutline = (): void => { + hoverOutline.style.display = "none"; + }; + + const onPointerMove = (event: PointerEvent): void => { + if (isAnnotationNode(event.target as Element)) { + clearHoverOutline(); + return; + } + if (tool === "select" && dragStart === null) { + const target = pickFromPoint(event.clientX, event.clientY); + if (target) positionBox(hoverOutline, rectFromDomRect(target.getBoundingClientRect())); + else clearHoverOutline(); + return; + } + clearHoverOutline(); + if (tool === "marquee" && dragStart) { + positionBox( + marqueeBox, + normalizeRect(dragStart.x, dragStart.y, event.clientX, event.clientY), + ); + return; + } + if (tool === "draw" && activeStroke) { + activeStroke.target.points = [ + ...activeStroke.target.points, + { x: event.clientX, y: event.clientY }, + ]; + activeStroke.target.bounds = strokeBounds( + activeStroke.target.points, + activeStroke.target.width, + ); + activeStroke.path.setAttribute("d", pathFromPoints(activeStroke.target.points)); + } + }; + + const onPointerDown = (event: PointerEvent): void => { + if (event.button !== 0 || isAnnotationNode(event.target as Element)) return; + event.preventDefault(); + event.stopPropagation(); + if (tool === "select") { + const target = pickFromPoint(event.clientX, event.clientY); + if (target) toggleSelected(target, event.shiftKey); + return; + } + if (tool === "erase") { + removeTargetAtPoint(event.clientX, event.clientY); + return; + } + dragStart = { x: event.clientX, y: event.clientY }; + if (tool === "draw") { + const stroke: PreviewAnnotationStrokeTarget = { + id: nextId("stroke"), + color: annotationTheme?.primary ?? "#2563eb", + width: 4, + points: [dragStart], + bounds: { x: dragStart.x, y: dragStart.y, width: 1, height: 1 }, + }; + const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); + path.setAttribute(OVERLAY_ATTRIBUTE, ""); + path.setAttribute("data-stroke-id", stroke.id); + path.setAttribute("fill", "none"); + path.setAttribute("stroke", stroke.color); + path.setAttribute("stroke-width", String(stroke.width)); + path.setAttribute("stroke-linecap", "round"); + path.setAttribute("stroke-linejoin", "round"); + svg.appendChild(path); + activeStroke = { target: stroke, path }; + } + }; + + const onPointerUp = (event: PointerEvent): void => { + if (!dragStart) return; + event.preventDefault(); + event.stopPropagation(); + if (tool === "marquee") { + const rect = normalizeRect(dragStart.x, dragStart.y, event.clientX, event.clientY); + marqueeBox.style.display = "none"; + if (isUsableRect(rect)) { + const found = selectElementsInRect(rect); + if (found === 0) { + const region: PreviewAnnotationRegionTarget = { id: nextId("region"), rect }; + regions.push(region); + const regionBox = createBox( + PRIMARY, + "color-mix(in srgb, var(--t3-primary) 6%, transparent)", + ); + regionBox.setAttribute("data-region-id", region.id); + positionBox(regionBox, rect); + root.appendChild(regionBox); + } + } + } else if (tool === "draw" && activeStroke) { + if (activeStroke.target.points.length > 1) strokes.push(activeStroke.target); + else activeStroke.path.remove(); + activeStroke = null; + } + dragStart = null; + updateStatus(); + }; + + const onClick = (event: MouseEvent): void => { + if (isAnnotationNode(event.target as Element)) return; + event.preventDefault(); + event.stopPropagation(); + }; + + const onPointerOut = (event: PointerEvent): void => { + if (event.relatedTarget === null) clearHoverOutline(); + }; + + const onWindowBlur = (): void => { + clearHoverOutline(); + }; + + const restoreStyles = (): void => { + for (const target of selected.values()) { + if (!(target.element instanceof HTMLElement || target.element instanceof SVGElement)) + continue; + for (const [property, baseline] of target.baselineStyles) { + if (baseline) target.element.style.setProperty(property, baseline); + else target.element.style.removeProperty(property); + } + } + }; + + const teardown = (notifyMain: boolean): void => { + if (finished) return; + finished = true; + restoreStyles(); + window.removeEventListener("pointermove", onPointerMove, true); + window.removeEventListener("pointerdown", onPointerDown, true); + window.removeEventListener("pointerup", onPointerUp, true); + window.removeEventListener("pointerout", onPointerOut, true); + window.removeEventListener("click", onClick, true); + window.removeEventListener("blur", onWindowBlur); + window.removeEventListener("keydown", onKeyDown, true); + window.removeEventListener("scroll", repaint, true); + window.removeEventListener("resize", repaint); + dragHandle.removeEventListener("pointerdown", onEditorPointerDown); + dragHandle.removeEventListener("pointermove", onEditorPointerMove); + dragHandle.removeEventListener("pointerup", onEditorPointerUp); + dragHandle.removeEventListener("pointercancel", onEditorPointerUp); + if (editorLayoutFrame !== null) window.cancelAnimationFrame(editorLayoutFrame); + ipcRenderer.off(CANCEL_PICK_CHANNEL, onCancel); + ipcRenderer.off(ANNOTATION_CAPTURED_CHANNEL, onCaptured); + document.documentElement.removeAttribute("data-t3code-annotation-tool"); + cursorStyle.remove(); + host.remove(); + activeSession = null; + if (notifyMain) ipcRenderer.send(ELEMENT_PICKED_CHANNEL, null); + }; + + const onCancel = (): void => teardown(false); + const onCaptured = (): void => teardown(false); + const onKeyDown = (event: KeyboardEvent): void => { + if (isAnnotationNode(event.target as Element) && event.key !== "Escape") return; + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + teardown(true); + return; + } + if (event.key === "v") tool = "select"; + else if (event.key === "r") tool = "marquee"; + else if (event.key === "d") tool = "draw"; + else if (event.key === "e") tool = "erase"; + else return; + refreshToolButtons(); + }; + + submit.addEventListener("click", () => { + if (pendingCapture || (selected.size === 0 && regions.length === 0 && strokes.length === 0)) + return; + pendingCapture = true; + submit.disabled = true; + submit.textContent = "Capturing…"; + void Promise.all( + Array.from(selected.values()).map(async (target) => { + const element = await captureElement(target.element); + if (!element) return null; + for (const change of styleChanges.values()) { + if (change.targetId === target.id) change.selector = element.selector; + } + return { + id: target.id, + element, + rect: rectFromDomRect(target.element.getBoundingClientRect()), + }; + }), + ).then((captured) => { + const elements = captured.filter((target) => target !== null); + const annotation: PreviewAnnotationPayload = { + id: nextId("annotation"), + pageUrl: location.href, + pageTitle: document.title?.trim() || null, + comment: comment.value.trim(), + elements, + regions: [...regions], + strokes: [...strokes], + styleChanges: Array.from(styleChanges.values()), + screenshot: null, + createdAt: new Date().toISOString(), + }; + editor.style.display = "none"; + toolbar.style.display = "none"; + hoverOutline.style.display = "none"; + const screenshotRect = unionRects([ + ...elements.map((target) => target.rect), + ...regions.map((region) => region.rect), + ...strokes.map((stroke) => stroke.bounds), + ]); + ipcRenderer.send(ELEMENT_PICKED_CHANNEL, annotation, screenshotRect); + }); + }); + comment.addEventListener("keydown", (event) => { + if (event.key !== "Enter" || !(event.metaKey || event.ctrlKey)) return; + event.preventDefault(); + submit.click(); + }); + + window.addEventListener("pointermove", onPointerMove, { capture: true, passive: false }); + window.addEventListener("pointerdown", onPointerDown, { capture: true, passive: false }); + window.addEventListener("pointerup", onPointerUp, { capture: true, passive: false }); + window.addEventListener("pointerout", onPointerOut, { capture: true, passive: true }); + window.addEventListener("click", onClick, { capture: true, passive: false }); + window.addEventListener("blur", onWindowBlur); + window.addEventListener("keydown", onKeyDown, { capture: true }); + window.addEventListener("scroll", repaint, { capture: true, passive: true }); + window.addEventListener("resize", repaint, { passive: true }); + ipcRenderer.on(CANCEL_PICK_CHANNEL, onCancel); + ipcRenderer.on(ANNOTATION_CAPTURED_CHANNEL, onCaptured); + document.documentElement.appendChild(host); + refreshToolButtons(); + updateStatus(); + activeSession = { + teardown, + applyTheme: (theme) => applyAnnotationTheme(host, theme), + }; +} + +ipcRenderer.on(START_PICK_CHANNEL, (_event, theme: DesktopPreviewAnnotationTheme | undefined) => { + if (theme) annotationTheme = theme; + startAnnotation(); +}); +ipcRenderer.on(ANNOTATION_THEME_CHANNEL, (_event, theme: DesktopPreviewAnnotationTheme) => { + annotationTheme = theme; + activeSession?.applyTheme(theme); +}); +ipcRenderer.on(CANCEL_PICK_CHANNEL, () => activeSession?.teardown(false)); diff --git a/apps/desktop/src/picked-element-payload.test.ts b/apps/desktop/src/preview/PickedElementPayload.test.ts similarity index 99% rename from apps/desktop/src/picked-element-payload.test.ts rename to apps/desktop/src/preview/PickedElementPayload.test.ts index 39537fa7c96..d7a96732477 100644 --- a/apps/desktop/src/picked-element-payload.test.ts +++ b/apps/desktop/src/preview/PickedElementPayload.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { isPickedElementPayload, isPreviewAnnotationPayload } from "./picked-element-payload.ts"; +import { isPickedElementPayload, isPreviewAnnotationPayload } from "./PickedElementPayload.ts"; function validPayload(overrides?: Record<string, unknown>): Record<string, unknown> { return { diff --git a/apps/desktop/src/picked-element-payload.ts b/apps/desktop/src/preview/PickedElementPayload.ts similarity index 98% rename from apps/desktop/src/picked-element-payload.ts rename to apps/desktop/src/preview/PickedElementPayload.ts index a9fadf63c8f..e2d596120db 100644 --- a/apps/desktop/src/picked-element-payload.ts +++ b/apps/desktop/src/preview/PickedElementPayload.ts @@ -1,6 +1,6 @@ /** * Strict structural validator for `PickedElementPayload` messages received - * from the in-page picker preload (`apps/desktop/src/preview-pick-preload.ts`) + * from the in-page picker preload (`apps/desktop/src/preview/PickPreload.ts`) * via `wc.ipc`. Lives in its own electron-free module so the validator is * trivially unit-testable. * diff --git a/apps/desktop/src/playwright-injected-runtime.test.ts b/apps/desktop/src/preview/PlaywrightInjectedRuntime.test.ts similarity index 94% rename from apps/desktop/src/playwright-injected-runtime.test.ts rename to apps/desktop/src/preview/PlaywrightInjectedRuntime.test.ts index 20692063672..02d80baf63c 100644 --- a/apps/desktop/src/playwright-injected-runtime.test.ts +++ b/apps/desktop/src/preview/PlaywrightInjectedRuntime.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vite-plus/test"; import { playwrightInjectedRuntimeInstallExpression, playwrightInjectedRuntimeSource, -} from "./playwright-injected-runtime.ts"; +} from "./PlaywrightInjectedRuntime.ts"; describe("playwright injected runtime", () => { it("extracts the pinned runtime from playwright-core", async () => { diff --git a/apps/desktop/src/playwright-injected-runtime.ts b/apps/desktop/src/preview/PlaywrightInjectedRuntime.ts similarity index 100% rename from apps/desktop/src/playwright-injected-runtime.ts rename to apps/desktop/src/preview/PlaywrightInjectedRuntime.ts diff --git a/apps/desktop/src/preview-webview-preferences.test.ts b/apps/desktop/src/preview/WebviewPreferences.test.ts similarity index 97% rename from apps/desktop/src/preview-webview-preferences.test.ts rename to apps/desktop/src/preview/WebviewPreferences.test.ts index dc88714a51d..498c1df4665 100644 --- a/apps/desktop/src/preview-webview-preferences.test.ts +++ b/apps/desktop/src/preview/WebviewPreferences.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { PREVIEW_WEBVIEW_PREFERENCES } from "./preview-webview-preferences.ts"; +import { PREVIEW_WEBVIEW_PREFERENCES } from "./WebviewPreferences.ts"; /** * Mirrors Electron's webview attribute parser closely enough to catch the diff --git a/apps/desktop/src/preview-webview-preferences.ts b/apps/desktop/src/preview/WebviewPreferences.ts similarity index 93% rename from apps/desktop/src/preview-webview-preferences.ts rename to apps/desktop/src/preview/WebviewPreferences.ts index 2d96bff456c..085c75232b3 100644 --- a/apps/desktop/src/preview-webview-preferences.ts +++ b/apps/desktop/src/preview/WebviewPreferences.ts @@ -4,7 +4,7 @@ * surfaces inherit the same security posture. * * Lives in its own electron-free module so the value is unit-testable - * without importing `preview-view-manager.ts` (which transitively imports + * without importing `Manager.ts` (which transitively imports * `electron` and blows up under vitest). * * - `contextIsolation=false`: the picker preload needs to share `globalThis` @@ -22,7 +22,7 @@ * - `nodeIntegration=false`: pinned for clarity (the page itself never gets * Node access). * - * Format notes (locked down by `preview-webview-preferences.test.ts`): + * Format notes (locked down by `WebviewPreferences.test.ts`): * - Whitespace-free. Electron's webpreferences parser splits on `,` and * does not trim, so a leading space would turn a key into an unknown one * and silently drop it. diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 01dbf3af1e8..569ac771c73 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -29,6 +29,7 @@ import * as ElectronTheme from "../electron/ElectronTheme.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; import * as DesktopWindow from "./DesktopWindow.ts"; +import * as PreviewManager from "../preview/Manager.ts"; const environmentInput = { dirname: "/repo/apps/desktop/dist-electron", @@ -166,6 +167,12 @@ function makeTestLayer(input: { } satisfies ElectronShell.ElectronShellShape), electronThemeLayer, electronWindowLayer, + Layer.mock(PreviewManager.PreviewManager)({ + getBrowserSession: () => Effect.succeed({} as Electron.Session), + setMainWindow: () => Effect.void, + isBrowserPartition: (partition) => partition.startsWith("persist:t3code-preview-"), + getBrowserPartition: () => "persist:t3code-preview-test", + }), ), ), ); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 953c7a3adb2..f24485fd879 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -11,7 +11,7 @@ import * as DesktopAssets from "../app/DesktopAssets.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopObservability from "../app/DesktopObservability.ts"; import * as DesktopState from "../app/DesktopState.ts"; -import { previewViewManager } from "../preview-view-manager.ts"; +import * as PreviewManager from "../preview/Manager.ts"; import * as ElectronMenu from "../electron/ElectronMenu.ts"; import * as ElectronShell from "../electron/ElectronShell.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; @@ -37,7 +37,8 @@ type DesktopWindowRuntimeServices = | ElectronMenu.ElectronMenu | ElectronShell.ElectronShell | ElectronTheme.ElectronTheme - | ElectronWindow.ElectronWindow; + | ElectronWindow.ElectronWindow + | PreviewManager.PreviewManager; export class DesktopWindowDevServerUrlMissingError extends Data.TaggedError( "DesktopWindowDevServerUrlMissingError", @@ -49,7 +50,8 @@ export class DesktopWindowDevServerUrlMissingError extends Data.TaggedError( export type DesktopWindowError = | DesktopWindowDevServerUrlMissingError - | ElectronWindow.ElectronWindowCreateError; + | ElectronWindow.ElectronWindowCreateError + | PreviewManager.PreviewManagerError; export interface DesktopWindowShape { readonly createMain: Effect.Effect<Electron.BrowserWindow, DesktopWindowError>; @@ -163,6 +165,7 @@ const make = Effect.gen(function* () { const electronShell = yield* ElectronShell.ElectronShell; const electronTheme = yield* ElectronTheme.ElectronTheme; const electronWindow = yield* ElectronWindow.ElectronWindow; + const previewManager = yield* PreviewManager.PreviewManager; const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; const state = yield* DesktopState.DesktopState; const context = yield* Effect.context<DesktopWindowRuntimeServices>(); @@ -171,7 +174,7 @@ const make = Effect.gen(function* () { const createWindow = Effect.fn("desktop.window.createWindow")(function* ( backendHttpUrl: URL, ): Effect.fn.Return<Electron.BrowserWindow, DesktopWindowError> { - previewViewManager.getBrowserSession(); + yield* previewManager.getBrowserSession(); const applicationUrl = environment.isDevelopment ? yield* resolveDesktopDevServerUrl(environment) : backendHttpUrl.href; @@ -198,11 +201,11 @@ const make = Effect.gen(function* () { }, }); - previewViewManager.setMainWindow(window); + yield* previewManager.setMainWindow(window); window.webContents.on("will-attach-webview", (event, webPreferences, params) => { if ( typeof params.partition !== "string" || - !previewViewManager.isBrowserPartition(params.partition) + !previewManager.isBrowserPartition(params.partition) ) { event.preventDefault(); return; diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index d13eb004222..3ae91bf8baf 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -5,6 +5,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; +import type * as Types from "effect/Types"; import { McpSchema, McpServer, Tool } from "effect/unstable/ai"; import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; @@ -29,6 +30,20 @@ const unauthorized = HttpServerResponse.jsonUnsafe( }, ); +type AuthenticatedHttpEffect = Effect.Effect< + HttpServerResponse.HttpServerResponse, + Types.unhandled, + McpInvocationContext.McpInvocationContext +>; + +type McpAuthMiddleware = ( + httpEffect: AuthenticatedHttpEffect, +) => Effect.Effect< + HttpServerResponse.HttpServerResponse, + Types.unhandled, + HttpServerRequest.HttpServerRequest +>; + export const normalizeMcpHttpResponse = ( response: HttpServerResponse.HttpServerResponse, ): HttpServerResponse.HttpServerResponse => { @@ -41,27 +56,123 @@ export const normalizeMcpHttpResponse = ( : response; }; +const makeMcpAuthMiddleware = McpSessionRegistry.McpSessionRegistry.pipe( + Effect.map( + (registry): McpAuthMiddleware => + Effect.fn("McpHttpServer.authenticateRequest")(function* (httpEffect) { + const request = yield* HttpServerRequest.HttpServerRequest; + const authorization = request.headers.authorization; + const token = + authorization?.startsWith("Bearer ") === true + ? authorization.slice("Bearer ".length).trim() + : ""; + const invocation = yield* registry.resolve(token); + if (!invocation) return unauthorized; + return yield* httpEffect.pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.map(normalizeMcpHttpResponse), + ); + }), + ), + Effect.withSpan("McpHttpServer.makeAuthMiddleware"), +); + const McpAuthMiddlewareLive = HttpRouter.middleware<{ provides: McpInvocationContext.McpInvocationContext; -}>()( - Effect.gen(function* () { - const registry = yield* McpSessionRegistry.McpSessionRegistry; - return Effect.fn("McpHttpServer.authenticateRequest")(function* (httpEffect) { - const request = yield* HttpServerRequest.HttpServerRequest; - const authorization = request.headers.authorization; - const token = - authorization?.startsWith("Bearer ") === true - ? authorization.slice("Bearer ".length).trim() - : ""; - const invocation = yield* registry.resolve(token); - if (!invocation) return unauthorized; - return yield* httpEffect.pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), - Effect.map(normalizeMcpHttpResponse), - ); +}>()(makeMcpAuthMiddleware).layer; + +const registerPreviewToolkit = Effect.fn("McpHttpServer.registerPreviewToolkit")(function* () { + const server = yield* McpServer.McpServer; + const built = yield* PreviewToolkit; + const handleTool = built.handle as unknown as ( + name: keyof typeof built.tools, + payload: unknown, + ) => Effect.Effect< + Stream.Stream<{ readonly encodedResult: unknown }, Error>, + Error, + McpInvocationContext.McpInvocationContext + >; + for (const tool of Object.values(built.tools)) { + yield* server.addTool({ + tool: new McpSchema.Tool({ + name: tool.name, + description: Tool.getDescription(tool), + inputSchema: Tool.getJsonSchema(tool), + annotations: { + ...Context.getOption(tool.annotations, Tool.Title).pipe( + Option.map((title) => ({ title })), + Option.getOrUndefined, + ), + readOnlyHint: Context.get(tool.annotations, Tool.Readonly), + destructiveHint: Context.get(tool.annotations, Tool.Destructive), + idempotentHint: Context.get(tool.annotations, Tool.Idempotent), + openWorldHint: Context.get(tool.annotations, Tool.OpenWorld), + }, + }), + annotations: tool.annotations, + handle: (payload) => + handleTool(tool.name as keyof typeof built.tools, payload).pipe( + Stream.unwrap, + Stream.run(Sink.last()), + Effect.flatMap(Effect.fromOption), + Effect.matchCause({ + onFailure: (cause) => + new McpSchema.CallToolResult({ + isError: true, + content: [{ type: "text", text: Cause.pretty(cause) }], + }), + onSuccess: (result) => { + if (tool.name === "preview_snapshot") { + const snapshot = result.encodedResult as { + readonly screenshot: { + readonly mimeType: "image/png"; + readonly data: string; + readonly width: number; + readonly height: number; + }; + readonly [key: string]: unknown; + }; + const { screenshot, ...page } = snapshot; + const metadata = { + ...page, + screenshot: { + mimeType: screenshot.mimeType, + width: screenshot.width, + height: screenshot.height, + }, + }; + return new McpSchema.CallToolResult({ + isError: false, + structuredContent: metadata, + content: [ + { type: "text", text: JSON.stringify(metadata) }, + { + type: "image", + data: new Uint8Array(Buffer.from(screenshot.data, "base64")), + mimeType: screenshot.mimeType, + }, + ], + }); + } + const encodedResultText = JSON.stringify(result.encodedResult) ?? "null"; + return new McpSchema.CallToolResult({ + isError: false, + structuredContent: + result.encodedResult !== null && typeof result.encodedResult === "object" + ? result.encodedResult + : undefined, + content: [{ type: "text", text: encodedResultText }], + }); + }, + }), + ) as unknown as Effect.Effect<McpSchema.CallToolResult, never, McpSchema.McpServerClient>, }); - }), -).layer; + } +}); + +export const PreviewToolkitRegistrationLive = Layer.effectDiscard(registerPreviewToolkit()).pipe( + Layer.provide(PreviewToolkitHandlersLive), +); const McpTransportLive = McpServer.layerHttp({ name: "T3 Code", @@ -69,98 +180,7 @@ const McpTransportLive = McpServer.layerHttp({ path: "/mcp", }).pipe(Layer.provide(McpAuthMiddlewareLive)); -export const PreviewToolkitRegistrationLive = Layer.effectDiscard( - Effect.gen(function* () { - const server = yield* McpServer.McpServer; - const built = yield* PreviewToolkit; - const handleTool = built.handle as unknown as ( - name: keyof typeof built.tools, - payload: unknown, - ) => Effect.Effect< - Stream.Stream<{ readonly encodedResult: unknown }, Error>, - Error, - McpInvocationContext.McpInvocationContext - >; - for (const tool of Object.values(built.tools)) { - yield* server.addTool({ - tool: new McpSchema.Tool({ - name: tool.name, - description: Tool.getDescription(tool), - inputSchema: Tool.getJsonSchema(tool), - annotations: { - ...Context.getOption(tool.annotations, Tool.Title).pipe( - Option.map((title) => ({ title })), - Option.getOrUndefined, - ), - readOnlyHint: Context.get(tool.annotations, Tool.Readonly), - destructiveHint: Context.get(tool.annotations, Tool.Destructive), - idempotentHint: Context.get(tool.annotations, Tool.Idempotent), - openWorldHint: Context.get(tool.annotations, Tool.OpenWorld), - }, - }), - annotations: tool.annotations, - handle: (payload) => - handleTool(tool.name as keyof typeof built.tools, payload).pipe( - Stream.unwrap, - Stream.run(Sink.last()), - Effect.flatMap(Effect.fromOption), - Effect.matchCause({ - onFailure: (cause) => - new McpSchema.CallToolResult({ - isError: true, - content: [{ type: "text", text: Cause.pretty(cause) }], - }), - onSuccess: (result) => { - if (tool.name === "preview_snapshot") { - const snapshot = result.encodedResult as { - readonly screenshot: { - readonly mimeType: "image/png"; - readonly data: string; - readonly width: number; - readonly height: number; - }; - readonly [key: string]: unknown; - }; - const { screenshot, ...page } = snapshot; - const metadata = { - ...page, - screenshot: { - mimeType: screenshot.mimeType, - width: screenshot.width, - height: screenshot.height, - }, - }; - return new McpSchema.CallToolResult({ - isError: false, - structuredContent: metadata, - content: [ - { type: "text", text: JSON.stringify(metadata) }, - { - type: "image", - data: new Uint8Array(Buffer.from(screenshot.data, "base64")), - mimeType: screenshot.mimeType, - }, - ], - }); - } - const encodedResultText = JSON.stringify(result.encodedResult) ?? "null"; - return new McpSchema.CallToolResult({ - isError: false, - structuredContent: - result.encodedResult !== null && typeof result.encodedResult === "object" - ? result.encodedResult - : undefined, - content: [{ type: "text", text: encodedResultText }], - }); - }, - }), - ) as unknown as Effect.Effect<McpSchema.CallToolResult, never, McpSchema.McpServerClient>, - }); - } - }), -).pipe(Layer.provide(PreviewToolkitHandlersLive)); - -export const layer = Layer.mergeAll(PreviewToolkitRegistrationLive).pipe( +export const layer = PreviewToolkitRegistrationLive.pipe( Layer.provideMerge(McpTransportLive), Layer.provide(PreviewAutomationBroker.layer), ); diff --git a/apps/server/src/mcp/McpSessionRegistry.test.ts b/apps/server/src/mcp/McpSessionRegistry.test.ts index d345953caeb..d6540d567af 100644 --- a/apps/server/src/mcp/McpSessionRegistry.test.ts +++ b/apps/server/src/mcp/McpSessionRegistry.test.ts @@ -18,15 +18,17 @@ const fakeEnvironment = ServerEnvironment.of({ }); const makeRegistry = (now: () => number) => - McpSessionRegistry.makeForTest({ - now, - idleTimeoutMs: 100, - maximumLifetimeMs: 1_000, - }).pipe( - Effect.provideService(HttpServer.HttpServer, fakeHttpServer), - Effect.provideService(ServerEnvironment, fakeEnvironment), - Effect.provide(NodeServices.layer), - ); + McpSessionRegistry.__testing + .make({ + now, + idleTimeoutMs: 100, + maximumLifetimeMs: 1_000, + }) + .pipe( + Effect.provideService(HttpServer.HttpServer, fakeHttpServer), + Effect.provideService(ServerEnvironment, fakeEnvironment), + Effect.provide(NodeServices.layer), + ); it.effect("stores only a token hash, resolves the bearer token, and revokes by thread", () => Effect.gen(function* () { diff --git a/apps/server/src/mcp/McpSessionRegistry.ts b/apps/server/src/mcp/McpSessionRegistry.ts index c87abe88bb5..161b560f746 100644 --- a/apps/server/src/mcp/McpSessionRegistry.ts +++ b/apps/server/src/mcp/McpSessionRegistry.ts @@ -184,8 +184,6 @@ export const layer: Layer.Layer< ), ); -export const makeForTest = make; - export const issueActiveMcpCredential = ( request: McpCredentialRequest, ): Effect.Effect<McpIssuedCredential | undefined> => @@ -200,3 +198,8 @@ export const revokeActiveMcpThread = (threadId: ThreadId): Effect.Effect<void> = export const revokeAllActiveMcpCredentials = (): Effect.Effect<void> => activeMcpSessionRegistry ? activeMcpSessionRegistry.revokeAll : Effect.void; + +/** Exposed for tests. */ +export const __testing = { + make, +}; diff --git a/apps/server/src/mcp/PreviewAutomationBroker.test.ts b/apps/server/src/mcp/PreviewAutomationBroker.test.ts index c5d316fd87f..7d702cd970b 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.test.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.test.ts @@ -23,7 +23,7 @@ const scope = { it.effect("routes a request to the focused owner and correlates its response", () => Effect.scoped( Effect.gen(function* () { - const broker = yield* PreviewAutomationBroker.makeForTest; + const broker = yield* PreviewAutomationBroker.__testing.make(); const requests = yield* broker.connect("client-1"); yield* Stream.runForEach(requests, (request) => broker.respond({ @@ -56,7 +56,7 @@ it.effect("routes a request to the focused owner and correlates its response", ( it.effect("rejects calls when no focused owner exists", () => Effect.gen(function* () { - const broker = yield* PreviewAutomationBroker.makeForTest; + const broker = yield* PreviewAutomationBroker.__testing.make(); const error = yield* broker .invoke<void>({ scope, operation: "status", input: {} }) .pipe(Effect.flip); @@ -67,7 +67,7 @@ it.effect("rejects calls when no focused owner exists", () => it.effect("routes interactive commands to a hidden durable browser host", () => Effect.scoped( Effect.gen(function* () { - const broker = yield* PreviewAutomationBroker.makeForTest; + const broker = yield* PreviewAutomationBroker.__testing.make(); const requests = yield* broker.connect("client-hidden"); yield* Stream.runForEach(requests, (request) => broker.respond({ requestId: request.requestId, ok: true }), diff --git a/apps/server/src/mcp/PreviewAutomationBroker.ts b/apps/server/src/mcp/PreviewAutomationBroker.ts index 68080bd9360..a8ae4a9eec4 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.ts @@ -119,14 +119,12 @@ const makeResponseError = ( } }; -const makeService = (): PreviewAutomationBrokerShape => { - const state = Effect.runSync( - Ref.make<BrokerState>({ - clients: new Map(), - owners: new Map(), - pending: new Map(), - }), - ); +const make = Effect.fn("PreviewAutomationBroker.make")(function* () { + const state = yield* Ref.make<BrokerState>({ + clients: new Map(), + owners: new Map(), + pending: new Map(), + }); let requestSequence = 0; const disconnect = Effect.fn("PreviewAutomationBroker.disconnect")(function* ( @@ -297,10 +295,11 @@ const makeService = (): PreviewAutomationBrokerShape => { }); return PreviewAutomationBroker.of({ connect, reportOwner, clearOwner, respond, invoke }); -}; +}); -const service = makeService(); -const make = Effect.succeed(service); -export const layer = Layer.effect(PreviewAutomationBroker, make); +export const layer = Layer.effect(PreviewAutomationBroker, make()); -export const makeForTest = Effect.sync(makeService); +/** Exposed for tests. */ +export const __testing = { + make, +}; diff --git a/apps/server/src/preview/Manager.ts b/apps/server/src/preview/Manager.ts index 8a52dbcc8c3..e18bbdaf493 100644 --- a/apps/server/src/preview/Manager.ts +++ b/apps/server/src/preview/Manager.ts @@ -127,7 +127,7 @@ const buildIdleSnapshot = (input: { updatedAt: input.updatedAt, }); -const make = Effect.gen(function* () { +const make = Effect.fn("PreviewManager.make")(function* () { const stateRef = yield* SynchronizedRef.make<ManagerState>(initialState); // Unbounded PubSub is fine here — events are tiny and we don't want to // block publishers if a subscriber is slow. WS clients backpressure on @@ -359,4 +359,4 @@ const make = Effect.gen(function* () { } satisfies PreviewManagerShape; }); -export const layer = Layer.effect(PreviewManager, make); +export const layer = Layer.effect(PreviewManager, make()); diff --git a/apps/server/src/preview/PortScanner.test.ts b/apps/server/src/preview/PortScanner.test.ts index 0f75a538ec6..350268acff6 100644 --- a/apps/server/src/preview/PortScanner.test.ts +++ b/apps/server/src/preview/PortScanner.test.ts @@ -2,8 +2,9 @@ import * as net from "node:net"; import { it as effectIt } from "@effect/vitest"; import { ThreadId } from "@t3tools/contracts"; +import * as Net from "@t3tools/shared/Net"; import { Effect, Layer } from "effect"; -import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; +import { describe, expect, it } from "vite-plus/test"; import { ProcessRunner } from "../processRunner.ts"; import * as PortScanner from "./PortScanner.ts"; @@ -13,7 +14,60 @@ const { parseLsofOutput, parsePortFromLsofName, parseWindowsListenerOutput, serv const TestProcessRunner = Layer.succeed(ProcessRunner, { run: () => Effect.die("ProcessRunner should not be used by Windows TCP probe tests"), }); -const TestPortDiscoveryLive = PortScanner.layer.pipe(Layer.provide(TestProcessRunner)); +const TestPortDiscoveryLive = PortScanner.layer.pipe( + Layer.provide(Layer.mergeAll(TestProcessRunner, Net.layer)), +); + +const openServer = (port: number): Effect.Effect<net.Server | null> => + Effect.callback((resume) => { + const server = net.createServer(); + server.once("error", () => { + resume(Effect.succeed(null)); + }); + server.listen(port, "127.0.0.1", () => { + resume(Effect.succeed(server)); + }); + return Effect.sync(() => { + server.close(); + }); + }); + +const closeServer = (server: net.Server): Effect.Effect<void> => + Effect.callback((resume) => { + server.close(() => resume(Effect.void)); + }); + +const windowsPlatform = Effect.acquireRelease( + Effect.sync(() => { + const originalPlatform = process.platform; + Object.defineProperty(process, "platform", { value: "win32", configurable: true }); + return originalPlatform; + }), + (originalPlatform) => + Effect.sync(() => { + Object.defineProperty(process, "platform", { + value: originalPlatform, + configurable: true, + }); + }), +); + +const openCommonDevServer = Effect.fn("PortScannerTest.openCommonDevServer")(function* ( + ports: ReadonlyArray<number>, +) { + for (const port of ports) { + const server = yield* openServer(port); + if (server !== null) return { port, server }; + } + return yield* Effect.die( + new Error("No common development port was available for the preview scanner test"), + ); +}); + +const commonDevServer = Effect.acquireRelease( + openCommonDevServer(PortScanner.COMMON_DEV_PORTS), + ({ server }) => closeServer(server), +); describe("parsePortFromLsofName", () => { it("parses *:port", () => { @@ -180,51 +234,26 @@ describe("parseWindowsListenerOutput", () => { * path (TCP-probe fallback) by monkey-patching `process.platform` for the * duration of the test so we don't depend on `lsof` being installed. */ -describe("PortDiscovery integration (TCP probe fallback)", () => { - let originalPlatform: NodeJS.Platform; - let server: net.Server; - let port: number; - - beforeEach(async () => { - originalPlatform = process.platform; - Object.defineProperty(process, "platform", { value: "win32", configurable: true }); - for (const candidate of PortScanner.COMMON_DEV_PORTS) { - const candidateServer = net.createServer(); - const listening = await new Promise<boolean>((resolve) => { - candidateServer.once("error", () => resolve(false)); - candidateServer.listen(candidate, "127.0.0.1", () => resolve(true)); - }); - if (listening) { - server = candidateServer; - port = candidate; - return; - } - candidateServer.close(); - } - throw new Error("No common development port was available for the preview scanner test"); - }); - - afterEach(async () => { - Object.defineProperty(process, "platform", { - value: originalPlatform, - configurable: true, - }); - await new Promise<void>((resolve) => server.close(() => resolve())); - }); - - effectIt.effect("scan() returns a server we just opened on a curated dev port", () => - Effect.gen(function* () { +effectIt.layer(TestPortDiscoveryLive)("PortDiscovery integration (TCP probe fallback)", (it) => { + it.effect( + "scan() returns a server we just opened on a curated dev port", + Effect.fn("PortScannerTest.scanFindsCommonDevServer")(function* () { + yield* windowsPlatform; + const { port } = yield* commonDevServer; const scanner = yield* PortScanner.PortDiscovery; const result = yield* scanner.scan(); const found = result.find((server) => server.port === port); expect(found).toBeDefined(); expect(found?.host).toBe("localhost"); - }).pipe(Effect.provide(TestPortDiscoveryLive)), + }), ); - effectIt.effect("retain() drives an immediate broadcast to subscribers", () => { - const received: number[] = []; - return Effect.gen(function* () { + it.effect( + "retain() drives an immediate broadcast to subscribers", + Effect.fn("PortScannerTest.retainBroadcastsImmediately")(function* () { + yield* windowsPlatform; + const { port } = yield* commonDevServer; + const received: number[] = []; const scanner = yield* PortScanner.PortDiscovery; const unsubscribe = yield* scanner.subscribe((servers) => Effect.sync(() => { @@ -235,6 +264,6 @@ describe("PortDiscovery integration (TCP probe fallback)", () => { unsubscribe(); release(); expect(received).toContain(port); - }).pipe(Effect.provide(TestPortDiscoveryLive)); - }); + }), + ); }); diff --git a/apps/server/src/preview/PortScanner.ts b/apps/server/src/preview/PortScanner.ts index 008de8ae798..d2ad4f43c37 100644 --- a/apps/server/src/preview/PortScanner.ts +++ b/apps/server/src/preview/PortScanner.ts @@ -5,15 +5,14 @@ * stable line-prefixed field format; this is the only `lsof` flag set we rely * on). * - * Windows / lsof missing: TCP-connects to a curated list of common dev ports - * on 127.0.0.1. + * Windows / lsof missing: checks a curated list of common dev ports through + * the shared Net service. * * Polling is reference-counted via `retain()`. A single layer-scoped fiber * polls forever, but each tick is a no-op when the retain count is zero. */ -import * as net from "node:net"; - import { ThreadId, type DiscoveredLocalServer } from "@t3tools/contracts"; +import * as Net from "@t3tools/shared/Net"; import { LSOF_LOCAL_HOST_TOKENS } from "@t3tools/shared/preview"; import { Cause, Context, Duration, Effect, Layer, Ref, Schedule } from "effect"; @@ -45,7 +44,6 @@ export const COMMON_DEV_PORTS: ReadonlyArray<number> = Object.freeze([ ]); const POLL_INTERVAL = Duration.seconds(3); -const TCP_PROBE_TIMEOUT_MS = 200; const LSOF_TIMEOUT_MS = 5_000; const WINDOWS_LISTENER_TIMEOUT_MS = 5_000; @@ -148,40 +146,6 @@ const parseWindowsListenerOutput = ( return [...seen.values()].toSorted((left, right) => left.port - right.port); }; -const probeTcpPort = (port: number): Promise<boolean> => - new Promise((resolve) => { - const socket = new net.Socket(); - let settled = false; - const finish = (ok: boolean) => { - if (settled) return; - settled = true; - socket.destroy(); - resolve(ok); - }; - socket.setTimeout(TCP_PROBE_TIMEOUT_MS); - socket.once("timeout", () => finish(false)); - socket.once("error", () => finish(false)); - socket.once("connect", () => finish(true)); - socket.connect({ host: "127.0.0.1", port }); - }); - -const probeCommonPorts = (): Effect.Effect<ReadonlyArray<DiscoveredLocalServer>> => - Effect.promise(async () => { - const results = await Promise.all( - COMMON_DEV_PORTS.map(async (port) => ({ port, listening: await probeTcpPort(port) })), - ); - return results - .filter((r) => r.listening) - .map<DiscoveredLocalServer>((r) => ({ - host: "localhost", - port: r.port, - url: `http://localhost:${r.port}`, - processName: null, - pid: null, - terminal: null, - })); - }); - const serversEqual = ( left: ReadonlyArray<DiscoveredLocalServer>, right: ReadonlyArray<DiscoveredLocalServer>, @@ -206,7 +170,8 @@ const serversEqual = ( return true; }; -const make = Effect.gen(function* () { +const make = Effect.fn("PortDiscovery.make")(function* () { + const net = yield* Net.NetService; const processRunner = yield* ProcessRunner; const stateRef = yield* Ref.make<ScannerState>({ lastSnapshot: [], @@ -224,6 +189,30 @@ const make = Effect.gen(function* () { // be a synchronous side-effect-only function. let retainCount = 0; + const probeCommonPorts = Effect.fn("PortDiscovery.probeCommonPorts")(function* () { + const results = yield* Effect.forEach( + COMMON_DEV_PORTS, + (port) => + net.isPortAvailableOnLoopback(port).pipe( + Effect.map((available) => ({ + port, + listening: !available, + })), + ), + { concurrency: "unbounded" }, + ); + return results + .filter((result) => result.listening) + .map<DiscoveredLocalServer>((result) => ({ + host: "localhost", + port: result.port, + url: `http://localhost:${result.port}`, + processName: null, + pid: null, + terminal: null, + })); + }); + const scanOnce = Effect.fn("PortDiscovery.scan")(function* () { const terminalByProcessId = new Map<number, TerminalProcessOwner>(); for (const registration of terminalProcesses.values()) { @@ -268,14 +257,15 @@ const make = Effect.gen(function* () { const broadcast = (servers: ReadonlyArray<DiscoveredLocalServer>): Effect.Effect<void> => Effect.forEach(Array.from(listeners), (listener) => listener(servers), { discard: true }); - const pollTick = Effect.gen(function* () { - if (retainCount <= 0) return; - const next = yield* scanOnce(); - const state = yield* Ref.get(stateRef); - if (serversEqual(state.lastSnapshot, next)) return; - yield* Ref.update(stateRef, (s) => ({ ...s, lastSnapshot: next })); - yield* broadcast(next); - }).pipe( + const pollTick = Effect.fn("PortDiscovery.pollTick")( + function* () { + if (retainCount <= 0) return; + const next = yield* scanOnce(); + const state = yield* Ref.get(stateRef); + if (serversEqual(state.lastSnapshot, next)) return; + yield* Ref.update(stateRef, (s) => ({ ...s, lastSnapshot: next })); + yield* broadcast(next); + }, Effect.catchCause((cause: Cause.Cause<never>) => Effect.logWarning("preview port scan failed", Cause.pretty(cause)), ), @@ -283,7 +273,7 @@ const make = Effect.gen(function* () { // Single layer-scoped polling fiber. Ticks are no-ops when no client is // currently retained, so the cost is one Ref.get every POLL_INTERVAL. - yield* Effect.forkScoped(pollTick.pipe(Effect.repeat(Schedule.spaced(POLL_INTERVAL)))); + yield* Effect.forkScoped(pollTick().pipe(Effect.repeat(Schedule.spaced(POLL_INTERVAL)))); const retain: PortDiscoveryShape["retain"] = Effect.fn("PortDiscovery.retain")(function* () { const wasIdle = retainCount === 0; @@ -291,7 +281,7 @@ const make = Effect.gen(function* () { if (wasIdle) { // Run an immediate scan + broadcast so the new retainer doesn't have // to wait up to POLL_INTERVAL for the first emission. - yield* pollTick; + yield* pollTick(); } let released = false; return () => { @@ -349,7 +339,7 @@ const make = Effect.gen(function* () { } satisfies PortDiscoveryShape; }); -export const layer = Layer.effect(PortDiscovery, make); +export const layer = Layer.effect(PortDiscovery, make()); /** Exposed for tests. */ export const __testing = { diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 4236a5ff2a7..1739a27e55b 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -66,7 +66,7 @@ import type { PreviewReportStatusInput, PreviewSessionSnapshot, } from "./preview.ts"; -import type { +import { PreviewAutomationClickInput, PreviewAutomationEvaluateInput, PreviewAutomationOwner, @@ -455,6 +455,42 @@ export interface DesktopPreviewTabState { updatedAt: string; } +export const DesktopPreviewTabIdSchema = Schema.String.check(Schema.isTrimmed()).check( + Schema.isNonEmpty(), +); + +export const DesktopPreviewNavStatusSchema = Schema.Union([ + Schema.Struct({ kind: Schema.Literal("Idle") }), + Schema.Struct({ + kind: Schema.Literal("Loading"), + url: Schema.String, + title: Schema.String, + }), + Schema.Struct({ + kind: Schema.Literal("Success"), + url: Schema.String, + title: Schema.String, + }), + Schema.Struct({ + kind: Schema.Literal("LoadFailed"), + url: Schema.String, + title: Schema.String, + code: Schema.Number, + description: Schema.String, + }), +]); + +export const DesktopPreviewTabStateSchema: Schema.Codec<DesktopPreviewTabState> = Schema.Struct({ + tabId: DesktopPreviewTabIdSchema, + webContentsId: Schema.NullOr(Schema.Int), + navStatus: DesktopPreviewNavStatusSchema, + canGoBack: Schema.Boolean, + canGoForward: Schema.Boolean, + zoomFactor: Schema.Number, + controller: Schema.Literals(["human", "agent", "none"]), + updatedAt: Schema.String, +}); + export interface DesktopPreviewPointerEvent { tabId: string; phase: "move" | "click"; @@ -464,6 +500,16 @@ export interface DesktopPreviewPointerEvent { createdAt: string; } +export const DesktopPreviewPointerEventSchema: Schema.Codec<DesktopPreviewPointerEvent> = + Schema.Struct({ + tabId: DesktopPreviewTabIdSchema, + phase: Schema.Literals(["move", "click"]), + x: Schema.Number, + y: Schema.Number, + sequence: Schema.Int, + createdAt: Schema.String, + }); + /** * Static config a renderer needs to mount a preview `<webview>`. Returned * atomically by `DesktopPreviewBridge.getPreviewConfig()` so the renderer @@ -487,6 +533,13 @@ export interface DesktopPreviewWebviewConfig { preloadUrl: string | null; } +export const DesktopPreviewWebviewConfigSchema: Schema.Codec<DesktopPreviewWebviewConfig> = + Schema.Struct({ + partition: Schema.String, + webPreferences: Schema.String, + preloadUrl: Schema.NullOr(Schema.String), + }); + export interface DesktopPreviewAnnotationTheme { colorScheme: "light" | "dark"; radius: string; @@ -507,6 +560,27 @@ export interface DesktopPreviewAnnotationTheme { fontMono: string; } +export const DesktopPreviewAnnotationThemeSchema: Schema.Codec<DesktopPreviewAnnotationTheme> = + Schema.Struct({ + colorScheme: Schema.Literals(["light", "dark"]), + radius: Schema.String, + background: Schema.String, + foreground: Schema.String, + popover: Schema.String, + popoverForeground: Schema.String, + primary: Schema.String, + primaryForeground: Schema.String, + muted: Schema.String, + mutedForeground: Schema.String, + accent: Schema.String, + accentForeground: Schema.String, + border: Schema.String, + input: Schema.String, + ring: Schema.String, + fontSans: Schema.String, + fontMono: Schema.String, + }); + export interface DesktopPreviewRecordingFrame { tabId: string; data: string; @@ -515,6 +589,15 @@ export interface DesktopPreviewRecordingFrame { receivedAt: string; } +export const DesktopPreviewRecordingFrameSchema: Schema.Codec<DesktopPreviewRecordingFrame> = + Schema.Struct({ + tabId: DesktopPreviewTabIdSchema, + data: Schema.String, + width: Schema.Number, + height: Schema.Number, + receivedAt: Schema.String, + }); + export interface DesktopPreviewRecordingArtifact { id: string; tabId: string; @@ -524,6 +607,16 @@ export interface DesktopPreviewRecordingArtifact { createdAt: string; } +export const DesktopPreviewRecordingArtifactSchema: Schema.Codec<DesktopPreviewRecordingArtifact> = + Schema.Struct({ + id: Schema.String, + tabId: DesktopPreviewTabIdSchema, + path: Schema.String, + mimeType: Schema.String, + sizeBytes: Schema.Int, + createdAt: Schema.String, + }); + export interface DesktopPreviewScreenshotArtifact { id: string; tabId: string; @@ -533,6 +626,16 @@ export interface DesktopPreviewScreenshotArtifact { createdAt: string; } +export const DesktopPreviewScreenshotArtifactSchema: Schema.Codec<DesktopPreviewScreenshotArtifact> = + Schema.Struct({ + id: Schema.String, + tabId: DesktopPreviewTabIdSchema, + path: Schema.String, + mimeType: Schema.Literal("image/png"), + sizeBytes: Schema.Int, + createdAt: Schema.String, + }); + /** * Single stack frame captured by react-grab's `getElementContext`. We surface * the source file/line so coding agents can jump straight to the JSX that @@ -545,6 +648,13 @@ export interface PickedElementStackFrame { columnNumber: number | null; } +export const PickedElementStackFrameSchema: Schema.Codec<PickedElementStackFrame> = Schema.Struct({ + functionName: Schema.NullOr(Schema.String), + fileName: Schema.NullOr(Schema.String), + lineNumber: Schema.NullOr(Schema.Number), + columnNumber: Schema.NullOr(Schema.Number), +}); + /** * A successful element pick from the preview webview. All fields are * best-effort — pages that don't ship a React fiber tree (or aren't running @@ -574,6 +684,19 @@ export interface PickedElementPayload { pickedAt: string; } +export const PickedElementPayloadSchema: Schema.Codec<PickedElementPayload> = Schema.Struct({ + pageUrl: Schema.String, + pageTitle: Schema.NullOr(Schema.String), + tagName: Schema.String, + selector: Schema.NullOr(Schema.String), + htmlPreview: Schema.String, + componentName: Schema.NullOr(Schema.String), + source: Schema.NullOr(PickedElementStackFrameSchema), + stack: Schema.Array(PickedElementStackFrameSchema), + styles: Schema.String, + pickedAt: Schema.String, +}); + export interface PreviewAnnotationRect { x: number; y: number; @@ -581,22 +704,47 @@ export interface PreviewAnnotationRect { height: number; } +export const PreviewAnnotationRectSchema: Schema.Codec<PreviewAnnotationRect> = Schema.Struct({ + x: Schema.Number, + y: Schema.Number, + width: Schema.Number, + height: Schema.Number, +}); + export interface PreviewAnnotationPoint { x: number; y: number; } +export const PreviewAnnotationPointSchema: Schema.Codec<PreviewAnnotationPoint> = Schema.Struct({ + x: Schema.Number, + y: Schema.Number, +}); + export interface PreviewAnnotationElementTarget { id: string; element: PickedElementPayload; rect: PreviewAnnotationRect; } +export const PreviewAnnotationElementTargetSchema: Schema.Codec<PreviewAnnotationElementTarget> = + Schema.Struct({ + id: Schema.String, + element: PickedElementPayloadSchema, + rect: PreviewAnnotationRectSchema, + }); + export interface PreviewAnnotationRegionTarget { id: string; rect: PreviewAnnotationRect; } +export const PreviewAnnotationRegionTargetSchema: Schema.Codec<PreviewAnnotationRegionTarget> = + Schema.Struct({ + id: Schema.String, + rect: PreviewAnnotationRectSchema, + }); + export interface PreviewAnnotationStrokeTarget { id: string; color: string; @@ -605,6 +753,15 @@ export interface PreviewAnnotationStrokeTarget { bounds: PreviewAnnotationRect; } +export const PreviewAnnotationStrokeTargetSchema: Schema.Codec<PreviewAnnotationStrokeTarget> = + Schema.Struct({ + id: Schema.String, + color: Schema.String, + width: Schema.Number, + points: Schema.Array(PreviewAnnotationPointSchema), + bounds: PreviewAnnotationRectSchema, + }); + export interface PreviewAnnotationStyleChange { targetId: string; selector: string | null; @@ -613,6 +770,15 @@ export interface PreviewAnnotationStyleChange { value: string; } +export const PreviewAnnotationStyleChangeSchema: Schema.Codec<PreviewAnnotationStyleChange> = + Schema.Struct({ + targetId: Schema.String, + selector: Schema.NullOr(Schema.String), + property: Schema.String, + previousValue: Schema.String, + value: Schema.String, + }); + export interface PreviewAnnotationScreenshot { dataUrl: string; width: number; @@ -620,6 +786,14 @@ export interface PreviewAnnotationScreenshot { cropRect: PreviewAnnotationRect; } +export const PreviewAnnotationScreenshotSchema: Schema.Codec<PreviewAnnotationScreenshot> = + Schema.Struct({ + dataUrl: Schema.String, + width: Schema.Number, + height: Schema.Number, + cropRect: PreviewAnnotationRectSchema, + }); + /** * A submitted preview annotation. One annotation may reference multiple DOM * elements, freeform regions, and ink strokes. The desktop main process adds @@ -638,6 +812,83 @@ export interface PreviewAnnotationPayload { createdAt: string; } +export const PreviewAnnotationPayloadSchema: Schema.Codec<PreviewAnnotationPayload> = Schema.Struct( + { + id: Schema.String, + pageUrl: Schema.String, + pageTitle: Schema.NullOr(Schema.String), + comment: Schema.String, + elements: Schema.Array(PreviewAnnotationElementTargetSchema), + regions: Schema.Array(PreviewAnnotationRegionTargetSchema), + strokes: Schema.Array(PreviewAnnotationStrokeTargetSchema), + styleChanges: Schema.Array(PreviewAnnotationStyleChangeSchema), + screenshot: Schema.NullOr(PreviewAnnotationScreenshotSchema), + createdAt: Schema.String, + }, +); + +export const DesktopPreviewTabInputSchema = Schema.Struct({ + tabId: DesktopPreviewTabIdSchema, +}); + +export const DesktopPreviewRegisterWebviewInputSchema = Schema.Struct({ + tabId: DesktopPreviewTabIdSchema, + webContentsId: Schema.Int.check(Schema.isGreaterThan(0)), +}); + +export const DesktopPreviewNavigateInputSchema = Schema.Struct({ + tabId: DesktopPreviewTabIdSchema, + url: Schema.String, +}); + +export const DesktopPreviewConfigInputSchema = Schema.Struct({ + environmentId: EnvironmentId, +}); + +export const DesktopPreviewAnnotationThemeInputSchema = Schema.Struct({ + theme: DesktopPreviewAnnotationThemeSchema, +}); + +export const DesktopPreviewArtifactInputSchema = Schema.Struct({ + path: Schema.String.check(Schema.isTrimmed()).check(Schema.isNonEmpty()), +}); + +export const DesktopPreviewRecordingSaveInputSchema = Schema.Struct({ + tabId: DesktopPreviewTabIdSchema, + mimeType: Schema.String.check(Schema.isTrimmed()).check(Schema.isNonEmpty()), + data: Schema.Uint8Array, +}); + +export const DesktopPreviewAutomationClickInputSchema = Schema.Struct({ + tabId: DesktopPreviewTabIdSchema, + input: PreviewAutomationClickInput, +}); + +export const DesktopPreviewAutomationTypeInputSchema = Schema.Struct({ + tabId: DesktopPreviewTabIdSchema, + input: PreviewAutomationTypeInput, +}); + +export const DesktopPreviewAutomationPressInputSchema = Schema.Struct({ + tabId: DesktopPreviewTabIdSchema, + input: PreviewAutomationPressInput, +}); + +export const DesktopPreviewAutomationScrollInputSchema = Schema.Struct({ + tabId: DesktopPreviewTabIdSchema, + input: PreviewAutomationScrollInput, +}); + +export const DesktopPreviewAutomationEvaluateInputSchema = Schema.Struct({ + tabId: DesktopPreviewTabIdSchema, + input: PreviewAutomationEvaluateInput, +}); + +export const DesktopPreviewAutomationWaitForInputSchema = Schema.Struct({ + tabId: DesktopPreviewTabIdSchema, + input: PreviewAutomationWaitForInput, +}); + export interface DesktopBridge { getAppBranding: () => DesktopAppBranding | null; getLocalEnvironmentBootstrap: () => DesktopEnvironmentBootstrap | null; From d1f977518bf4c950878d146f72c1559f9398ae4d Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Fri, 12 Jun 2026 17:49:22 -0700 Subject: [PATCH 14/25] Port preview manager to Effect-based browser sessions - derive preview partitions through `BrowserSession` - serialize session state and async preview control flow - update tests for screenshot, automation, and partition behavior --- apps/desktop/src/ipc/methods/preview.ts | 3 +- .../src/preview/BrowserSession.test.ts | 83 + apps/desktop/src/preview/BrowserSession.ts | 104 +- apps/desktop/src/preview/Manager.test.ts | 561 +-- apps/desktop/src/preview/Manager.ts | 3051 +++++++++-------- apps/desktop/src/preview/PickPreload.ts | 2 +- .../preview/PlaywrightInjectedRuntime.test.ts | 28 +- .../src/preview/PlaywrightInjectedRuntime.ts | 81 +- apps/desktop/src/window/DesktopWindow.test.ts | 2 +- .../preview/PreviewAutomationOwner.tsx | 10 +- apps/web/src/reactGrabBoundary.test.ts | 5 +- 11 files changed, 2227 insertions(+), 1703 deletions(-) create mode 100644 apps/desktop/src/preview/BrowserSession.test.ts diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index 395cbad1ced..8adae374ad0 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -1,4 +1,3 @@ -// @effect-diagnostics nodeBuiltinImport:off import { DesktopPreviewAnnotationThemeInputSchema, DesktopPreviewArtifactInputSchema, @@ -195,7 +194,7 @@ export const getPreviewConfig = makeIpcMethod({ const manager = yield* PreviewManager.PreviewManager; yield* manager.getBrowserSession(environmentId); return { - partition: manager.getBrowserPartition(environmentId), + partition: yield* manager.getBrowserPartition(environmentId), webPreferences: PREVIEW_WEBVIEW_PREFERENCES, preloadUrl: pathToFileURL(`${__dirname}/preview-pick-preload.cjs`).href, }; diff --git a/apps/desktop/src/preview/BrowserSession.test.ts b/apps/desktop/src/preview/BrowserSession.test.ts new file mode 100644 index 00000000000..5526e5e0e54 --- /dev/null +++ b/apps/desktop/src/preview/BrowserSession.test.ts @@ -0,0 +1,83 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { beforeEach, vi } from "vite-plus/test"; + +const { fromPartition, sessions } = vi.hoisted(() => ({ + fromPartition: vi.fn(), + sessions: new Map< + string, + { + readonly clearCache: ReturnType<typeof vi.fn>; + readonly clearStorageData: ReturnType<typeof vi.fn>; + readonly getUserAgent: ReturnType<typeof vi.fn>; + readonly setPermissionRequestHandler: ReturnType<typeof vi.fn>; + readonly setUserAgent: ReturnType<typeof vi.fn>; + } + >(), +})); + +vi.mock("electron", () => ({ + session: { + fromPartition, + }, +})); + +import * as BrowserSession from "./BrowserSession.ts"; + +const layer = BrowserSession.layer.pipe(Layer.provide(NodeServices.layer)); + +describe("BrowserSession", () => { + beforeEach(() => { + sessions.clear(); + fromPartition.mockReset(); + fromPartition.mockImplementation((partition: string) => { + const browserSession = { + clearCache: vi.fn(() => Promise.resolve()), + clearStorageData: vi.fn(() => Promise.resolve()), + getUserAgent: vi.fn(() => "Mozilla/5.0 Electron/41.5.0 t3code/0.0.27"), + setPermissionRequestHandler: vi.fn(), + setUserAgent: vi.fn(), + }; + sessions.set(partition, browserSession); + return browserSession; + }); + }); + + it.effect("derives deterministic partitions and memoizes sessions", () => + Effect.gen(function* () { + const browserSessions = yield* BrowserSession.BrowserSession; + + const partition = yield* browserSessions.getPartition("scope-a"); + const first = yield* browserSessions.getSession("scope-a"); + const second = yield* browserSessions.getSession("scope-a"); + + assert.strictEqual(partition, "persist:t3code-preview-f051bb2c68cb7b2fe969"); + assert.strictEqual(first, second); + assert.strictEqual(fromPartition.mock.calls.length, 1); + }).pipe(Effect.provide(layer)), + ); + + it.effect("clears storage and cache for every created session", () => + Effect.gen(function* () { + const browserSessions = yield* BrowserSession.BrowserSession; + yield* browserSessions.getSession("scope-a"); + yield* browserSessions.getSession("scope-b"); + + yield* browserSessions.clearCookies(); + yield* browserSessions.clearCache(); + + assert.strictEqual(sessions.size, 2); + for (const browserSession of sessions.values()) { + assert.strictEqual(browserSession.clearStorageData.mock.calls.length, 1); + assert.deepEqual(browserSession.clearStorageData.mock.calls[0], [ + { + storages: ["cookies", "localstorage", "indexdb", "websql", "serviceworkers"], + }, + ]); + assert.strictEqual(browserSession.clearCache.mock.calls.length, 1); + } + }).pipe(Effect.provide(layer)), + ); +}); diff --git a/apps/desktop/src/preview/BrowserSession.ts b/apps/desktop/src/preview/BrowserSession.ts index 58ca30f3088..ead28c12f9b 100644 --- a/apps/desktop/src/preview/BrowserSession.ts +++ b/apps/desktop/src/preview/BrowserSession.ts @@ -1,10 +1,12 @@ import type { Session } from "electron"; import { session } from "electron"; -import { createHash } from "node:crypto"; import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; +import * as Encoding from "effect/Encoding"; import * as Layer from "effect/Layer"; +import * as SynchronizedRef from "effect/SynchronizedRef"; const PREVIEW_PARTITION_PREFIX = "persist:t3code-preview-"; @@ -18,7 +20,7 @@ export class BrowserSessionError extends Data.TaggedError("BrowserSessionError") } export interface BrowserSessionShape { - readonly getPartition: (scope?: string) => string; + readonly getPartition: (scope?: string) => Effect.Effect<string, BrowserSessionError>; readonly isPartition: (partition: string) => boolean; readonly getSession: (scope?: string) => Effect.Effect<Session, BrowserSessionError>; readonly clearCookies: () => Effect.Effect<void, BrowserSessionError>; @@ -29,19 +31,25 @@ export class BrowserSession extends Context.Service<BrowserSession, BrowserSessi "@t3tools/desktop/preview/BrowserSession", ) {} -const make = Effect.fn("BrowserSession.make")(() => - Effect.sync(() => { - const sessions = new Map<string, Session>(); - const getPartition = (scope = "shared"): string => { - const digest = createHash("sha256").update(scope).digest("hex").slice(0, 20); - return `${PREVIEW_PARTITION_PREFIX}${digest}`; - }; +const make = Effect.gen(function* BrowserSessionMake() { + const crypto = yield* Crypto.Crypto; + const sessionsRef = yield* SynchronizedRef.make<ReadonlyMap<string, Session>>(new Map()); - const getSession = Effect.fn("BrowserSession.getSession")(function* (scope = "shared") { - const partition = getPartition(scope); + const getPartition = Effect.fn("BrowserSession.getPartition")(function* (scope = "shared") { + const digest = yield* crypto + .digest("SHA-256", new TextEncoder().encode(scope)) + .pipe( + Effect.mapError((cause) => new BrowserSessionError({ operation: "getPartition", cause })), + ); + return `${PREVIEW_PARTITION_PREFIX}${Encoding.encodeHex(digest).slice(0, 20)}`; + }); + + const getSession = Effect.fn("BrowserSession.getSession")(function* (scope = "shared") { + const partition = yield* getPartition(scope); + return yield* SynchronizedRef.modifyEffect(sessionsRef, (sessions) => { const existing = sessions.get(partition); - if (existing) return existing; - return yield* Effect.try({ + if (existing) return Effect.succeed([existing, sessions] as const); + return Effect.try({ try: () => { const browserSession = session.fromPartition(partition); const userAgent = browserSession @@ -53,41 +61,47 @@ const make = Effect.fn("BrowserSession.make")(() => const allowed = ["clipboard-read", "clipboard-write", "notifications", "geolocation"]; callback(allowed.includes(permission)); }); - sessions.set(partition, browserSession); - return browserSession; + const next = new Map(sessions); + next.set(partition, browserSession); + return [browserSession, next] as const; }, catch: (cause) => new BrowserSessionError({ operation: "getSession", cause }), }); }); + }); - return BrowserSession.of({ - getPartition, - isPartition: (partition) => partition.startsWith(PREVIEW_PARTITION_PREFIX), - getSession, - clearCookies: Effect.fn("BrowserSession.clearCookies")(function* () { - yield* Effect.tryPromise({ - try: () => - Promise.all( - [...sessions.values()].map((browserSession) => - browserSession.clearStorageData({ - storages: ["cookies", "localstorage", "indexdb", "websql", "serviceworkers"], - }), - ), - ), - catch: (cause) => new BrowserSessionError({ operation: "clearCookies", cause }), - }); - }), - clearCache: Effect.fn("BrowserSession.clearCache")(function* () { - yield* Effect.tryPromise({ - try: () => - Promise.all( - [...sessions.values()].map((browserSession) => browserSession.clearCache()), - ), - catch: (cause) => new BrowserSessionError({ operation: "clearCache", cause }), - }); - }), - }); - }), -); + return BrowserSession.of({ + getPartition, + isPartition: (partition) => partition.startsWith(PREVIEW_PARTITION_PREFIX), + getSession, + clearCookies: Effect.fn("BrowserSession.clearCookies")(function* () { + const sessions = yield* SynchronizedRef.get(sessionsRef); + yield* Effect.all( + [...sessions.values()].map((browserSession) => + Effect.tryPromise({ + try: () => + browserSession.clearStorageData({ + storages: ["cookies", "localstorage", "indexdb", "websql", "serviceworkers"], + }), + catch: (cause) => new BrowserSessionError({ operation: "clearCookies", cause }), + }), + ), + { concurrency: "unbounded", discard: true }, + ); + }), + clearCache: Effect.fn("BrowserSession.clearCache")(function* () { + const sessions = yield* SynchronizedRef.get(sessionsRef); + yield* Effect.all( + [...sessions.values()].map((browserSession) => + Effect.tryPromise({ + try: () => browserSession.clearCache(), + catch: (cause) => new BrowserSessionError({ operation: "clearCache", cause }), + }), + ), + { concurrency: "unbounded", discard: true }, + ); + }), + }); +}).pipe(Effect.withSpan("BrowserSession.make")); -export const layer = Layer.effect(BrowserSession, make()); +export const layer = Layer.effect(BrowserSession, make); diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 7e07d556783..ac32d74ec69 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -1,14 +1,30 @@ -import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { it as effectIt } from "@effect/vitest"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import type * as Scope from "effect/Scope"; +import { TestClock } from "effect/testing"; +import { beforeEach, describe, expect, vi } from "vite-plus/test"; -const fromId = vi.fn(() => null); -const mkdir = vi.fn(async () => undefined); -const writeFile = vi.fn(async () => undefined); -const showItemInFolder = vi.fn(); -const writeImage = vi.fn(); -const createFromPath = vi.fn(() => ({ isEmpty: () => false })); -const webviewSend = vi.fn(); +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as BrowserSession from "./BrowserSession.ts"; +import * as PreviewManager from "./Manager.ts"; -vi.mock("node:fs/promises", () => ({ mkdir, writeFile })); +const { createFromPath, fromId, mkdir, showItemInFolder, webviewSend, writeFile, writeImage } = + vi.hoisted(() => ({ + createFromPath: vi.fn(() => ({ isEmpty: () => false })), + fromId: vi.fn(() => null), + mkdir: vi.fn((_path: string) => undefined), + showItemInFolder: vi.fn(), + webviewSend: vi.fn(), + writeFile: vi.fn((_path: string, _data: Uint8Array) => undefined), + writeImage: vi.fn(), + })); vi.mock("electron", () => ({ clipboard: { @@ -17,18 +33,64 @@ vi.mock("electron", () => ({ nativeImage: { createFromPath, }, - session: { - fromPartition: vi.fn(), - }, shell: { showItemInFolder, }, + session: { + fromPartition: vi.fn(), + }, webContents: { fromId, }, })); -describe("PreviewViewManager automation status", () => { +const browserSessionLayer = Layer.succeed( + BrowserSession.BrowserSession, + BrowserSession.BrowserSession.of({ + getPartition: () => Effect.succeed("persist:t3code-preview-test"), + isPartition: (partition) => partition.startsWith("persist:t3code-preview-"), + getSession: () => Effect.die("unexpected getSession"), + clearCookies: () => Effect.void, + clearCache: () => Effect.void, + }), +); + +const environmentLayer = Layer.succeed( + DesktopEnvironment.DesktopEnvironment, + DesktopEnvironment.DesktopEnvironment.of({ + browserArtifactsDir: "/tmp/t3/dev/browser-artifacts", + } as DesktopEnvironment.DesktopEnvironmentShape), +); + +const fileSystemLayer = FileSystem.layerNoop({ + makeDirectory: (path) => + Effect.sync(() => { + mkdir(path); + }), + writeFile: (path, data) => + Effect.sync(() => { + writeFile(path, data); + }), +}); + +const layer = PreviewManager.layer.pipe( + Layer.provideMerge(browserSessionLayer), + Layer.provideMerge(environmentLayer), + Layer.provideMerge(fileSystemLayer), + Layer.provideMerge(Path.layer), +); + +const withManager = <A>( + use: ( + manager: PreviewManager.PreviewManagerShape, + ) => Effect.Effect<A, PreviewManager.PreviewManagerError, Scope.Scope>, +) => + Effect.gen(function* () { + const manager = yield* PreviewManager.PreviewManager; + return yield* use(manager); + }).pipe(Effect.provide(layer), Effect.scoped); + +describe("PreviewManager", () => { beforeEach(() => { fromId.mockClear(); mkdir.mockClear(); @@ -39,245 +101,274 @@ describe("PreviewViewManager automation status", () => { webviewSend.mockClear(); }); - it("reports an unregistered webview as temporarily unavailable", async () => { - const { PreviewViewManager } = (await import("./Manager.ts")).__testing; - const manager = new PreviewViewManager(); + effectIt.effect("reports an unregistered webview as temporarily unavailable", () => + withManager((manager) => + Effect.gen(function* () { + expect(yield* manager.automationStatus("tab_1")).toEqual({ + available: false, + visible: true, + tabId: "tab_1", + url: null, + title: null, + loading: false, + }); - expect(manager.automationStatus("tab_1")).toEqual({ - available: false, - visible: true, - tabId: "tab_1", - url: null, - title: null, - loading: false, - }); + yield* manager.createTab("tab_1"); - manager.createTab("tab_1"); - - expect(manager.automationStatus("tab_1")).toEqual({ - available: false, - visible: true, - tabId: "tab_1", - url: null, - title: null, - loading: false, - }); - expect(fromId).not.toHaveBeenCalled(); - }); - - it("captures a PNG screenshot into browser artifacts", async () => { - const png = Buffer.from("preview-png"); - const capturePage = vi.fn(async () => ({ toPNG: () => png })); - const listeners = new Map<string, (...args: never[]) => void>(); - fromId.mockReturnValue({ - id: 42, - isDestroyed: () => false, - getType: () => "webview", - getURL: () => "https://example.com:8443/path?query=value", - getTitle: () => "Example", - isLoading: () => false, - getZoomFactor: () => 1, - setZoomFactor: vi.fn(), - on: vi.fn((event: string, listener: (...args: never[]) => void) => { - listeners.set(event, listener); + expect(yield* manager.automationStatus("tab_1")).toEqual({ + available: false, + visible: true, + tabId: "tab_1", + url: null, + title: null, + loading: false, + }); + expect(fromId).not.toHaveBeenCalled(); }), - off: vi.fn(), - ipc: { on: vi.fn(), off: vi.fn() }, - send: webviewSend, - navigationHistory: { canGoBack: () => false, canGoForward: () => false }, - setWindowOpenHandler: vi.fn(), - debugger: { - isAttached: () => false, - attach: vi.fn(), - sendCommand: vi.fn(async () => undefined), - on: vi.fn(), - off: vi.fn(), - }, - capturePage, - } as never); - const { PreviewViewManager } = (await import("./Manager.ts")).__testing; - const manager = new PreviewViewManager(); - manager.configureArtifactDirectory("/tmp/t3/dev/browser-artifacts"); - manager.createTab("tab_1"); - manager.registerWebview("tab_1", 42); + ), + ); - expect(webviewSend).toHaveBeenCalledWith( - "preview:annotation-theme", - expect.objectContaining({ - colorScheme: "light", - primary: "oklch(0.488 0.217 264)", - }), - ); + effectIt.effect("captures a PNG screenshot into browser artifacts", () => + withManager((manager) => + Effect.gen(function* () { + const png = Buffer.from("preview-png"); + const capturePage = vi.fn(async () => ({ toPNG: () => png })); + const listeners = new Map<string, (...args: never[]) => void>(); + fromId.mockReturnValue({ + id: 42, + isDestroyed: () => false, + getType: () => "webview", + getURL: () => "https://example.com:8443/path?query=value", + getTitle: () => "Example", + isLoading: () => false, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + on: vi.fn((event: string, listener: (...args: never[]) => void) => { + listeners.set(event, listener); + }), + off: vi.fn(), + ipc: { on: vi.fn(), off: vi.fn() }, + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand: vi.fn(async () => undefined), + on: vi.fn(), + off: vi.fn(), + }, + capturePage, + } as never); - const artifact = await manager.captureScreenshot("tab_1"); + yield* manager.createTab("tab_1"); + yield* manager.registerWebview("tab_1", 42); - expect(capturePage).toHaveBeenCalledOnce(); - expect(mkdir).toHaveBeenCalledWith("/tmp/t3/dev/browser-artifacts", { - recursive: true, - }); - expect(writeFile).toHaveBeenCalledWith(artifact.path, png); - expect(artifact).toMatchObject({ - tabId: "tab_1", - mimeType: "image/png", - sizeBytes: png.byteLength, - }); - expect(artifact.path).toMatch( - /\/browser-artifacts\/browser-screenshot-example-com-[^.]+\.png$/, - ); - }); + expect(webviewSend).toHaveBeenCalledWith( + "preview:annotation-theme", + expect.objectContaining({ + colorScheme: "light", + primary: "oklch(0.488 0.217 264)", + }), + ); - it("reveals only files inside the configured browser artifact directory", async () => { - const { PreviewViewManager } = (await import("./Manager.ts")).__testing; - const manager = new PreviewViewManager(); - manager.configureArtifactDirectory("/tmp/t3/dev/browser-artifacts"); + const artifact = yield* manager.captureScreenshot("tab_1"); - manager.revealArtifact("/tmp/t3/dev/browser-artifacts/browser-screenshot-test.png"); + expect(capturePage).toHaveBeenCalledOnce(); + expect(mkdir).toHaveBeenCalledWith("/tmp/t3/dev/browser-artifacts"); + expect(writeFile).toHaveBeenCalledWith(artifact.path, png); + expect(artifact).toMatchObject({ + tabId: "tab_1", + mimeType: "image/png", + sizeBytes: png.byteLength, + }); + expect(artifact.path).toMatch( + /\/browser-artifacts\/browser-screenshot-example-com-[^.]+\.png$/, + ); + }), + ), + ); - expect(showItemInFolder).toHaveBeenCalledWith( - "/tmp/t3/dev/browser-artifacts/browser-screenshot-test.png", - ); - expect(() => manager.revealArtifact("/tmp/t3/dev/settings.json")).toThrow( - "outside the configured artifact directory", - ); - }); + effectIt.effect("reveals only files inside the configured browser artifact directory", () => + withManager((manager) => + Effect.gen(function* () { + yield* manager.revealArtifact("/tmp/t3/dev/browser-artifacts/browser-screenshot-test.png"); - it("copies screenshot artifacts to the system clipboard", async () => { - const { PreviewViewManager } = (await import("./Manager.ts")).__testing; - const manager = new PreviewViewManager(); - manager.configureArtifactDirectory("/tmp/t3/dev/browser-artifacts"); - const artifactPath = "/tmp/t3/dev/browser-artifacts/browser-screenshot-test.png"; + expect(showItemInFolder).toHaveBeenCalledWith( + "/tmp/t3/dev/browser-artifacts/browser-screenshot-test.png", + ); + const exit = yield* Effect.exit(manager.revealArtifact("/tmp/t3/dev/settings.json")); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isSuccess(exit)) return; + const error = Option.getOrThrow(Cause.findErrorOption(exit.cause)); + expect(error.cause).toMatchObject({ + message: "Preview artifact path is outside the configured artifact directory.", + }); + }), + ), + ); - manager.copyArtifactToClipboard(artifactPath); + effectIt.effect("copies screenshot artifacts to the system clipboard", () => + withManager((manager) => + Effect.gen(function* () { + const artifactPath = "/tmp/t3/dev/browser-artifacts/browser-screenshot-test.png"; - expect(createFromPath).toHaveBeenCalledWith(artifactPath); - expect(writeImage).toHaveBeenCalledOnce(); - expect(() => manager.copyArtifactToClipboard("/tmp/t3/dev/settings.json")).toThrow( - "outside the configured artifact directory", - ); - }); + yield* manager.copyArtifactToClipboard(artifactPath); + + expect(createFromPath).toHaveBeenCalledWith(artifactPath); + expect(writeImage).toHaveBeenCalledOnce(); + const exit = yield* Effect.exit( + manager.copyArtifactToClipboard("/tmp/t3/dev/settings.json"), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isSuccess(exit)) return; + const error = Option.getOrThrow(Cause.findErrorOption(exit.cause)); + expect(error.cause).toMatchObject({ + message: "Preview artifact path is outside the configured artifact directory.", + }); + }), + ), + ); - it("emits the resolved pointer target before dispatching an automation click", async () => { - let humanInput: ((_event: unknown, signal: unknown) => void) | undefined; - const activity: string[] = []; - const sendCommand = vi.fn(async (method: string, params?: Record<string, unknown>) => { - if (method === "Runtime.evaluate") { - return { - result: { - value: { width: 800, height: 600 }, + effectIt.effect("emits the resolved pointer target before dispatching an automation click", () => + withManager((manager) => + Effect.gen(function* () { + let humanInput: ((_event: unknown, signal: unknown) => void) | undefined; + const activity: string[] = []; + const sendCommand = vi.fn(async (method: string, params?: Record<string, unknown>) => { + if (method === "Runtime.evaluate") { + return { + result: { + value: { width: 800, height: 600 }, + }, + }; + } + if (method === "Input.dispatchMouseEvent" && params?.type === "mousePressed") { + activity.push("mousePressed"); + humanInput?.({}, { kind: "pointer", x: params.x, y: params.y, button: 0 }); + } + return undefined; + }); + fromId.mockReturnValue({ + id: 42, + isDestroyed: () => false, + getType: () => "webview", + getURL: () => "https://example.com", + getTitle: () => "Example", + isLoading: () => false, + isDevToolsOpened: () => false, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + on: vi.fn(), + off: vi.fn(), + ipc: { + on: vi.fn((channel: string, listener: typeof humanInput) => { + if (channel === "preview:human-input") humanInput = listener; + }), + off: vi.fn(), }, - }; - } - if (method === "Input.dispatchMouseEvent" && params?.type === "mousePressed") { - activity.push("mousePressed"); - humanInput?.({}, { kind: "pointer", x: params.x, y: params.y, button: 0 }); - } - return undefined; - }); - fromId.mockReturnValue({ - id: 42, - isDestroyed: () => false, - getType: () => "webview", - getURL: () => "https://example.com", - getTitle: () => "Example", - isLoading: () => false, - isDevToolsOpened: () => false, - getZoomFactor: () => 1, - setZoomFactor: vi.fn(), - on: vi.fn(), - off: vi.fn(), - ipc: { - on: vi.fn((channel: string, listener: typeof humanInput) => { - if (channel === "preview:human-input") humanInput = listener; - }), - off: vi.fn(), - }, - send: webviewSend, - navigationHistory: { canGoBack: () => false, canGoForward: () => false }, - setWindowOpenHandler: vi.fn(), - debugger: { - isAttached: () => false, - attach: vi.fn(), - sendCommand, - on: vi.fn(), - off: vi.fn(), - }, - } as never); - const { PreviewViewManager } = (await import("./Manager.ts")).__testing; - const manager = new PreviewViewManager(); - manager.onPointerEvent((event) => activity.push(event.phase)); - manager.createTab("tab_1"); - manager.registerWebview("tab_1", 42); + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand, + on: vi.fn(), + off: vi.fn(), + }, + } as never); - await manager.automationClick("tab_1", { x: 120, y: 80 }); + yield* manager.subscribePointerEvents((event) => activity.push(event.phase)); + yield* manager.createTab("tab_1"); + yield* manager.registerWebview("tab_1", 42); + const click = yield* manager + .automationClick("tab_1", { x: 120, y: 80 }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* TestClock.adjust(200); + yield* Fiber.join(click); - expect(activity).toEqual(["move", "click", "mousePressed"]); - expect(sendCommand).toHaveBeenCalledWith("Input.dispatchMouseEvent", { - type: "mousePressed", - x: 120, - y: 80, - button: "left", - clickCount: 1, - }); - expect(sendCommand).toHaveBeenCalledWith("Input.dispatchMouseEvent", { - type: "mouseReleased", - x: 120, - y: 80, - button: "left", - clickCount: 1, - }); - }); + expect(activity).toEqual(["move", "click", "mousePressed"]); + expect(sendCommand).toHaveBeenCalledWith("Input.dispatchMouseEvent", { + type: "mousePressed", + x: 120, + y: 80, + button: "left", + clickCount: 1, + }); + expect(sendCommand).toHaveBeenCalledWith("Input.dispatchMouseEvent", { + type: "mouseReleased", + x: 120, + y: 80, + button: "left", + clickCount: 1, + }); + }), + ), + ); - it("still interrupts agent control for a different human pointer event", async () => { - let humanInput: ((_event: unknown, signal: unknown) => void) | undefined; - const sendCommand = vi.fn(async (method: string) => { - if (method === "Runtime.evaluate") { - return { - result: { - value: { width: 800, height: 600 }, + effectIt.effect("still interrupts agent control for a different human pointer event", () => + withManager((manager) => + Effect.gen(function* () { + let humanInput: ((_event: unknown, signal: unknown) => void) | undefined; + const sendCommand = vi.fn(async (method: string) => { + if (method === "Runtime.evaluate") { + return { + result: { + value: { width: 800, height: 600 }, + }, + }; + } + if (method === "Input.dispatchMouseEvent") { + humanInput?.({}, { kind: "pointer", x: 400, y: 300, button: 0 }); + } + return undefined; + }); + fromId.mockReturnValue({ + id: 42, + isDestroyed: () => false, + getType: () => "webview", + getURL: () => "https://example.com", + getTitle: () => "Example", + isLoading: () => false, + isDevToolsOpened: () => false, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + on: vi.fn(), + off: vi.fn(), + ipc: { + on: vi.fn((channel: string, listener: typeof humanInput) => { + if (channel === "preview:human-input") humanInput = listener; + }), + off: vi.fn(), + }, + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand, + on: vi.fn(), + off: vi.fn(), }, - }; - } - if (method === "Input.dispatchMouseEvent") { - humanInput?.({}, { kind: "pointer", x: 400, y: 300, button: 0 }); - } - return undefined; - }); - fromId.mockReturnValue({ - id: 42, - isDestroyed: () => false, - getType: () => "webview", - getURL: () => "https://example.com", - getTitle: () => "Example", - isLoading: () => false, - isDevToolsOpened: () => false, - getZoomFactor: () => 1, - setZoomFactor: vi.fn(), - on: vi.fn(), - off: vi.fn(), - ipc: { - on: vi.fn((channel: string, listener: typeof humanInput) => { - if (channel === "preview:human-input") humanInput = listener; - }), - off: vi.fn(), - }, - send: webviewSend, - navigationHistory: { canGoBack: () => false, canGoForward: () => false }, - setWindowOpenHandler: vi.fn(), - debugger: { - isAttached: () => false, - attach: vi.fn(), - sendCommand, - on: vi.fn(), - off: vi.fn(), - }, - } as never); - const { PreviewViewManager } = (await import("./Manager.ts")).__testing; - const manager = new PreviewViewManager(); - manager.createTab("tab_1"); - manager.registerWebview("tab_1", 42); + } as never); - await expect(manager.automationClick("tab_1", { x: 120, y: 80 })).rejects.toMatchObject({ - name: "PreviewAutomationControlInterruptedError", - }); - }); + yield* manager.createTab("tab_1"); + yield* manager.registerWebview("tab_1", 42); + + const click = yield* manager + .automationClick("tab_1", { x: 120, y: 80 }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* TestClock.adjust(200); + const exit = yield* Fiber.await(click); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isSuccess(exit)) return; + const error = Option.getOrThrow(Cause.findErrorOption(exit.cause)); + expect(error.cause).toMatchObject({ + name: "PreviewAutomationControlInterruptedError", + }); + }), + ), + ); }); diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 5c62179bf6f..a55283fbebb 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -1,7 +1,5 @@ -// @effect-diagnostics globalDate:off -// @effect-diagnostics nodeBuiltinImport:off /** - * PreviewViewManager — desktop side of the in-app browser preview. + * Desktop side of the in-app browser preview. * * Hosts per-tab Chromium WebContents references (the actual <webview> * elements live in the renderer; we only attach listeners and forward state @@ -36,14 +34,22 @@ import { shell, webContents, } from "electron"; -import { mkdir, writeFile } from "node:fs/promises"; -import { isAbsolute, join, relative, resolve, sep } from "node:path"; -import { setTimeout as sleep } from "node:timers/promises"; +import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Data from "effect/Data"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; import type * as Scope from "effect/Scope"; +import * as SynchronizedRef from "effect/SynchronizedRef"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as BrowserSession from "./BrowserSession.ts"; @@ -96,6 +102,7 @@ const DIAGNOSTIC_BUFFER_LIMIT = 200; const MAX_ARTIFACT_SITE_SLUG_LENGTH = 80; const AGENT_CURSOR_MOVE_MS = 160; const AGENT_CURSOR_CLICK_LEAD_MS = 40; +const encodeUnknownJson = Schema.encodeUnknownEffect(Schema.UnknownFromJsonString); const DEFAULT_ANNOTATION_THEME: DesktopPreviewAnnotationTheme = { colorScheme: "light", radius: "0.625rem", @@ -187,35 +194,41 @@ const normalizeCaptureRect = (value: unknown): PreviewAnnotationRect | null => { }; }; -const captureAnnotationScreenshot = async ( +const captureAnnotationScreenshot = ( wc: Electron.WebContents, cropRect: PreviewAnnotationRect | null, -) => { - const image = await wc.capturePage( - cropRect - ? { - x: cropRect.x, - y: cropRect.y, - width: cropRect.width, - height: cropRect.height, - } - : undefined, +): Effect.Effect<PreviewAnnotationPayload["screenshot"], PreviewManagerError> => + Effect.tryPromise({ + try: () => + wc.capturePage( + cropRect + ? { + x: cropRect.x, + y: cropRect.y, + width: cropRect.width, + height: cropRect.height, + } + : undefined, + ), + catch: (cause) => new PreviewManagerError({ operation: "captureAnnotationScreenshot", cause }), + }).pipe( + Effect.map((image) => { + const size = image.getSize(); + return { + dataUrl: image.toDataURL(), + width: size.width, + height: size.height, + cropRect: cropRect ?? { x: 0, y: 0, width: size.width, height: size.height }, + }; + }), ); - const size = image.getSize(); - return { - dataUrl: image.toDataURL(), - width: size.width, - height: size.height, - cropRect: cropRect ?? { x: 0, y: 0, width: size.width, height: size.height }, - }; -}; const findZoomStep = (current: number): number => { - for (let index = 0; index < ZOOM_LEVELS.length; index += 1) { - if (Math.abs(ZOOM_LEVELS[index]! - current) < ZOOM_EPSILON) return index; - if (ZOOM_LEVELS[index]! > current) return index - 1; - } - return ZOOM_LEVELS.length - 1; + const index = ZOOM_LEVELS.findIndex( + (level) => Math.abs(level - current) < ZOOM_EPSILON || level > current, + ); + if (index < 0) return ZOOM_LEVELS.length - 1; + return Math.abs(ZOOM_LEVELS[index]! - current) < ZOOM_EPSILON ? index : index - 1; }; const nextZoomLevel = (current: number, direction: "in" | "out"): number => { @@ -241,14 +254,12 @@ interface ManagedListeners { } interface PickSession { - readonly resolve: (payload: PreviewAnnotationPayload | null) => void; - readonly cleanup: () => void; + readonly cancel: Effect.Effect<void>; } interface BrowserControlSession { readonly webContentsId: number; - tail: Promise<void>; - initialized: Promise<void>; + readonly semaphore: Semaphore.Semaphore; readonly onMessage: ( event: Electron.Event, method: string, @@ -257,9 +268,9 @@ interface BrowserControlSession { } interface BrowserDiagnostics { - readonly consoleEntries: PreviewAutomationConsoleEntry[]; - readonly networkEntries: PreviewAutomationNetworkEntry[]; - readonly requests: Map<string, { url: string; method: string }>; + readonly consoleEntries: ReadonlyArray<PreviewAutomationConsoleEntry>; + readonly networkEntries: ReadonlyArray<PreviewAutomationNetworkEntry>; + readonly requests: ReadonlyMap<string, { url: string; method: string }>; } type PointerEventListener = (event: DesktopPreviewPointerEvent) => void; @@ -323,107 +334,796 @@ const inputSignalsMatch = (left: PreviewInputSignal, right: PreviewInputSignal): ); }; -class PreviewViewManager { - private annotationTheme = DEFAULT_ANNOTATION_THEME; - private artifactDirectory: string | null = null; - private mainWindow: BrowserWindow | null = null; - private readonly tabs = new Map<string, PreviewTabState>(); - private readonly attached = new Map<number, ManagedListeners>(); - private readonly listeners = new Set<Listener>(); - private readonly pointerEventListeners = new Set<PointerEventListener>(); - private readonly recordingFrameListeners = new Set<RecordingFrameListener>(); - /** In-flight preview annotation sessions, keyed by tabId. */ - private readonly pickSessions = new Map<string, PickSession>(); - /** One long-lived CDP attachment and serialized command queue per guest. */ - private readonly controlSessions = new Map<number, BrowserControlSession>(); - private readonly diagnostics = new Map<number, BrowserDiagnostics>(); - private readonly expectedAgentInputs = new Map<string, ExpectedAgentInput[]>(); - private readonly controlEpoch = new Map<string, number>(); - private readonly actionTimeline = new Map<string, PreviewAutomationActionEvent[]>(); - private actionSequence = 0; - private pointerSequence = 0; - private recordingTabId: string | null = null; - - configureArtifactDirectory(directory: string): void { - this.artifactDirectory = resolve(directory); - } +const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function* ( + artifactDirectory: string, +) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const context = yield* Effect.context<never>(); + const runFork = Effect.runForkWith(context); + const resolvedArtifactDirectory = path.resolve(artifactDirectory); + const playwrightInstallExpression = yield* Effect.cached( + playwrightInjectedRuntimeInstallExpression().pipe( + Effect.mapError( + (cause) => + new PreviewManagerError({ + operation: "ensurePlaywrightInjected", + cause, + }), + ), + ), + ); + + const annotationThemeRef = yield* Ref.make(DEFAULT_ANNOTATION_THEME); + const mainWindowRef = yield* Ref.make<Option.Option<BrowserWindow>>(Option.none()); + const tabsRef = yield* SynchronizedRef.make<ReadonlyMap<string, PreviewTabState>>(new Map()); + const attachedRef = yield* Ref.make<ReadonlyMap<number, ManagedListeners>>(new Map()); + const listenersRef = yield* Ref.make<ReadonlySet<Listener>>(new Set()); + const pointerEventListenersRef = yield* Ref.make<ReadonlySet<PointerEventListener>>(new Set()); + const recordingFrameListenersRef = yield* Ref.make<ReadonlySet<RecordingFrameListener>>( + new Set(), + ); + const pickSessionsRef = yield* Ref.make<ReadonlyMap<string, PickSession>>(new Map()); + const controlSessionsRef = yield* SynchronizedRef.make< + ReadonlyMap<number, BrowserControlSession> + >(new Map()); + const diagnosticsRef = yield* Ref.make<ReadonlyMap<number, BrowserDiagnostics>>(new Map()); + const expectedAgentInputsRef = yield* Ref.make< + ReadonlyMap<string, ReadonlyArray<ExpectedAgentInput>> + >(new Map()); + const controlEpochRef = yield* Ref.make<ReadonlyMap<string, number>>(new Map()); + const actionTimelineRef = yield* Ref.make< + ReadonlyMap<string, ReadonlyArray<PreviewAutomationActionEvent>> + >(new Map()); + const actionSequenceRef = yield* Ref.make(0); + const pointerSequenceRef = yield* Ref.make(0); + const recordingTabIdRef = yield* Ref.make<Option.Option<string>>(Option.none()); + + const fail = (operation: string, cause: unknown): PreviewManagerError => + new PreviewManagerError({ operation, cause }); + const attempt = <A>(operation: string, evaluate: () => A) => + Effect.try({ try: evaluate, catch: (cause) => fail(operation, cause) }); + const attemptPromise = <A>(operation: string, evaluate: () => PromiseLike<A>) => + Effect.tryPromise({ try: evaluate, catch: (cause) => fail(operation, cause) }); + const currentIso = DateTime.now.pipe(Effect.map(DateTime.formatIso)); + const currentMillis = Clock.currentTimeMillis; + const encodeJson = (operation: string, value: unknown) => + encodeUnknownJson(value).pipe(Effect.mapError((cause) => fail(operation, cause))); + const nextCounter = (ref: Ref.Ref<number>) => + Ref.modify(ref, (value) => [value, value + 1] as const); + const replaceMap = <K, V>( + source: ReadonlyMap<K, V>, + update: (copy: Map<K, V>) => void, + ): ReadonlyMap<K, V> => { + const copy = new Map(source); + update(copy); + return copy; + }; - private requireArtifactDirectory(): string { - if (!this.artifactDirectory) { - throw new Error("Preview artifact directory is not configured."); + const emit = Effect.fn("PreviewManager.emit")(function* (tabId: string, state: PreviewTabState) { + const listeners = yield* Ref.get(listenersRef); + yield* Effect.forEach( + listeners, + (listener) => Effect.sync(() => listener(tabId, state)).pipe(Effect.ignore), + { discard: true }, + ); + }); + + const update = Effect.fn("PreviewManager.update")(function* ( + tabId: string, + patch: Partial<PreviewTabState>, + ) { + const updatedAt = yield* currentIso; + const next = yield* SynchronizedRef.modify(tabsRef, (tabs) => { + const current = tabs.get(tabId); + if (!current) return [Option.none<PreviewTabState>(), tabs] as const; + const state: PreviewTabState = { ...current, ...patch, updatedAt }; + return [ + Option.some(state), + replaceMap(tabs, (copy) => { + copy.set(tabId, state); + }), + ] as const; + }); + if (Option.isSome(next)) yield* emit(tabId, next.value); + }); + + const requireWebContents = Effect.fn("PreviewManager.requireWebContents")(function* ( + tabId: string, + ) { + const tabs = yield* SynchronizedRef.get(tabsRef); + const tab = tabs.get(tabId); + if (!tab) return yield* fail("requireWebContents", new PreviewTabNotFoundError(tabId)); + if (tab.webContentsId == null) { + return yield* fail("requireWebContents", new PreviewWebviewNotInitializedError(tabId)); } - return this.artifactDirectory; - } + const wc = webContents.fromId(tab.webContentsId); + if (!wc) { + return yield* fail( + "requireWebContents", + new PreviewWebContentsNotFoundError(tabId, tab.webContentsId), + ); + } + return wc; + }); - private resolveArtifactPath(path: string): string { - const directory = this.requireArtifactDirectory(); - const resolvedPath = resolve(path); - const relativePath = relative(directory, resolvedPath); - if ( - relativePath.length === 0 || - relativePath === ".." || - relativePath.startsWith(`..${sep}`) || - isAbsolute(relativePath) + const resolveArtifactPath = (artifactPath: string) => + attempt("resolveArtifactPath", () => { + const resolvedPath = path.resolve(artifactPath); + const relativePath = path.relative(resolvedArtifactDirectory, resolvedPath); + if ( + relativePath.length === 0 || + relativePath === ".." || + relativePath.startsWith(`..${path.sep}`) || + path.isAbsolute(relativePath) + ) { + return null; + } + return resolvedPath; + }).pipe( + Effect.flatMap((resolvedPath) => + resolvedPath === null + ? Effect.fail( + fail( + "resolveArtifactPath", + new Error("Preview artifact path is outside the configured artifact directory."), + ), + ) + : Effect.succeed(resolvedPath), + ), + ); + + const tabIdForWebContents = Effect.fn("PreviewManager.tabIdForWebContents")(function* ( + webContentsId: number, + ) { + const tabs = yield* SynchronizedRef.get(tabsRef); + return ( + Array.from(tabs.entries()).find(([, tab]) => tab.webContentsId === webContentsId)?.[0] ?? null + ); + }); + + const pushBounded = <A>(buffer: ReadonlyArray<A>, entry: A): ReadonlyArray<A> => + [...buffer, entry].slice(-DIAGNOSTIC_BUFFER_LIMIT); + + const captureDiagnosticMessage = Effect.fn("PreviewManager.captureDiagnosticMessage")(function* ( + webContentsId: number, + method: string, + params: Record<string, unknown>, + ) { + const timestamp = yield* currentIso; + yield* Ref.update(diagnosticsRef, (allDiagnostics) => { + const current = allDiagnostics.get(webContentsId); + if (!current) return allDiagnostics; + const requestId = typeof params["requestId"] === "string" ? params["requestId"] : null; + const next = (() => { + if (method === "Runtime.consoleAPICalled") { + const args = Array.isArray(params["args"]) ? params["args"] : []; + const text = args + .map((arg) => { + if (typeof arg !== "object" || arg === null) return String(arg); + const value = arg as Record<string, unknown>; + return String(value["value"] ?? value["description"] ?? ""); + }) + .join(" "); + return { + ...current, + consoleEntries: pushBounded(current.consoleEntries, { + level: typeof params["type"] === "string" ? params["type"] : "log", + text, + timestamp, + source: "console", + }), + }; + } + if (method === "Runtime.exceptionThrown") { + const details = + typeof params["exceptionDetails"] === "object" && params["exceptionDetails"] !== null + ? (params["exceptionDetails"] as Record<string, unknown>) + : {}; + return { + ...current, + consoleEntries: pushBounded(current.consoleEntries, { + level: "error", + text: String(details["text"] ?? "Uncaught exception"), + timestamp, + source: "exception", + }), + }; + } + if (method === "Log.entryAdded") { + const entry = + typeof params["entry"] === "object" && params["entry"] !== null + ? (params["entry"] as Record<string, unknown>) + : {}; + return { + ...current, + consoleEntries: pushBounded(current.consoleEntries, { + level: typeof entry["level"] === "string" ? entry["level"] : "info", + text: String(entry["text"] ?? ""), + timestamp, + source: typeof entry["source"] === "string" ? entry["source"] : "log", + }), + }; + } + if (method === "Network.requestWillBeSent" && requestId) { + const request = + typeof params["request"] === "object" && params["request"] !== null + ? (params["request"] as Record<string, unknown>) + : {}; + return { + ...current, + requests: replaceMap(current.requests, (copy) => { + copy.set(requestId, { + url: String(request["url"] ?? ""), + method: String(request["method"] ?? "GET"), + }); + }), + }; + } + if (method === "Network.responseReceived" && requestId) { + const request = current.requests.get(requestId); + const response = + typeof params["response"] === "object" && params["response"] !== null + ? (params["response"] as Record<string, unknown>) + : {}; + const status = typeof response["status"] === "number" ? response["status"] : null; + return request && status !== null && status >= 400 + ? { + ...current, + networkEntries: pushBounded(current.networkEntries, { + ...request, + status, + failed: true, + timestamp, + }), + } + : current; + } + if (method === "Network.loadingFailed" && requestId) { + const request = current.requests.get(requestId); + return { + ...current, + requests: replaceMap(current.requests, (copy) => { + copy.delete(requestId); + }), + networkEntries: request + ? pushBounded(current.networkEntries, { + ...request, + status: null, + failed: true, + errorText: String(params["errorText"] ?? "Network request failed"), + timestamp, + }) + : current.networkEntries, + }; + } + if (method === "Network.loadingFinished" && requestId) { + return { + ...current, + requests: replaceMap(current.requests, (copy) => { + copy.delete(requestId); + }), + }; + } + return current; + })(); + return replaceMap(allDiagnostics, (copy) => { + copy.set(webContentsId, next); + }); + }); + }); + + const detachControlSession = Effect.fn("PreviewManager.detachControlSession")(function* ( + webContentsId: number, + ) { + const control = yield* SynchronizedRef.modify(controlSessionsRef, (sessions) => [ + sessions.get(webContentsId), + replaceMap(sessions, (copy) => { + copy.delete(webContentsId); + }), + ]); + yield* Ref.update(diagnosticsRef, (diagnostics) => + replaceMap(diagnostics, (copy) => { + copy.delete(webContentsId); + }), + ); + const wc = webContents.fromId(webContentsId); + if (!wc || wc.isDestroyed()) return; + if (control) wc.debugger.off("message", control.onMessage); + if (!wc.debugger.isAttached()) return; + yield* attempt("detachControlSession", () => wc.debugger.detach()).pipe(Effect.ignore); + }); + + const ensureControlSession = Effect.fn("PreviewManager.ensureControlSession")(function* ( + wc: Electron.WebContents, + ) { + return yield* SynchronizedRef.modifyEffect(controlSessionsRef, (sessions) => { + const existing = sessions.get(wc.id); + if (existing) return Effect.succeed([existing, sessions] as const); + if (wc.isDevToolsOpened()) { + return Effect.fail( + fail( + "ensureControlSession", + automationError( + "PreviewAutomationExecutionError", + "Close preview DevTools before using agent browser control.", + ), + ), + ); + } + if (wc.debugger.isAttached()) { + return Effect.fail( + fail( + "ensureControlSession", + automationError( + "PreviewAutomationExecutionError", + "Preview control cannot attach because another debugger owns this page.", + ), + ), + ); + } + return Effect.gen(function* () { + const semaphore = yield* Semaphore.make(1); + const onMessage: BrowserControlSession["onMessage"] = (_event, method, params) => { + runFork( + Effect.gen(function* () { + if (method === "Page.screencastFrame") { + const sessionId = params["sessionId"]; + if (typeof sessionId === "number") { + yield* attemptPromise("ackScreencastFrame", () => + wc.debugger.sendCommand("Page.screencastFrameAck", { sessionId }), + ).pipe(Effect.ignore); + } + const tabId = yield* tabIdForWebContents(wc.id); + const metadata = + typeof params["metadata"] === "object" && params["metadata"] !== null + ? (params["metadata"] as Record<string, unknown>) + : {}; + if (tabId && typeof params["data"] === "string") { + const receivedAt = yield* currentIso; + const listeners = yield* Ref.get(recordingFrameListenersRef); + const frame: DesktopPreviewRecordingFrame = { + tabId, + data: params["data"], + width: + typeof metadata["deviceWidth"] === "number" ? metadata["deviceWidth"] : 0, + height: + typeof metadata["deviceHeight"] === "number" ? metadata["deviceHeight"] : 0, + receivedAt, + }; + yield* Effect.forEach( + listeners, + (listener) => Effect.sync(() => listener(frame)).pipe(Effect.ignore), + { discard: true }, + ); + } + } + yield* captureDiagnosticMessage(wc.id, method, params); + }), + ); + }; + const control: BrowserControlSession = { webContentsId: wc.id, semaphore, onMessage }; + yield* Ref.update(diagnosticsRef, (diagnostics) => + replaceMap(diagnostics, (copy) => { + copy.set(wc.id, { + consoleEntries: [], + networkEntries: [], + requests: new Map(), + }); + }), + ); + yield* attempt("attachDebuggerListeners", () => { + wc.debugger.on("message", onMessage); + wc.debugger.attach("1.3"); + }); + yield* Effect.all( + ["Runtime.enable", "Accessibility.enable", "Network.enable", "Log.enable"].map((method) => + attemptPromise("initializeDebugger", () => wc.debugger.sendCommand(method)), + ), + { concurrency: "unbounded", discard: true }, + ).pipe( + Effect.tapError(() => + Effect.all([ + Ref.update(diagnosticsRef, (diagnostics) => + replaceMap(diagnostics, (copy) => { + copy.delete(wc.id); + }), + ), + attempt("detachFailedDebugger", () => { + wc.debugger.off("message", onMessage); + if (wc.debugger.isAttached()) wc.debugger.detach(); + }).pipe(Effect.ignore), + ]).pipe(Effect.asVoid), + ), + ); + return [ + control, + replaceMap(sessions, (copy) => { + copy.set(wc.id, control); + }), + ] as const; + }); + }); + }); + + const pushAction = (tabId: string, event: PreviewAutomationActionEvent) => + Ref.update(actionTimelineRef, (timelines) => + replaceMap(timelines, (copy) => { + copy.set(tabId, [...(timelines.get(tabId) ?? []), event].slice(-200)); + }), + ); + const replaceAction = (tabId: string, event: PreviewAutomationActionEvent) => + Ref.update(actionTimelineRef, (timelines) => { + const timeline = timelines.get(tabId); + if (!timeline) return timelines; + return replaceMap(timelines, (copy) => { + copy.set( + tabId, + timeline.map((candidate) => (candidate.id === event.id ? event : candidate)), + ); + }); + }); + + type SendCommand = ( + method: string, + commandParams?: Record<string, unknown>, + ) => Effect.Effect<unknown, PreviewManagerError>; + + const withControlSession = Effect.fn("PreviewManager.withControlSession")(function* <A>( + tabId: string, + wc: Electron.WebContents, + action: string, + use: (send: SendCommand) => Effect.Effect<A, PreviewManagerError>, + ) { + const sequence = yield* nextCounter(actionSequenceRef); + const startedAt = yield* currentIso; + const millis = yield* currentMillis; + const actionEvent: PreviewAutomationActionEvent = { + id: `browser-action-${millis.toString(36)}-${sequence.toString(36)}`, + action, + status: "running", + startedAt, + }; + yield* pushAction(tabId, actionEvent); + const epoch = (yield* Ref.get(controlEpochRef)).get(tabId) ?? 0; + const control = yield* ensureControlSession(wc); + const execute = Effect.fn("PreviewManager.executeControlAction")(function* () { + yield* update(tabId, { controller: "agent" }); + const send: SendCommand = Effect.fn("PreviewManager.sendCommand")( + function* (method, commandParams) { + const before = (yield* Ref.get(controlEpochRef)).get(tabId) ?? 0; + if (before !== epoch) { + return yield* fail( + action, + automationError( + "PreviewAutomationControlInterruptedError", + "Browser control was interrupted by human input.", + ), + ); + } + const result = yield* attemptPromise(action, () => + wc.debugger.sendCommand(method, commandParams), + ); + const after = (yield* Ref.get(controlEpochRef)).get(tabId) ?? 0; + if (after !== epoch) { + return yield* fail( + action, + automationError( + "PreviewAutomationControlInterruptedError", + "Browser control was interrupted by human input.", + ), + ); + } + return result; + }, + ); + return yield* use(send); + }); + const finalize = Effect.fn("PreviewManager.finalizeControlAction")(function* ( + exit: Exit.Exit<A, PreviewManagerError>, ) { - throw new Error("Preview artifact path is outside the configured artifact directory."); - } - return resolvedPath; - } + const completedAt = yield* currentIso; + if (exit._tag === "Success") { + yield* replaceAction(tabId, { + ...actionEvent, + status: "succeeded", + completedAt, + }); + } else { + const error = Option.getOrNull(Cause.findErrorOption(exit.cause)); + const underlying = error instanceof PreviewManagerError ? error.cause : error; + const interrupted = + underlying instanceof Error && + underlying.name === "PreviewAutomationControlInterruptedError"; + yield* replaceAction(tabId, { + ...actionEvent, + status: interrupted ? "interrupted" : "failed", + completedAt, + error: underlying instanceof Error ? underlying.message : String(underlying), + }); + } + const tabs = yield* SynchronizedRef.get(tabsRef); + if (tabs.has(tabId)) yield* update(tabId, { controller: "none" }); + }); + return yield* control.semaphore.withPermit(execute().pipe(Effect.onExit(finalize))); + }); - revealArtifact(path: string): void { - const resolvedPath = this.resolveArtifactPath(path); - shell.showItemInFolder(resolvedPath); - } + const evaluateWithDebugger = <A = unknown>( + send: SendCommand, + expression: string, + returnByValue: boolean, + awaitPromise = true, + ): Effect.Effect<A, PreviewManagerError> => + send("Runtime.evaluate", { + expression, + awaitPromise, + returnByValue, + userGesture: true, + }).pipe( + Effect.flatMap((rawResponse) => { + const response = rawResponse as CdpEvaluationResult; + return response.exceptionDetails + ? Effect.fail( + fail( + "evaluate", + automationError( + "PreviewAutomationExecutionError", + response.exceptionDetails.exception?.description ?? + response.exceptionDetails.text ?? + "JavaScript evaluation failed.", + ), + ), + ) + : Effect.succeed(response.result?.value as A); + }), + ); - copyArtifactToClipboard(path: string): void { - const resolvedPath = this.resolveArtifactPath(path); - const image = nativeImage.createFromPath(resolvedPath); - if (image.isEmpty()) { - throw new Error("Preview artifact could not be loaded as an image."); - } - clipboard.writeImage(image); - } + const automationLocator = (input: { + readonly selector?: string | undefined; + readonly locator?: string | undefined; + }): string | null => input.locator ?? (input.selector ? `css=${input.selector}` : null); - setAnnotationTheme(theme: DesktopPreviewAnnotationTheme): void { - this.annotationTheme = theme; - for (const tab of this.tabs.values()) { - if (tab.webContentsId == null) continue; - const wc = webContents.fromId(tab.webContentsId); - if (!wc || wc.isDestroyed()) continue; - wc.send(ANNOTATION_THEME_CHANNEL, theme); - } - } + const ensurePlaywrightInjected = Effect.fn("PreviewManager.ensurePlaywrightInjected")(function* ( + send: SendCommand, + ) { + const installed = yield* evaluateWithDebugger<boolean>( + send, + "Boolean(globalThis.__t3PlaywrightInjected)", + true, + ); + if (installed) return; + const expression = yield* playwrightInstallExpression; + yield* evaluateWithDebugger(send, expression, true); + }); - setMainWindow(window: BrowserWindow): void { - this.mainWindow = window; - } + const cancelPickElement = Effect.fn("PreviewManager.cancelPickElement")(function* ( + tabId: string, + ) { + const session = (yield* Ref.get(pickSessionsRef)).get(tabId); + if (session) yield* session.cancel; + }); - createTab(tabId: string): PreviewTabState { - const existing = this.tabs.get(tabId); - if (existing) return existing; - const initial: PreviewTabState = { - tabId, - webContentsId: null, - navStatus: { kind: "Idle" }, - canGoBack: false, - canGoForward: false, - zoomFactor: DEFAULT_ZOOM_FACTOR, - controller: "none", - updatedAt: new Date().toISOString(), + const detachListeners = Effect.fn("PreviewManager.detachListeners")(function* ( + webContentsId: number, + ) { + const handlers = yield* Ref.modify(attachedRef, (attached) => [ + attached.get(webContentsId), + replaceMap(attached, (copy) => { + copy.delete(webContentsId); + }), + ]); + if (!handlers) return; + const wc = webContents.fromId(webContentsId); + if (!wc || wc.isDestroyed()) return; + yield* attempt("detachListeners", () => { + wc.off("did-navigate", handlers.navigate); + wc.off("did-navigate-in-page", handlers.navigate); + wc.off("page-title-updated", handlers.navigate); + wc.off("did-start-loading", handlers.navigate); + wc.off("did-stop-loading", handlers.navigate); + wc.off("did-fail-load", handlers.failed as never); + wc.off("before-input-event", handlers.beforeInput); + wc.ipc.off(HUMAN_INPUT_CHANNEL, handlers.humanInput); + }).pipe(Effect.ignore); + }); + + const isAppShortcut = (input: Electron.Input): boolean => + input.type === "keyDown" && + APP_FORWARDED_SHORTCUTS.some( + (shortcut) => + shortcut.key.toLowerCase() === input.key.toLowerCase() && + shortcut.meta === input.meta && + shortcut.shift === input.shift && + shortcut.control === input.control, + ); + + const computeNavStatus = (wc: Electron.WebContents): PreviewNavStatus => { + 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 }; + }; + + const consumeExpectedAgentInput = Effect.fn("PreviewManager.consumeExpectedAgentInput")( + function* (tabId: string, signal: PreviewInputSignal) { + const now = yield* currentMillis; + return yield* Ref.modify(expectedAgentInputsRef, (allExpected) => { + const pending = (allExpected.get(tabId) ?? []).filter( + (expected) => expected.expiresAt > now, + ); + const index = pending.findIndex((expected) => inputSignalsMatch(expected.signal, signal)); + const matched = index >= 0; + const nextPending = matched + ? pending.filter((_, pendingIndex) => pendingIndex !== index) + : pending; + return [ + matched, + replaceMap(allExpected, (copy) => { + if (nextPending.length === 0) copy.delete(tabId); + else copy.set(tabId, nextPending); + }), + ] as const; + }); + }, + ); + + const expectAgentInput = Effect.fn("PreviewManager.expectAgentInput")(function* ( + tabId: string, + signal: PreviewInputSignal, + ) { + const now = yield* currentMillis; + yield* Ref.update(expectedAgentInputsRef, (allExpected) => + replaceMap(allExpected, (copy) => { + const pending = (allExpected.get(tabId) ?? []).filter( + (expected) => expected.expiresAt > now, + ); + copy.set(tabId, [...pending, { signal, expiresAt: now + 1_000 }]); + }), + ); + }); + + const attachListeners = Effect.fn("PreviewManager.attachListeners")(function* ( + tabId: string, + wc: Electron.WebContents, + ) { + const sync = () => + runFork( + Effect.gen(function* () { + if (wc.isDestroyed()) return; + yield* update(tabId, { + navStatus: computeNavStatus(wc), + canGoBack: wc.navigationHistory.canGoBack(), + canGoForward: wc.navigationHistory.canGoForward(), + }); + }), + ); + const failed = (_event: Event, code: number, description: string): void => { + if (code === -3) return; + runFork( + update(tabId, { + navStatus: { + kind: "LoadFailed", + url: wc.getURL(), + title: wc.getTitle(), + code, + description, + }, + }), + ); }; - this.tabs.set(tabId, initial); - this.emit(tabId, initial); - return initial; - } + const humanInput = (_event: unknown, rawSignal?: unknown): void => { + runFork( + Effect.gen(function* () { + if ( + isPreviewInputSignal(rawSignal) && + (yield* consumeExpectedAgentInput(tabId, rawSignal)) + ) { + return; + } + yield* Ref.update(controlEpochRef, (epochs) => + replaceMap(epochs, (copy) => { + copy.set(tabId, (epochs.get(tabId) ?? 0) + 1); + }), + ); + yield* update(tabId, { controller: "human" }); + yield* Effect.sleep(750); + const tabs = yield* SynchronizedRef.get(tabsRef); + if (tabs.get(tabId)?.controller === "human") { + yield* update(tabId, { controller: "none" }); + } + }), + ); + }; + const beforeInput = (event: Electron.Event, input: Electron.Input): void => { + runFork( + Effect.gen(function* () { + const mainWindow = yield* Ref.get(mainWindowRef); + if ( + !isAppShortcut(input) || + Option.isNone(mainWindow) || + mainWindow.value.isDestroyed() + ) { + return; + } + event.preventDefault(); + mainWindow.value.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) : []), + ], + }); + }), + ); + }; + yield* attempt("attachListeners", () => { + 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", failed as never); + wc.ipc.on(HUMAN_INPUT_CHANNEL, humanInput); + wc.setWindowOpenHandler(({ url }) => { + runFork(attemptPromise("openPreviewWindow", () => wc.loadURL(url)).pipe(Effect.ignore)); + return { action: "deny" }; + }); + wc.on("before-input-event", beforeInput); + }); + yield* Ref.update(attachedRef, (attached) => + replaceMap(attached, (copy) => { + copy.set(wc.id, { navigate: sync, failed, humanInput, beforeInput }); + }), + ); + }); - closeTab(tabId: string): void { - const tab = this.tabs.get(tabId); + const setMainWindow = Effect.fn("PreviewManager.setMainWindow")(function* ( + window: BrowserWindow, + ) { + yield* Ref.set(mainWindowRef, Option.some(window)); + }); + + const createTab = Effect.fn("PreviewManager.createTab")(function* (tabId: string) { + const updatedAt = yield* currentIso; + const state = yield* SynchronizedRef.modify(tabsRef, (tabs) => { + const existing = tabs.get(tabId); + if (existing) return [existing, tabs] as const; + const initial: PreviewTabState = { + tabId, + webContentsId: null, + navStatus: { kind: "Idle" }, + canGoBack: false, + canGoForward: false, + zoomFactor: DEFAULT_ZOOM_FACTOR, + controller: "none", + updatedAt, + }; + return [ + initial, + replaceMap(tabs, (copy) => { + copy.set(tabId, initial); + }), + ] as const; + }); + yield* emit(tabId, state); + return state; + }); + + const closeTab = Effect.fn("PreviewManager.closeTab")(function* (tabId: string) { + const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); if (!tab) return; - this.cancelPickElement(tabId); + yield* cancelPickElement(tabId); if (tab.webContentsId != null) { - this.detachControlSession(tab.webContentsId); - this.detachListeners(tab.webContentsId); + yield* Effect.all( + [detachControlSession(tab.webContentsId), detachListeners(tab.webContentsId)], + { concurrency: 2, discard: true }, + ); } + const updatedAt = yield* currentIso; const closed: PreviewTabState = { ...tab, webContentsId: null, @@ -432,289 +1132,336 @@ class PreviewViewManager { canGoForward: false, zoomFactor: DEFAULT_ZOOM_FACTOR, controller: "none", - updatedAt: new Date().toISOString(), + updatedAt, }; - this.tabs.delete(tabId); - this.emit(tabId, closed); - } + yield* SynchronizedRef.update(tabsRef, (tabs) => + replaceMap(tabs, (copy) => { + copy.delete(tabId); + }), + ); + yield* emit(tabId, closed); + }); - registerWebview(tabId: string, webContentsId: number): void { - const tab = this.tabs.get(tabId); + const registerWebview = Effect.fn("PreviewManager.registerWebview")(function* ( + tabId: string, + webContentsId: number, + ) { + const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); if (!tab) { - throw new PreviewTabNotFoundError(tabId); + return yield* fail("registerWebview", new PreviewTabNotFoundError(tabId)); } const wc = webContents.fromId(webContentsId); - if (!wc) { - throw new PreviewWebContentsNotFoundError(tabId, webContentsId); - } - // Defence in depth: a malicious renderer could otherwise trick us into - // attaching listeners to the main window's WebContents (or any other - // process) by passing an arbitrary id. - if (wc.getType() !== "webview") { - throw new PreviewWebContentsNotFoundError(tabId, webContentsId); - } - if (this.mainWindow && wc.hostWebContents !== this.mainWindow.webContents) { - throw new PreviewWebContentsNotFoundError(tabId, webContentsId); + const mainWindow = yield* Ref.get(mainWindowRef); + if ( + !wc || + wc.getType() !== "webview" || + (Option.isSome(mainWindow) && wc.hostWebContents !== mainWindow.value.webContents) + ) { + return yield* fail( + "registerWebview", + new PreviewWebContentsNotFoundError(tabId, webContentsId), + ); } - if (tab.webContentsId === webContentsId && this.attached.has(webContentsId)) { - wc.send(ANNOTATION_THEME_CHANNEL, this.annotationTheme); + const attached = yield* Ref.get(attachedRef); + const annotationTheme = yield* Ref.get(annotationThemeRef); + if (tab.webContentsId === webContentsId && attached.has(webContentsId)) { + yield* attempt("registerWebview.sendTheme", () => + wc.send(ANNOTATION_THEME_CHANNEL, annotationTheme), + ); return; } if (tab.webContentsId != null && tab.webContentsId !== webContentsId) { - this.detachControlSession(tab.webContentsId); - this.detachListeners(tab.webContentsId); - // Any in-flight pick is bound to the OLD WebContents via `wc.ipc.on`. - // Cancel it so the toggle button doesn't get stuck pressed waiting - // forever for a click on a webview that no longer hosts the listener. - this.cancelPickElement(tabId); + yield* Effect.all( + [ + detachControlSession(tab.webContentsId), + detachListeners(tab.webContentsId), + cancelPickElement(tabId), + ], + { concurrency: 3, discard: true }, + ); } - this.attachListeners(tabId, wc); - void this.ensureControlSession(wc).catch(() => undefined); - // Restore the persisted zoom factor onto the freshly-attached WebContents - // so a thread-switch + remount lands the user back where they were. + yield* attachListeners(tabId, wc); + runFork(ensureControlSession(wc).pipe(Effect.ignore)); if (Math.abs(tab.zoomFactor - DEFAULT_ZOOM_FACTOR) > ZOOM_EPSILON) { - try { - wc.setZoomFactor(tab.zoomFactor); - } catch { - // wc may have been torn down between resolution and call. - } + yield* attempt("registerWebview.restoreZoom", () => wc.setZoomFactor(tab.zoomFactor)).pipe( + Effect.ignore, + ); } - this.update(tabId, { + yield* update(tabId, { webContentsId, - navStatus: this.computeNavStatus(wc), + navStatus: computeNavStatus(wc), canGoBack: wc.navigationHistory.canGoBack(), canGoForward: wc.navigationHistory.canGoForward(), zoomFactor: tab.zoomFactor, }); - wc.send(ANNOTATION_THEME_CHANNEL, this.annotationTheme); - } + yield* attempt("registerWebview.sendTheme", () => + wc.send(ANNOTATION_THEME_CHANNEL, annotationTheme), + ); + }); - async navigate(tabId: string, rawUrl: string): Promise<void> { - const wc = this.requireWebContents(tabId); - const url = this.normalizeUrl(rawUrl); + const navigate = Effect.fn("PreviewManager.navigate")(function* (tabId: string, rawUrl: string) { + const wc = yield* requireWebContents(tabId); + const url = yield* attempt("navigate.normalizeUrl", () => normalizePreviewUrl(rawUrl)); if (wc.getURL() === url) { - wc.reload(); + yield* attempt("navigate.reload", () => wc.reload()); return; } - await wc.loadURL(url); - } - - goBack(tabId: string): void { - const wc = this.requireWebContents(tabId); - if (wc.navigationHistory.canGoBack()) { - wc.navigationHistory.goBack(); - } - } - - goForward(tabId: string): void { - const wc = this.requireWebContents(tabId); - if (wc.navigationHistory.canGoForward()) { - wc.navigationHistory.goForward(); - } - } + yield* attemptPromise("navigate.loadURL", () => wc.loadURL(url)); + }); - refresh(tabId: string): void { - const wc = this.requireWebContents(tabId); - wc.reload(); - } + const withWebContents = Effect.fn("PreviewManager.withWebContents")(function* ( + operation: string, + tabId: string, + use: (wc: Electron.WebContents) => void, + ) { + const wc = yield* requireWebContents(tabId); + yield* attempt(operation, () => use(wc)); + }); - /** Bypass HTTP cache on the next load — equivalent to Cmd+Shift+R. */ - hardReload(tabId: string): void { - const wc = this.requireWebContents(tabId); - wc.reloadIgnoringCache(); - } + const goBack = (tabId: string) => + withWebContents("goBack", tabId, (wc) => { + if (wc.navigationHistory.canGoBack()) wc.navigationHistory.goBack(); + }); + const goForward = (tabId: string) => + withWebContents("goForward", tabId, (wc) => { + if (wc.navigationHistory.canGoForward()) wc.navigationHistory.goForward(); + }); + const refresh = (tabId: string) => withWebContents("refresh", tabId, (wc) => wc.reload()); + const hardReload = (tabId: string) => + withWebContents("hardReload", tabId, (wc) => wc.reloadIgnoringCache()); - /** - * Open the guest webview's DevTools, detached so it doesn't steal panel - * area. Idempotent — re-invoking focuses the existing window. - */ - openDevTools(tabId: string): void { - const wc = this.requireWebContents(tabId); + const openDevTools = Effect.fn("PreviewManager.openDevTools")(function* (tabId: string) { + const wc = yield* requireWebContents(tabId); if (wc.isDevToolsOpened()) { - wc.devToolsWebContents?.focus(); + yield* attempt("openDevTools.focus", () => wc.devToolsWebContents?.focus()); return; } - this.detachControlSession(wc.id); - wc.once("devtools-closed", () => { - if (!wc.isDestroyed()) void this.ensureControlSession(wc).catch(() => undefined); + yield* detachControlSession(wc.id); + yield* attempt("openDevTools", () => { + wc.once("devtools-closed", () => { + if (!wc.isDestroyed()) runFork(ensureControlSession(wc).pipe(Effect.ignore)); + }); + wc.openDevTools({ mode: "detach" }); }); - wc.openDevTools({ mode: "detach" }); - } + }); - /** - * Activate annotation mode for `tabId`. Resolves after the guest submits a - * multi-target annotation and the desktop process captures its screenshot, - * or with `null` when the user cancels. - * - * Exactly one pick session may be active per tab — re-invoking while a - * pick is in flight cleanly resolves the old session with `null` first. - */ - async pickElement(tabId: string): Promise<PreviewAnnotationPayload | null> { - const wc = this.requireWebContents(tabId); - this.cancelPickElement(tabId); - return new Promise<PreviewAnnotationPayload | null>((resolve) => { - // `wc.ipc` is the per-WebContents IpcMain that receives messages the - // webview's preload sends with `ipcRenderer.send(...)`. We use that - // (not the global `wc.on("ipc-message", ...)`, which is for - // `sendToHost` and only fires on the host renderer's <webview> - // element) so the main process actually observes the picked payload. - const cleanup = () => { - wc.ipc.removeListener(ELEMENT_PICKED_CHANNEL, onMessage); - wc.off("destroyed", onDestroyed); - wc.off("did-start-navigation", onNavigated); - this.pickSessions.delete(tabId); - }; - const session: PickSession = { resolve, cleanup }; - const settle = (payload: PreviewAnnotationPayload | null) => { - if (this.pickSessions.get(tabId) !== session) return; - cleanup(); - resolve(payload); - }; - const onMessage = (_event: Electron.IpcMainEvent, ...args: unknown[]): void => { - const payload = args[0]; - if (payload == null) { - settle(null); - return; - } - if (!isPreviewAnnotationPayload(payload)) { - settle(null); - return; - } - const cropRect = normalizeCaptureRect(args[1]); - void captureAnnotationScreenshot(wc, cropRect) - .then((screenshot) => settle({ ...payload, screenshot })) - .catch(() => settle(payload)) - .finally(() => { - if (wc.isDestroyed()) return; - try { - wc.send(ANNOTATION_CAPTURED_CHANNEL); - } catch { - // The guest may have navigated while capture was in flight. + const setAnnotationTheme = Effect.fn("PreviewManager.setAnnotationTheme")(function* ( + theme: DesktopPreviewAnnotationTheme, + ) { + yield* Ref.set(annotationThemeRef, theme); + const tabs = yield* SynchronizedRef.get(tabsRef); + yield* Effect.forEach( + tabs.values(), + (tab) => { + if (tab.webContentsId == null) return Effect.void; + const wc = webContents.fromId(tab.webContentsId); + return !wc || wc.isDestroyed() + ? Effect.void + : attempt("setAnnotationTheme", () => wc.send(ANNOTATION_THEME_CHANNEL, theme)).pipe( + Effect.ignore, + ); + }, + { discard: true }, + ); + }); + + const pickElement = Effect.fn("PreviewManager.pickElement")(function* (tabId: string) { + const wc = yield* requireWebContents(tabId); + yield* cancelPickElement(tabId); + const annotationTheme = yield* Ref.get(annotationThemeRef); + return yield* Effect.callback<PreviewAnnotationPayload | null, PreviewManagerError>( + (resume) => { + const cleanup = Effect.gen(function* () { + yield* attempt("pickElement.cleanup", () => { + wc.ipc.removeListener(ELEMENT_PICKED_CHANNEL, onMessage); + wc.off("destroyed", onDestroyed); + wc.off("did-start-navigation", onNavigated); + }).pipe(Effect.ignore); + yield* Ref.update(pickSessionsRef, (sessions) => + replaceMap(sessions, (copy) => { + copy.delete(tabId); + }), + ); + }); + const settle = (payload: PreviewAnnotationPayload | null) => { + runFork( + Effect.gen(function* () { + const active = (yield* Ref.get(pickSessionsRef)).get(tabId); + if (!active || active.cancel !== cancel) return; + yield* cleanup; + resume(Effect.succeed(payload)); + }), + ); + }; + const cancel = Effect.gen(function* () { + yield* cleanup; + const tabs = yield* SynchronizedRef.get(tabsRef); + const activeTab = tabs.get(tabId); + if (activeTab?.webContentsId != null) { + const activeWc = webContents.fromId(activeTab.webContentsId); + if (activeWc && !activeWc.isDestroyed()) { + yield* attempt("cancelPickElement", () => activeWc.send(CANCEL_PICK_CHANNEL)).pipe( + Effect.ignore, + ); } - }); - }; - const onDestroyed = () => settle(null); - const onNavigated = () => settle(null); - wc.ipc.on(ELEMENT_PICKED_CHANNEL, onMessage); - wc.once("destroyed", onDestroyed); - // A page navigation (incl. SPA → same-document) tears down the - // preload's listeners, so we cancel proactively to avoid hanging. - wc.once("did-start-navigation", onNavigated); - this.pickSessions.set(tabId, session); - // Force-focus the guest webContents BEFORE sending start-pick. Without - // this, Electron's input router will deliver the user's first - // mousemove/click to the host renderer (where the pick button lives) - // instead of to the guest's window listeners — manifest as "the - // picker overlay never appears on remote pages I haven't clicked - // into yet". The renderer-side handler in `PreviewView` is responsible - // for restoring focus to the previously-active host element when the - // pick promise resolves so the user's textarea cursor isn't lost. - try { - if (!wc.isFocused()) wc.focus(); - } catch { - // wc may be torn down; the next try/catch settles. - } - try { - wc.send(START_PICK_CHANNEL, this.annotationTheme); - } catch { - settle(null); - } - }); - } + } + resume(Effect.succeed(null)); + }); + const onMessage = (_event: Electron.IpcMainEvent, ...args: unknown[]): void => { + const payload = args[0]; + if (!isPreviewAnnotationPayload(payload)) { + settle(null); + return; + } + const cropRect = normalizeCaptureRect(args[1]); + runFork( + captureAnnotationScreenshot(wc, cropRect).pipe( + Effect.matchEffect({ + onFailure: () => Effect.sync(() => settle(payload)), + onSuccess: (screenshot) => Effect.sync(() => settle({ ...payload, screenshot })), + }), + Effect.ensuring( + attempt("pickElement.captureComplete", () => { + if (!wc.isDestroyed()) wc.send(ANNOTATION_CAPTURED_CHANNEL); + }).pipe(Effect.ignore), + ), + ), + ); + }; + const onDestroyed = () => settle(null); + const onNavigated = () => settle(null); + runFork( + Effect.gen(function* () { + yield* attempt("pickElement.register", () => { + wc.ipc.on(ELEMENT_PICKED_CHANNEL, onMessage); + wc.once("destroyed", onDestroyed); + wc.once("did-start-navigation", onNavigated); + if (!wc.isFocused()) wc.focus(); + wc.send(START_PICK_CHANNEL, annotationTheme); + }); + yield* Ref.update(pickSessionsRef, (sessions) => + replaceMap(sessions, (copy) => { + copy.set(tabId, { cancel }); + }), + ); + }).pipe( + Effect.catch((error: PreviewManagerError) => { + resume(Effect.fail(error)); + return cleanup; + }), + ), + ); + return cancel; + }, + ); + }); - cancelPickElement(tabId: string): void { - const session = this.pickSessions.get(tabId); - if (!session) return; - session.cleanup(); - // Best-effort: tell the page to dismiss the overlay even if it's still - // alive — keeps the next invoke fresh. - const tab = this.tabs.get(tabId); - if (tab?.webContentsId != null) { + const applyZoom = Effect.fn("PreviewManager.applyZoom")(function* ( + tabId: string, + transform: (current: number) => number, + ) { + const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); + if (!tab) return; + const next = transform(tab.zoomFactor); + if (Math.abs(next - tab.zoomFactor) < ZOOM_EPSILON) return; + if (tab.webContentsId != null) { const wc = webContents.fromId(tab.webContentsId); if (wc && !wc.isDestroyed()) { - try { - wc.send(CANCEL_PICK_CHANNEL); - } catch { - // wc may have been torn down; nothing to clean up. - } + yield* attempt("applyZoom", () => wc.setZoomFactor(next)); } } - session.resolve(null); - } + yield* update(tabId, { zoomFactor: next }); + }); - async startRecording(tabId: string): Promise<void> { - if (this.recordingTabId && this.recordingTabId !== tabId) { - throw new Error("Only one browser recording can be active per window."); - } - const wc = this.requireWebContents(tabId); - await this.withControlSession(tabId, wc, "recording.start", async (send) => { - await send("Page.enable"); - await send("Page.startScreencast", { - format: "jpeg", - quality: 80, - maxWidth: 1600, - maxHeight: 1200, - everyNthFrame: 1, - }); - }); - this.recordingTabId = tabId; - } + const captureScreenshot = Effect.fn("PreviewManager.captureScreenshot")(function* ( + tabId: string, + ) { + const wc = yield* requireWebContents(tabId); + const [createdAt, millis, image] = yield* Effect.all([ + currentIso, + currentMillis, + attemptPromise("captureScreenshot.capturePage", () => wc.capturePage()), + ]); + const id = `browser-screenshot-${artifactSiteSlug(wc.getURL())}-${millis.toString(36)}`; + const artifactPath = path.join(resolvedArtifactDirectory, `${id}.png`); + const data = image.toPNG(); + yield* fileSystem + .makeDirectory(resolvedArtifactDirectory, { recursive: true }) + .pipe(Effect.mapError((cause) => fail("captureScreenshot.makeDirectory", cause))); + yield* fileSystem + .writeFile(artifactPath, data) + .pipe(Effect.mapError((cause) => fail("captureScreenshot.writeFile", cause))); + return { + id, + tabId, + path: artifactPath, + mimeType: "image/png" as const, + sizeBytes: data.byteLength, + createdAt, + }; + }); - async captureScreenshot(tabId: string): Promise<DesktopPreviewScreenshotArtifact> { - const wc = this.requireWebContents(tabId); - const createdAt = new Date().toISOString(); - const id = `browser-screenshot-${artifactSiteSlug(wc.getURL())}-${Date.now().toString(36)}`; - const directory = this.requireArtifactDirectory(); - const path = join(directory, `${id}.png`); - const data = (await wc.capturePage()).toPNG(); - await mkdir(directory, { recursive: true }); - await writeFile(path, data); - return { id, tabId, path, mimeType: "image/png", sizeBytes: data.byteLength, createdAt }; - } + const startRecording = Effect.fn("PreviewManager.startRecording")(function* (tabId: string) { + const recordingTabId = yield* Ref.get(recordingTabIdRef); + if (Option.isSome(recordingTabId) && recordingTabId.value !== tabId) { + return yield* fail( + "startRecording", + new Error("Only one browser recording can be active per window."), + ); + } + const wc = yield* requireWebContents(tabId); + yield* withControlSession(tabId, wc, "recording.start", (send) => + Effect.gen(function* () { + yield* send("Page.enable"); + yield* send("Page.startScreencast", { + format: "jpeg", + quality: 80, + maxWidth: 1600, + maxHeight: 1200, + everyNthFrame: 1, + }); + }), + ); + yield* Ref.set(recordingTabIdRef, Option.some(tabId)); + }); - async stopRecording(tabId: string): Promise<void> { - if (this.recordingTabId !== tabId) return; - const wc = this.requireWebContents(tabId); - await this.withControlSession(tabId, wc, "recording.stop", async (send) => { - await send("Page.stopScreencast"); - }); - this.recordingTabId = null; - } + const stopRecording = Effect.fn("PreviewManager.stopRecording")(function* (tabId: string) { + const recordingTabId = yield* Ref.get(recordingTabIdRef); + if (Option.isNone(recordingTabId) || recordingTabId.value !== tabId) return; + const wc = yield* requireWebContents(tabId); + yield* withControlSession(tabId, wc, "recording.stop", (send) => + send("Page.stopScreencast").pipe(Effect.asVoid), + ); + yield* Ref.set(recordingTabIdRef, Option.none()); + }); - async saveRecording( + const saveRecording = Effect.fn("PreviewManager.saveRecording")(function* ( tabId: string, mimeType: string, data: Uint8Array, - ): Promise<DesktopPreviewRecordingArtifact> { - const createdAt = new Date().toISOString(); - const id = `browser-recording-${Date.now().toString(36)}`; + ) { + const [createdAt, millis] = yield* Effect.all([currentIso, currentMillis]); + const id = `browser-recording-${millis.toString(36)}`; const extension = mimeType.includes("mp4") ? "mp4" : "webm"; - const directory = this.requireArtifactDirectory(); - const path = join(directory, `${id}.${extension}`); - await mkdir(directory, { recursive: true }); - await writeFile(path, data); - return { id, tabId, path, mimeType, sizeBytes: data.byteLength, createdAt }; - } - - onRecordingFrame(listener: RecordingFrameListener): () => void { - this.recordingFrameListeners.add(listener); - return () => this.recordingFrameListeners.delete(listener); - } - - zoomIn(tabId: string): void { - this.applyZoom(tabId, (current) => nextZoomLevel(current, "in")); - } - - zoomOut(tabId: string): void { - this.applyZoom(tabId, (current) => nextZoomLevel(current, "out")); - } - - resetZoom(tabId: string): void { - this.applyZoom(tabId, () => DEFAULT_ZOOM_FACTOR); - } + const artifactPath = path.join(resolvedArtifactDirectory, `${id}.${extension}`); + yield* fileSystem + .makeDirectory(resolvedArtifactDirectory, { recursive: true }) + .pipe(Effect.mapError((cause) => fail("saveRecording.makeDirectory", cause))); + yield* fileSystem + .writeFile(artifactPath, data) + .pipe(Effect.mapError((cause) => fail("saveRecording.writeFile", cause))); + return { + id, + tabId, + path: artifactPath, + mimeType, + sizeBytes: data.byteLength, + createdAt, + }; + }); - automationStatus(tabId: string): PreviewAutomationStatus { - const tab = this.tabs.get(tabId); + const automationStatus = Effect.fn("PreviewManager.automationStatus")(function* (tabId: string) { + const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); if (!tab || tab.webContentsId == null) { const navStatus = tab?.navStatus; return { @@ -727,223 +1474,266 @@ class PreviewViewManager { }; } const wc = webContents.fromId(tab.webContentsId); - if (!wc || wc.isDestroyed()) { - return { - available: false, - visible: true, - tabId, - url: null, - title: null, - loading: false, - }; - } - return { - available: true, - visible: true, - tabId, - url: wc.getURL() || null, - title: wc.getTitle() || null, - loading: wc.isLoading(), - }; - } + return !wc || wc.isDestroyed() + ? { + available: false, + visible: true, + tabId, + url: null, + title: null, + loading: false, + } + : { + available: true, + visible: true, + tabId, + url: wc.getURL() || null, + title: wc.getTitle() || null, + loading: wc.isLoading(), + }; + }); - async automationSnapshot(tabId: string): Promise<PreviewAutomationSnapshot> { - const wc = this.requireWebContents(tabId); - return this.withControlSession(tabId, wc, "snapshot", async (send) => { - await Promise.all([send("Runtime.enable"), send("Accessibility.enable")]); - const page = await this.evaluateWithDebugger<{ - url: string; - title: string; - loading: boolean; - visibleText: string; - interactiveElements: PreviewAutomationSnapshot["interactiveElements"]; - }>( - send, - `(() => { - const selectorFor = (element) => { - if (element.id) return "#" + CSS.escape(element.id); - for (const attribute of ["data-testid", "name"]) { - const value = element.getAttribute(attribute); - if (value) return element.tagName.toLowerCase() + "[" + attribute + "=" + JSON.stringify(value) + "]"; - } - const parts = []; - let current = element; - while (current && current.nodeType === Node.ELEMENT_NODE && parts.length < 8) { - let part = current.tagName.toLowerCase(); - const parent = current.parentElement; - if (parent) { - const siblings = Array.from(parent.children).filter((child) => child.tagName === current.tagName); - if (siblings.length > 1) part += ":nth-of-type(" + (siblings.indexOf(current) + 1) + ")"; + const automationSnapshot = Effect.fn("PreviewManager.automationSnapshot")(function* ( + tabId: string, + ) { + const wc = yield* requireWebContents(tabId); + return yield* withControlSession(tabId, wc, "snapshot", (send) => + Effect.gen(function* () { + yield* Effect.all([send("Runtime.enable"), send("Accessibility.enable")], { + concurrency: 2, + discard: true, + }); + const page = yield* evaluateWithDebugger<{ + url: string; + title: string; + loading: boolean; + visibleText: string; + interactiveElements: PreviewAutomationSnapshot["interactiveElements"]; + }>( + send, + `(() => { + const selectorFor = (element) => { + if (element.id) return "#" + CSS.escape(element.id); + for (const attribute of ["data-testid", "name"]) { + const value = element.getAttribute(attribute); + if (value) return element.tagName.toLowerCase() + "[" + attribute + "=" + JSON.stringify(value) + "]"; } - parts.unshift(part); - current = parent; - } - return parts.join(" > "); - }; - const visible = (element) => { - const style = getComputedStyle(element); - const rect = element.getBoundingClientRect(); - return style.visibility !== "hidden" && style.display !== "none" && rect.width > 0 && rect.height > 0; - }; - const elements = Array.from(document.querySelectorAll( - "a[href],button,input,textarea,select,[role],[tabindex]" - )).filter(visible).slice(0, ${MAX_INTERACTIVE_ELEMENTS}).map((element) => { - const rect = element.getBoundingClientRect(); + const buildParts = (current, parts = []) => { + if (!current || current.nodeType !== Node.ELEMENT_NODE || parts.length >= 8) { + return parts; + } + const parent = current.parentElement; + const siblings = parent + ? Array.from(parent.children).filter((child) => child.tagName === current.tagName) + : []; + const base = current.tagName.toLowerCase(); + const part = siblings.length > 1 + ? base + ":nth-of-type(" + (siblings.indexOf(current) + 1) + ")" + : base; + return buildParts(parent, [part, ...parts]); + }; + return buildParts(element).join(" > "); + }; + const visible = (element) => { + const style = getComputedStyle(element); + const rect = element.getBoundingClientRect(); + return style.visibility !== "hidden" && style.display !== "none" && rect.width > 0 && rect.height > 0; + }; + const elements = Array.from(document.querySelectorAll( + "a[href],button,input,textarea,select,[role],[tabindex]" + )).filter(visible).slice(0, ${MAX_INTERACTIVE_ELEMENTS}).map((element) => { + const rect = element.getBoundingClientRect(); + return { + tag: element.tagName.toLowerCase(), + role: element.getAttribute("role"), + name: element.getAttribute("aria-label") || element.innerText || element.getAttribute("name") || "", + selector: selectorFor(element), + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height + }; + }); return { - tag: element.tagName.toLowerCase(), - role: element.getAttribute("role"), - name: element.getAttribute("aria-label") || element.innerText || element.getAttribute("name") || "", - selector: selectorFor(element), - x: rect.x, - y: rect.y, - width: rect.width, - height: rect.height + url: location.href, + title: document.title, + loading: document.readyState !== "complete", + visibleText: (document.body?.innerText || "").slice(0, ${MAX_VISIBLE_TEXT_LENGTH}), + interactiveElements: elements }; - }); - return { - url: location.href, - title: document.title, - loading: document.readyState !== "complete", - visibleText: (document.body?.innerText || "").slice(0, ${MAX_VISIBLE_TEXT_LENGTH}), - interactiveElements: elements - }; + })()`, + true, + ); + const [accessibility, sourceImage, diagnostics, timelines] = yield* Effect.all([ + send("Accessibility.getFullAXTree"), + attemptPromise("automationSnapshot.capturePage", () => wc.capturePage()), + Ref.get(diagnosticsRef), + Ref.get(actionTimelineRef), + ]); + const sourceSize = sourceImage.getSize(); + const image = + sourceSize.width > MAX_SCREENSHOT_WIDTH + ? sourceImage.resize({ width: MAX_SCREENSHOT_WIDTH }) + : sourceImage; + const size = image.getSize(); + const browserDiagnostics = diagnostics.get(wc.id); + return { + ...page, + accessibilityTree: accessibility, + consoleEntries: [...(browserDiagnostics?.consoleEntries ?? [])], + networkEntries: [...(browserDiagnostics?.networkEntries ?? [])], + actionTimeline: [...(timelines.get(tabId) ?? [])], + screenshot: { + mimeType: "image/png" as const, + data: image.toPNG().toString("base64"), + width: size.width, + height: size.height, + }, + }; + }), + ); + }); + + const resolveClickPoint = Effect.fn("PreviewManager.resolveClickPoint")(function* ( + send: SendCommand, + input: PreviewAutomationClickInput, + ) { + if (!("selector" in input) && !("locator" in input)) { + return { x: input.x!, y: input.y! }; + } + const locator = automationLocator(input)!; + yield* ensurePlaywrightInjected(send); + const locatorJson = yield* encodeJson("automationClick.encodeLocator", locator); + const point = yield* evaluateWithDebugger< + { x: number; y: number } | { invalidSelector: true; message: string } | { notFound: true } + >( + send, + `(() => { + try { + const injected = globalThis.__t3PlaywrightInjected; + const parsed = injected.parseSelector(${locatorJson}); + const element = injected.querySelector(parsed, document, true); + if (!element) return { notFound: true }; + const visible = injected.elementState(element, "visible"); + const enabled = injected.elementState(element, "enabled"); + if (!visible.matches || !enabled.matches) return { notFound: true }; + element.scrollIntoView({ block: "center", inline: "center" }); + const rect = element.getBoundingClientRect(); + return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }; + } catch (error) { + return { invalidSelector: true, message: String(error) }; + } })()`, - true, + true, + ); + if ("invalidSelector" in point) { + return yield* fail( + "automationClick", + automationError("PreviewAutomationInvalidSelectorError", point.message, { + selector: locator, + }), ); - const accessibility = await send("Accessibility.getFullAXTree"); - let image = await wc.capturePage(); - let size = image.getSize(); - if (size.width > MAX_SCREENSHOT_WIDTH) { - image = image.resize({ width: MAX_SCREENSHOT_WIDTH }); - size = image.getSize(); - } - return { - ...page, - accessibilityTree: accessibility, - consoleEntries: [...(this.diagnostics.get(wc.id)?.consoleEntries ?? [])], - networkEntries: [...(this.diagnostics.get(wc.id)?.networkEntries ?? [])], - actionTimeline: [...(this.actionTimeline.get(tabId) ?? [])], - screenshot: { - mimeType: "image/png", - data: image.toPNG().toString("base64"), - width: size.width, - height: size.height, - }, - }; - }); - } + } + if ("notFound" in point) { + return yield* fail( + "automationClick", + automationError( + "PreviewAutomationExecutionError", + `No element matches locator ${locator}.`, + ), + ); + } + return point; + }); - async automationClick(tabId: string, input: PreviewAutomationClickInput): Promise<void> { - const wc = this.requireWebContents(tabId); - await this.withControlSession(tabId, wc, "click", async (send) => { - await Promise.all([ - send("Runtime.enable"), - send("Input.setIgnoreInputEvents", { ignore: false }), - ]); - let x: number; - let y: number; - if ("selector" in input || "locator" in input) { - await this.ensurePlaywrightInjected(send); - const locator = this.automationLocator(input); - const point = await this.evaluateWithDebugger< - { x: number; y: number } | { invalidSelector: true; message: string } | { notFound: true } - >( + const emitPointerEvent = Effect.fn("PreviewManager.emitPointerEvent")(function* ( + event: DesktopPreviewPointerEvent, + ) { + const listeners = yield* Ref.get(pointerEventListenersRef); + yield* Effect.forEach( + listeners, + (listener) => Effect.sync(() => listener(event)).pipe(Effect.ignore), + { discard: true }, + ); + }); + + const automationClick = Effect.fn("PreviewManager.automationClick")(function* ( + tabId: string, + input: PreviewAutomationClickInput, + ) { + const wc = yield* requireWebContents(tabId); + yield* withControlSession(tabId, wc, "click", (send) => + Effect.gen(function* () { + yield* Effect.all( + [send("Runtime.enable"), send("Input.setIgnoreInputEvents", { ignore: false })], + { concurrency: 2, discard: true }, + ); + const point = yield* resolveClickPoint(send, input); + const viewport = yield* evaluateWithDebugger<{ width: number; height: number }>( send, - `(() => { - try { - const injected = globalThis.__t3PlaywrightInjected; - const parsed = injected.parseSelector(${JSON.stringify(locator)}); - const element = injected.querySelector(parsed, document, true); - if (!element) return { notFound: true }; - const visible = injected.elementState(element, "visible"); - const enabled = injected.elementState(element, "enabled"); - if (!visible.matches || !enabled.matches) return { notFound: true }; - element.scrollIntoView({ block: "center", inline: "center" }); - const rect = element.getBoundingClientRect(); - return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }; - } catch (error) { - return { invalidSelector: true, message: String(error) }; - } - })()`, + "({ width: window.innerWidth, height: window.innerHeight })", true, ); - if ("invalidSelector" in point) { - throw automationError("PreviewAutomationInvalidSelectorError", point.message, { - selector: locator, - }); - } - if ("notFound" in point) { - throw automationError( - "PreviewAutomationExecutionError", - `No element matches locator ${locator}.`, + if (point.x < 0 || point.y < 0 || point.x > viewport.width || point.y > viewport.height) { + return yield* fail( + "automationClick", + automationError( + "PreviewAutomationExecutionError", + `Click coordinates (${point.x}, ${point.y}) are outside the preview viewport.`, + ), ); } - x = point.x; - y = point.y; - } else { - x = input.x!; - y = input.y!; - } - const viewport = await this.evaluateWithDebugger<{ width: number; height: number }>( - send, - "({ width: window.innerWidth, height: window.innerHeight })", - true, - ); - if (x < 0 || y < 0 || x > viewport.width || y > viewport.height) { - throw automationError( - "PreviewAutomationExecutionError", - `Click coordinates (${x}, ${y}) are outside the preview viewport.`, - ); - } - this.emitPointerEvent({ - tabId, - phase: "move", - x, - y, - sequence: this.pointerSequence++, - createdAt: new Date().toISOString(), - }); - await sleep(AGENT_CURSOR_MOVE_MS); - this.emitPointerEvent({ - tabId, - phase: "click", - x, - y, - sequence: this.pointerSequence++, - createdAt: new Date().toISOString(), - }); - await sleep(AGENT_CURSOR_CLICK_LEAD_MS); - this.expectAgentInput(tabId, { kind: "pointer", x, y, button: 0 }); - await send("Input.dispatchMouseEvent", { - type: "mousePressed", - x, - y, - button: "left", - clickCount: 1, - }); - await send("Input.dispatchMouseEvent", { - type: "mouseReleased", - x, - y, - button: "left", - clickCount: 1, - }); - }); - } + const moveSequence = yield* nextCounter(pointerSequenceRef); + const moveCreatedAt = yield* currentIso; + yield* emitPointerEvent({ + tabId, + phase: "move", + ...point, + sequence: moveSequence, + createdAt: moveCreatedAt, + }); + yield* Effect.sleep(AGENT_CURSOR_MOVE_MS); + const clickSequence = yield* nextCounter(pointerSequenceRef); + const clickCreatedAt = yield* currentIso; + yield* emitPointerEvent({ + tabId, + phase: "click", + ...point, + sequence: clickSequence, + createdAt: clickCreatedAt, + }); + yield* Effect.sleep(AGENT_CURSOR_CLICK_LEAD_MS); + yield* expectAgentInput(tabId, { kind: "pointer", ...point, button: 0 }); + yield* send("Input.dispatchMouseEvent", { + type: "mousePressed", + ...point, + button: "left", + clickCount: 1, + }); + yield* send("Input.dispatchMouseEvent", { + type: "mouseReleased", + ...point, + button: "left", + clickCount: 1, + }); + }), + ); + }); - async automationType(tabId: string, input: PreviewAutomationTypeInput): Promise<void> { - const wc = this.requireWebContents(tabId); - await this.withControlSession(tabId, wc, "type", async (send) => { - await send("Runtime.enable"); - const locator = this.automationLocator(input); - if (locator) await this.ensurePlaywrightInjected(send); - const focusResult = await this.evaluateWithDebugger< - { ok: true } | { invalidSelector: true; message: string } | { notFound: true } - >( - send, - `(() => { + const focusAutomationTarget = Effect.fn("PreviewManager.focusAutomationTarget")(function* ( + send: SendCommand, + input: PreviewAutomationTypeInput, + ) { + const locator = automationLocator(input); + if (locator) yield* ensurePlaywrightInjected(send); + const locatorJson = locator ? yield* encodeJson("automationType.encodeLocator", locator) : null; + const result = yield* evaluateWithDebugger< + { ok: true } | { invalidSelector: true; message: string } | { notFound: true } + >( + send, + `(() => { try { - const element = ${locator ? `(() => { const injected = globalThis.__t3PlaywrightInjected; return injected.querySelector(injected.parseSelector(${JSON.stringify(locator)}), document, true); })()` : "document.activeElement"}; + const element = ${locatorJson ? `(() => { const injected = globalThis.__t3PlaywrightInjected; return injected.querySelector(injected.parseSelector(${locatorJson}), document, true); })()` : "document.activeElement"}; if (!element) return { notFound: true }; element.focus(); if (${input.clear ?? false}) { @@ -956,151 +1746,110 @@ class PreviewViewManager { return { invalidSelector: true, message: String(error) }; } })()`, - true, - ); - if ("invalidSelector" in focusResult) { - throw automationError("PreviewAutomationInvalidSelectorError", focusResult.message, { + true, + ); + if ("invalidSelector" in result) { + return yield* fail( + "automationType", + automationError("PreviewAutomationInvalidSelectorError", result.message, { selector: input.selector ?? "", - }); - } - if ("notFound" in focusResult) { - throw automationError( + }), + ); + } + if ("notFound" in result) { + return yield* fail( + "automationType", + automationError( "PreviewAutomationExecutionError", locator ? `No element matches locator ${locator}.` : "No element is focused in the preview.", - ); - } - await send("Input.insertText", { text: input.text }); - await this.evaluateWithDebugger( - send, - `(() => { - const element = document.activeElement; - element?.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: ${JSON.stringify(input.text)} })); - element?.dispatchEvent(new Event("change", { bubbles: true })); - })()`, - false, + ), ); - }); - } - - async automationPress(tabId: string, input: PreviewAutomationPressInput): Promise<void> { - const wc = this.requireWebContents(tabId); - await this.withControlSession(tabId, wc, "press", async (send) => { - const modifiers = (input.modifiers ?? []).reduce((value, modifier) => { - switch (modifier) { - case "Alt": - return value | 1; - case "Control": - return value | 2; - case "Meta": - return value | 4; - case "Shift": - return value | 8; - } - }, 0); - const key = input.key; - const text = key.length === 1 ? key : undefined; - const params = { - key, - code: key.length === 1 ? `Key${key.toUpperCase()}` : key, - modifiers, - ...(text ? { text, unmodifiedText: text } : {}), - }; - this.expectAgentInput(tabId, { kind: "key", key, code: params.code }); - await send("Input.dispatchKeyEvent", { type: "keyDown", ...params }); - await send("Input.dispatchKeyEvent", { type: "keyUp", ...params }); - }); - } + } + }); - async automationScroll(tabId: string, input: PreviewAutomationScrollInput): Promise<void> { - const wc = this.requireWebContents(tabId); - await this.withControlSession(tabId, wc, "scroll", async (send) => { - await send("Runtime.enable"); - const locator = this.automationLocator(input); - if (locator) await this.ensurePlaywrightInjected(send); - const result = await this.evaluateWithDebugger< - { ok: true } | { invalidSelector: true; message: string } | { notFound: true } - >( - send, - `(() => { - try { - const target = ${locator ? `(() => { const injected = globalThis.__t3PlaywrightInjected; return injected.querySelector(injected.parseSelector(${JSON.stringify(locator)}), document, true); })()` : "window"}; - if (!target) return { notFound: true }; - target.scrollBy({ left: ${input.deltaX ?? 0}, top: ${input.deltaY ?? 0}, behavior: "instant" }); - return { ok: true }; - } catch (error) { - return { invalidSelector: true, message: String(error) }; - } - })()`, - true, - ); - if ("invalidSelector" in result) { - throw automationError("PreviewAutomationInvalidSelectorError", result.message, { - selector: input.selector ?? "", - }); - } - if ("notFound" in result) { - throw automationError( - "PreviewAutomationExecutionError", - `No element matches locator ${locator}.`, + const automationType = Effect.fn("PreviewManager.automationType")(function* ( + tabId: string, + input: PreviewAutomationTypeInput, + ) { + const wc = yield* requireWebContents(tabId); + yield* withControlSession(tabId, wc, "type", (send) => + Effect.gen(function* () { + yield* send("Runtime.enable"); + yield* focusAutomationTarget(send, input); + yield* send("Input.insertText", { text: input.text }); + const textJson = yield* encodeJson("automationType.encodeText", input.text); + yield* evaluateWithDebugger( + send, + `(() => { + const element = document.activeElement; + element?.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: ${textJson} })); + element?.dispatchEvent(new Event("change", { bubbles: true })); + })()`, + false, ); - } - }); - } + }), + ); + }); - async automationEvaluate(tabId: string, input: PreviewAutomationEvaluateInput): Promise<unknown> { - const wc = this.requireWebContents(tabId); - return this.withControlSession(tabId, wc, "evaluate", async (send) => { - await send("Runtime.enable"); - const value = await this.evaluateWithDebugger( - send, - input.expression, - input.returnByValue ?? true, - input.awaitPromise ?? true, - ); - const serialized = JSON.stringify(value); - if ( - serialized !== undefined && - Buffer.byteLength(serialized, "utf8") > MAX_EVALUATION_BYTES - ) { - throw automationError( - "PreviewAutomationResultTooLargeError", - `Evaluation result exceeds ${MAX_EVALUATION_BYTES} bytes.`, - { maximumBytes: MAX_EVALUATION_BYTES }, - ); - } - return value; - }); - } + const automationPress = Effect.fn("PreviewManager.automationPress")(function* ( + tabId: string, + input: PreviewAutomationPressInput, + ) { + const wc = yield* requireWebContents(tabId); + yield* withControlSession(tabId, wc, "press", (send) => + Effect.gen(function* () { + const modifiers = (input.modifiers ?? []).reduce((value, modifier) => { + switch (modifier) { + case "Alt": + return value | 1; + case "Control": + return value | 2; + case "Meta": + return value | 4; + case "Shift": + return value | 8; + } + }, 0); + const key = input.key; + const text = key.length === 1 ? key : undefined; + const params = { + key, + code: key.length === 1 ? `Key${key.toUpperCase()}` : key, + modifiers, + ...(text ? { text, unmodifiedText: text } : {}), + }; + yield* expectAgentInput(tabId, { kind: "key", key, code: params.code }); + yield* send("Input.dispatchKeyEvent", { type: "keyDown", ...params }); + yield* send("Input.dispatchKeyEvent", { type: "keyUp", ...params }); + }), + ); + }); - async automationWaitFor(tabId: string, input: PreviewAutomationWaitForInput): Promise<void> { - const wc = this.requireWebContents(tabId); - const timeoutMs = input.timeoutMs ?? 15_000; - await this.withControlSession(tabId, wc, "waitFor", async (send) => { - await send("Runtime.enable"); - const locator = this.automationLocator(input); - if (locator) await this.ensurePlaywrightInjected(send); - const deadline = Date.now() + timeoutMs; - while (Date.now() <= deadline) { - const result = await this.evaluateWithDebugger< - { matched: boolean } | { invalidSelector: true; message: string } + const automationScroll = Effect.fn("PreviewManager.automationScroll")(function* ( + tabId: string, + input: PreviewAutomationScrollInput, + ) { + const wc = yield* requireWebContents(tabId); + yield* withControlSession(tabId, wc, "scroll", (send) => + Effect.gen(function* () { + yield* send("Runtime.enable"); + const locator = automationLocator(input); + if (locator) yield* ensurePlaywrightInjected(send); + const locatorJson = locator + ? yield* encodeJson("automationScroll.encodeLocator", locator) + : null; + const result = yield* evaluateWithDebugger< + { ok: true } | { invalidSelector: true; message: string } | { notFound: true } >( send, `(() => { try { - const selectorMatched = ${locator ? `(() => { const injected = globalThis.__t3PlaywrightInjected; return injected.querySelector(injected.parseSelector(${JSON.stringify(locator)}), document, false) !== null; })()` : "true"}; - const textMatched = ${ - input.text - ? `(document.body?.innerText || "").includes(${JSON.stringify(input.text)})` - : "true" - }; - const urlMatched = ${ - input.urlIncludes - ? `location.href.includes(${JSON.stringify(input.urlIncludes)})` - : "true" - }; - return { matched: selectorMatched && textMatched && urlMatched }; + const target = ${locatorJson ? `(() => { const injected = globalThis.__t3PlaywrightInjected; return injected.querySelector(injected.parseSelector(${locatorJson}), document, true); })()` : "window"}; + if (!target) return { notFound: true }; + target.scrollBy({ left: ${input.deltaX ?? 0}, top: ${input.deltaY ?? 0}, behavior: "instant" }); + return { ok: true }; } catch (error) { return { invalidSelector: true, message: String(error) }; } @@ -1108,563 +1857,211 @@ class PreviewViewManager { true, ); if ("invalidSelector" in result) { - throw automationError("PreviewAutomationInvalidSelectorError", result.message, { - selector: input.selector ?? "", - }); - } - if (result.matched) return; - await sleep(100); - } - throw automationError( - "PreviewAutomationTimeoutError", - `Preview condition did not match within ${timeoutMs}ms.`, - ); - }); - } - - private applyZoom(tabId: string, transform: (current: number) => number): void { - const tab = this.tabs.get(tabId); - if (!tab) return; - const next = transform(tab.zoomFactor); - if (Math.abs(next - tab.zoomFactor) < ZOOM_EPSILON) return; - if (tab.webContentsId != null) { - const wc = webContents.fromId(tab.webContentsId); - if (wc && !wc.isDestroyed()) { - wc.setZoomFactor(next); - } - } - this.update(tabId, { zoomFactor: next }); - } - - private async withControlSession<A>( - tabId: string, - wc: Electron.WebContents, - action: string, - use: ( - send: (method: string, commandParams?: Record<string, unknown>) => Promise<unknown>, - ) => Promise<A>, - ): Promise<A> { - const actionEvent: PreviewAutomationActionEvent = { - id: `browser-action-${Date.now().toString(36)}-${(this.actionSequence++).toString(36)}`, - action, - status: "running", - startedAt: new Date().toISOString(), - }; - this.pushAction(tabId, actionEvent); - const epoch = this.controlEpoch.get(tabId) ?? 0; - const control = await this.ensureControlSession(wc); - let resolveTail: () => void = () => undefined; - const previous = control.tail; - control.tail = new Promise<void>((resolve) => { - resolveTail = resolve; - }); - await previous; - this.update(tabId, { controller: "agent" }); - try { - const send = async (method: string, commandParams?: Record<string, unknown>) => { - if ((this.controlEpoch.get(tabId) ?? 0) !== epoch) { - throw automationError( - "PreviewAutomationControlInterruptedError", - "Browser control was interrupted by human input.", + return yield* fail( + "automationScroll", + automationError("PreviewAutomationInvalidSelectorError", result.message, { + selector: input.selector ?? "", + }), ); } - const result = await wc.debugger.sendCommand(method, commandParams); - if ((this.controlEpoch.get(tabId) ?? 0) !== epoch) { - throw automationError( - "PreviewAutomationControlInterruptedError", - "Browser control was interrupted by human input.", + if ("notFound" in result) { + return yield* fail( + "automationScroll", + automationError( + "PreviewAutomationExecutionError", + `No element matches locator ${locator}.`, + ), ); } - return result; - }; - const result = await use(send); - this.replaceAction(tabId, { - ...actionEvent, - status: "succeeded", - completedAt: new Date().toISOString(), - }); - return result; - } catch (cause) { - const interrupted = - cause instanceof Error && cause.name === "PreviewAutomationControlInterruptedError"; - this.replaceAction(tabId, { - ...actionEvent, - status: interrupted ? "interrupted" : "failed", - completedAt: new Date().toISOString(), - error: cause instanceof Error ? cause.message : String(cause), - }); - if (cause instanceof Error && cause.name.startsWith("PreviewAutomation")) throw cause; - throw automationError( - "PreviewAutomationExecutionError", - cause instanceof Error ? cause.message : String(cause), - { tabId, cause }, - ); - } finally { - if (this.tabs.has(tabId)) this.update(tabId, { controller: "none" }); - resolveTail(); - } - } - - private pushAction(tabId: string, event: PreviewAutomationActionEvent): void { - const timeline = this.actionTimeline.get(tabId) ?? []; - timeline.push(event); - if (timeline.length > 200) timeline.splice(0, timeline.length - 200); - this.actionTimeline.set(tabId, timeline); - } - - private replaceAction(tabId: string, event: PreviewAutomationActionEvent): void { - const timeline = this.actionTimeline.get(tabId); - if (!timeline) return; - const index = timeline.findIndex((candidate) => candidate.id === event.id); - if (index >= 0) timeline[index] = event; - } + }), + ); + }); - private async ensureControlSession(wc: Electron.WebContents): Promise<BrowserControlSession> { - const existing = this.controlSessions.get(wc.id); - if (existing) { - await existing.initialized; - return existing; - } - if (wc.isDevToolsOpened()) { - throw automationError( - "PreviewAutomationExecutionError", - "Close preview DevTools before using agent browser control.", - ); - } - if (wc.debugger.isAttached()) { - throw automationError( - "PreviewAutomationExecutionError", - "Preview control cannot attach because another debugger owns this page.", - ); - } - const diagnostics: BrowserDiagnostics = { - consoleEntries: [], - networkEntries: [], - requests: new Map(), - }; - this.diagnostics.set(wc.id, diagnostics); - const onMessage: BrowserControlSession["onMessage"] = (_event, method, params) => { - if (method === "Page.screencastFrame") { - const frame = params; - const sessionId = frame["sessionId"]; - if (typeof sessionId === "number") { - void wc.debugger - .sendCommand("Page.screencastFrameAck", { sessionId }) - .catch(() => undefined); - } - const tabId = this.tabIdForWebContents(wc.id); - const metadata = - typeof frame["metadata"] === "object" && frame["metadata"] !== null - ? (frame["metadata"] as Record<string, unknown>) - : {}; - if (tabId && typeof frame["data"] === "string") { - const payload: DesktopPreviewRecordingFrame = { - tabId, - data: frame["data"], - width: typeof metadata["deviceWidth"] === "number" ? metadata["deviceWidth"] : 0, - height: typeof metadata["deviceHeight"] === "number" ? metadata["deviceHeight"] : 0, - receivedAt: new Date().toISOString(), - }; - for (const listener of this.recordingFrameListeners) listener(payload); + const automationEvaluate = Effect.fn("PreviewManager.automationEvaluate")(function* ( + tabId: string, + input: PreviewAutomationEvaluateInput, + ) { + const wc = yield* requireWebContents(tabId); + return yield* withControlSession(tabId, wc, "evaluate", (send) => + Effect.gen(function* () { + yield* send("Runtime.enable"); + const value = yield* evaluateWithDebugger( + send, + input.expression, + input.returnByValue ?? true, + input.awaitPromise ?? true, + ); + const serialized = yield* encodeJson("automationEvaluate.encodeResult", value); + if (Buffer.byteLength(serialized, "utf8") > MAX_EVALUATION_BYTES) { + return yield* fail( + "automationEvaluate", + automationError( + "PreviewAutomationResultTooLargeError", + `Evaluation result exceeds ${MAX_EVALUATION_BYTES} bytes.`, + { maximumBytes: MAX_EVALUATION_BYTES }, + ), + ); } - } - this.captureDiagnosticMessage(diagnostics, method, params); - }; - const control: BrowserControlSession = { - webContentsId: wc.id, - tail: Promise.resolve(), - initialized: Promise.resolve(), - onMessage, - }; - wc.debugger.on("message", onMessage); - control.initialized = (async () => { - wc.debugger.attach("1.3"); - await Promise.all([ - wc.debugger.sendCommand("Runtime.enable"), - wc.debugger.sendCommand("Accessibility.enable"), - wc.debugger.sendCommand("Network.enable"), - wc.debugger.sendCommand("Log.enable"), - ]); - })(); - this.controlSessions.set(wc.id, control); - try { - await control.initialized; - return control; - } catch (cause) { - this.controlSessions.delete(wc.id); - throw cause; - } - } - - private detachControlSession(webContentsId: number): void { - const control = this.controlSessions.get(webContentsId); - this.controlSessions.delete(webContentsId); - this.diagnostics.delete(webContentsId); - const wc = webContents.fromId(webContentsId); - if (!wc || wc.isDestroyed()) return; - if (control) { - wc.debugger.off("message", control.onMessage); - } - if (!wc.debugger.isAttached()) return; - try { - wc.debugger.detach(); - } catch { - // Target teardown can race detachment. - } - } - - private tabIdForWebContents(webContentsId: number): string | null { - for (const [tabId, tab] of this.tabs) { - if (tab.webContentsId === webContentsId) return tabId; - } - return null; - } - - private captureDiagnosticMessage( - diagnostics: BrowserDiagnostics, - method: string, - params: Record<string, unknown>, - ): void { - const timestamp = new Date().toISOString(); - if (method === "Runtime.consoleAPICalled") { - const args = Array.isArray(params["args"]) ? params["args"] : []; - const text = args - .map((arg) => { - if (typeof arg !== "object" || arg === null) return String(arg); - const value = arg as Record<string, unknown>; - return String(value["value"] ?? value["description"] ?? ""); - }) - .join(" "); - this.pushBounded(diagnostics.consoleEntries, { - level: typeof params["type"] === "string" ? params["type"] : "log", - text, - timestamp, - source: "console", - }); - return; - } - if (method === "Runtime.exceptionThrown") { - const details = - typeof params["exceptionDetails"] === "object" && params["exceptionDetails"] !== null - ? (params["exceptionDetails"] as Record<string, unknown>) - : {}; - this.pushBounded(diagnostics.consoleEntries, { - level: "error", - text: String(details["text"] ?? "Uncaught exception"), - timestamp, - source: "exception", - }); - return; - } - if (method === "Log.entryAdded") { - const entry = - typeof params["entry"] === "object" && params["entry"] !== null - ? (params["entry"] as Record<string, unknown>) - : {}; - this.pushBounded(diagnostics.consoleEntries, { - level: typeof entry["level"] === "string" ? entry["level"] : "info", - text: String(entry["text"] ?? ""), - timestamp, - source: typeof entry["source"] === "string" ? entry["source"] : "log", - }); - return; - } - const requestId = typeof params["requestId"] === "string" ? params["requestId"] : null; - if (method === "Network.requestWillBeSent" && requestId) { - const request = - typeof params["request"] === "object" && params["request"] !== null - ? (params["request"] as Record<string, unknown>) - : {}; - diagnostics.requests.set(requestId, { - url: String(request["url"] ?? ""), - method: String(request["method"] ?? "GET"), - }); - return; - } - if (method === "Network.responseReceived" && requestId) { - const request = diagnostics.requests.get(requestId); - const response = - typeof params["response"] === "object" && params["response"] !== null - ? (params["response"] as Record<string, unknown>) - : {}; - const status = typeof response["status"] === "number" ? response["status"] : null; - if (request && status !== null && status >= 400) { - this.pushBounded(diagnostics.networkEntries, { - ...request, - status, - failed: true, - timestamp, - }); - } - return; - } - if (method === "Network.loadingFailed" && requestId) { - const request = diagnostics.requests.get(requestId); - if (request) { - this.pushBounded(diagnostics.networkEntries, { - ...request, - status: null, - failed: true, - errorText: String(params["errorText"] ?? "Network request failed"), - timestamp, - }); - } - diagnostics.requests.delete(requestId); - return; - } - if (method === "Network.loadingFinished" && requestId) diagnostics.requests.delete(requestId); - } - - private pushBounded<A>(buffer: A[], entry: A): void { - buffer.push(entry); - if (buffer.length > DIAGNOSTIC_BUFFER_LIMIT) { - buffer.splice(0, buffer.length - DIAGNOSTIC_BUFFER_LIMIT); - } - } - - private async evaluateWithDebugger<A = unknown>( - send: (method: string, commandParams?: Record<string, unknown>) => Promise<unknown>, - expression: string, - returnByValue: boolean, - awaitPromise = true, - ): Promise<A> { - const response = (await send("Runtime.evaluate", { - expression, - awaitPromise, - returnByValue, - userGesture: true, - })) as CdpEvaluationResult; - if (response.exceptionDetails) { - throw automationError( - "PreviewAutomationExecutionError", - response.exceptionDetails.exception?.description ?? - response.exceptionDetails.text ?? - "JavaScript evaluation failed.", - ); - } - return response.result?.value as A; - } - - private automationLocator(input: { - readonly selector?: string | undefined; - readonly locator?: string | undefined; - }): string | null { - if (input.locator) return input.locator; - if (input.selector) return `css=${input.selector}`; - return null; - } - - private async ensurePlaywrightInjected( - send: (method: string, commandParams?: Record<string, unknown>) => Promise<unknown>, - ): Promise<void> { - const installed = await this.evaluateWithDebugger<boolean>( - send, - "Boolean(globalThis.__t3PlaywrightInjected)", - true, + return value; + }), ); - if (installed) return; - const expression = await playwrightInjectedRuntimeInstallExpression(); - await this.evaluateWithDebugger(send, expression, true); - } - - onStateChange(listener: Listener): () => void { - this.listeners.add(listener); - return () => { - this.listeners.delete(listener); - }; - } - - onPointerEvent(listener: PointerEventListener): () => void { - this.pointerEventListeners.add(listener); - return () => { - this.pointerEventListeners.delete(listener); - }; - } - - private emitPointerEvent(event: DesktopPreviewPointerEvent): void { - for (const listener of this.pointerEventListeners) listener(event); - } + }); - private expectAgentInput(tabId: string, signal: PreviewInputSignal): void { - const now = Date.now(); - const pending = (this.expectedAgentInputs.get(tabId) ?? []).filter( - (expected) => expected.expiresAt > now, + const automationWaitFor = Effect.fn("PreviewManager.automationWaitFor")(function* ( + tabId: string, + input: PreviewAutomationWaitForInput, + ) { + const wc = yield* requireWebContents(tabId); + const timeoutMs = input.timeoutMs ?? 15_000; + yield* withControlSession(tabId, wc, "waitFor", (send) => + Effect.gen(function* () { + yield* send("Runtime.enable"); + const locator = automationLocator(input); + if (locator) yield* ensurePlaywrightInjected(send); + const [locatorJson, textJson, urlIncludesJson] = yield* Effect.all([ + locator ? encodeJson("automationWaitFor.encodeLocator", locator) : Effect.succeed(null), + input.text + ? encodeJson("automationWaitFor.encodeText", input.text) + : Effect.succeed(null), + input.urlIncludes + ? encodeJson("automationWaitFor.encodeUrl", input.urlIncludes) + : Effect.succeed(null), + ]); + const deadline = (yield* currentMillis) + timeoutMs; + while ((yield* currentMillis) <= deadline) { + const result = yield* evaluateWithDebugger< + { matched: boolean } | { invalidSelector: true; message: string } + >( + send, + `(() => { + try { + const selectorMatched = ${locatorJson ? `(() => { const injected = globalThis.__t3PlaywrightInjected; return injected.querySelector(injected.parseSelector(${locatorJson}), document, false) !== null; })()` : "true"}; + const textMatched = ${ + textJson ? `(document.body?.innerText || "").includes(${textJson})` : "true" + }; + const urlMatched = ${ + urlIncludesJson ? `location.href.includes(${urlIncludesJson})` : "true" + }; + return { matched: selectorMatched && textMatched && urlMatched }; + } catch (error) { + return { invalidSelector: true, message: String(error) }; + } + })()`, + true, + ); + if ("invalidSelector" in result) { + return yield* fail( + "automationWaitFor", + automationError("PreviewAutomationInvalidSelectorError", result.message, { + selector: input.selector ?? "", + }), + ); + } + if (result.matched) return; + yield* Effect.sleep(100); + } + return yield* fail( + "automationWaitFor", + automationError( + "PreviewAutomationTimeoutError", + `Preview condition did not match within ${timeoutMs}ms.`, + ), + ); + }), ); - pending.push({ signal, expiresAt: now + 1_000 }); - this.expectedAgentInputs.set(tabId, pending); - } + }); - private consumeExpectedAgentInput(tabId: string, signal: PreviewInputSignal): boolean { - const now = Date.now(); - const pending = (this.expectedAgentInputs.get(tabId) ?? []).filter( - (expected) => expected.expiresAt > now, - ); - const index = pending.findIndex((expected) => inputSignalsMatch(expected.signal, signal)); - if (index < 0) { - if (pending.length === 0) this.expectedAgentInputs.delete(tabId); - else this.expectedAgentInputs.set(tabId, pending); - return false; - } - pending.splice(index, 1); - if (pending.length === 0) this.expectedAgentInputs.delete(tabId); - else this.expectedAgentInputs.set(tabId, pending); - return true; - } + const revealArtifact = Effect.fn("PreviewManager.revealArtifact")(function* ( + artifactPath: string, + ) { + const resolvedPath = yield* resolveArtifactPath(artifactPath); + yield* attempt("revealArtifact", () => shell.showItemInFolder(resolvedPath)); + }); - destroy(): void { - for (const tabId of Array.from(this.tabs.keys())) { - this.closeTab(tabId); + const copyArtifactToClipboard = Effect.fn("PreviewManager.copyArtifactToClipboard")(function* ( + artifactPath: string, + ) { + const resolvedPath = yield* resolveArtifactPath(artifactPath); + const image = yield* attempt("copyArtifactToClipboard.load", () => + nativeImage.createFromPath(resolvedPath), + ); + if (image.isEmpty()) { + return yield* fail( + "copyArtifactToClipboard", + new Error("Preview artifact could not be loaded as an image."), + ); } - this.listeners.clear(); - this.expectedAgentInputs.clear(); - this.pointerEventListeners.clear(); - this.recordingFrameListeners.clear(); - } - - private attachListeners(tabId: string, wc: Electron.WebContents): void { - const sync = () => { - if (wc.isDestroyed()) return; - this.update(tabId, { - navStatus: this.computeNavStatus(wc), - canGoBack: wc.navigationHistory.canGoBack(), - canGoForward: wc.navigationHistory.canGoForward(), - }); - }; - const failed = (_event: Event, code: number, description: string): void => { - // -3 = ABORTED (user navigated away mid-load); ignore. - if (code === -3) return; - this.update(tabId, { - navStatus: { - kind: "LoadFailed", - url: wc.getURL(), - title: wc.getTitle(), - code, - description, - }, - }); - }; - const humanInput = (_event: unknown, rawSignal?: unknown): void => { - if (isPreviewInputSignal(rawSignal) && this.consumeExpectedAgentInput(tabId, rawSignal)) { - return; - } - this.controlEpoch.set(tabId, (this.controlEpoch.get(tabId) ?? 0) + 1); - this.update(tabId, { controller: "human" }); - void sleep(750).then(() => { - if (this.tabs.get(tabId)?.controller === "human") { - this.update(tabId, { controller: "none" }); - } - }); - }; - - 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", failed as never); - wc.ipc.on(HUMAN_INPUT_CHANNEL, humanInput); - - // Keep external links inside the same view (matches ami's policy). - wc.setWindowOpenHandler(({ url }) => { - void wc.loadURL(url); - return { action: "deny" }; - }); - - // Forward app-level shortcuts to the main window so mod+shift+J etc. - // still toggles the preview panel even when the webview has focus. - const beforeInput = (event: Electron.Event, input: Electron.Input): void => { - 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) : []), - ], - }); - } - }; - wc.on("before-input-event", beforeInput); - - this.attached.set(wc.id, { navigate: sync, failed, humanInput, beforeInput }); - } - - private detachListeners(webContentsId: number): void { - const handlers = this.attached.get(webContentsId); - if (!handlers) return; - this.attached.delete(webContentsId); - const wc = webContents.fromId(webContentsId); - if (!wc || wc.isDestroyed()) return; - wc.off("did-navigate", handlers.navigate); - wc.off("did-navigate-in-page", handlers.navigate); - wc.off("page-title-updated", handlers.navigate); - wc.off("did-start-loading", handlers.navigate); - wc.off("did-stop-loading", handlers.navigate); - wc.off("did-fail-load", handlers.failed as never); - wc.off("before-input-event", handlers.beforeInput); - wc.ipc.off(HUMAN_INPUT_CHANNEL, handlers.humanInput); - } + yield* attempt("copyArtifactToClipboard.write", () => clipboard.writeImage(image)); + }); - private isAppShortcut(input: Electron.Input): boolean { - if (input.type !== "keyDown") return false; - return APP_FORWARDED_SHORTCUTS.some( - (shortcut) => - shortcut.key.toLowerCase() === input.key.toLowerCase() && - shortcut.meta === input.meta && - shortcut.shift === input.shift && - shortcut.control === input.control, + const subscribe = <A>( + ref: Ref.Ref<ReadonlySet<A>>, + listener: A, + ): Effect.Effect<void, never, Scope.Scope> => + Effect.acquireRelease( + Ref.update(ref, (listeners) => new Set([...listeners, listener])), + () => + Ref.update(ref, (listeners) => { + const next = new Set(listeners); + next.delete(listener); + return next; + }), + ).pipe(Effect.asVoid); + + const destroy = Effect.fn("PreviewManager.destroy")(function* () { + const tabs = yield* SynchronizedRef.get(tabsRef); + yield* Effect.forEach(tabs.keys(), closeTab, { discard: true }); + yield* Effect.all( + [ + Ref.set(listenersRef, new Set()), + Ref.set(expectedAgentInputsRef, new Map()), + Ref.set(pointerEventListenersRef, new Set()), + Ref.set(recordingFrameListenersRef, new Set()), + ], + { discard: true }, ); - } - - private computeNavStatus(wc: Electron.WebContents): PreviewNavStatus { - 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<PreviewTabState>): void { - const current = this.tabs.get(tabId); - if (!current) return; - const next: PreviewTabState = { - ...current, - ...patch, - updatedAt: new Date().toISOString(), - }; - this.tabs.set(tabId, next); - this.emit(tabId, next); - } + }); - private emit(tabId: string, state: PreviewTabState): void { - for (const listener of this.listeners) { - try { - listener(tabId, state); - } catch { - // listener errors must not crash the manager - } - } - } + yield* Effect.addFinalizer(() => destroy().pipe(Effect.ignore)); - private normalizeUrl(input: string): string { - // Surface the shared error directly so the IPC caller (and any future - // desktop-side telemetry) gets the `detail` field for free. Defining a - // bespoke desktop class would just lose information. - return normalizePreviewUrl(input); - } -} + return { + automationClick, + automationEvaluate, + automationPress, + automationScroll, + automationSnapshot, + automationStatus, + automationType, + automationWaitFor, + cancelPickElement, + captureScreenshot, + closeTab, + copyArtifactToClipboard, + createTab, + goBack, + goForward, + hardReload, + navigate, + openDevTools, + pickElement, + refresh, + registerWebview, + resetZoom: (tabId: string) => applyZoom(tabId, () => DEFAULT_ZOOM_FACTOR), + revealArtifact, + saveRecording, + setAnnotationTheme, + setMainWindow, + startRecording, + stopRecording, + subscribePointerEvents: (listener: PointerEventListener) => + subscribe(pointerEventListenersRef, listener), + subscribeRecordingFrames: (listener: RecordingFrameListener) => + subscribe(recordingFrameListenersRef, listener), + subscribeStateChanges: (listener: Listener) => subscribe(listenersRef, listener), + zoomIn: (tabId: string) => applyZoom(tabId, (current) => nextZoomLevel(current, "in")), + zoomOut: (tabId: string) => applyZoom(tabId, (current) => nextZoomLevel(current, "out")), + }; +}); export class PreviewTabNotFoundError extends Error { readonly tabId: string; @@ -1725,7 +2122,7 @@ export interface PreviewManagerShape { readonly openDevTools: (tabId: string) => Effect.Effect<void, PreviewManagerError>; readonly clearCookies: () => Effect.Effect<void, PreviewManagerError>; readonly clearCache: () => Effect.Effect<void, PreviewManagerError>; - readonly getBrowserPartition: (scope?: string) => string; + readonly getBrowserPartition: (scope?: string) => Effect.Effect<string, PreviewManagerError>; readonly setAnnotationTheme: ( theme: DesktopPreviewAnnotationTheme, ) => Effect.Effect<void, PreviewManagerError>; @@ -1788,29 +2185,10 @@ export class PreviewManager extends Context.Service<PreviewManager, PreviewManag "@t3tools/desktop/preview/Manager/PreviewManager", ) {} -const make = Effect.fn("PreviewManager.make")(function* () { +const make = Effect.gen(function* PreviewManagerMake() { const environment = yield* DesktopEnvironment.DesktopEnvironment; const browserSession = yield* BrowserSession.BrowserSession; - const manager = new PreviewViewManager(); - manager.configureArtifactDirectory(environment.browserArtifactsDir); - yield* Effect.addFinalizer(() => Effect.sync(() => manager.destroy())); - - const attempt = <A>( - operation: string, - evaluate: () => A, - ): Effect.Effect<A, PreviewManagerError> => - Effect.try({ - try: evaluate, - catch: (cause) => new PreviewManagerError({ operation, cause }), - }); - const attemptPromise = <A>( - operation: string, - evaluate: () => Promise<A>, - ): Effect.Effect<A, PreviewManagerError> => - Effect.tryPromise({ - try: evaluate, - catch: (cause) => new PreviewManagerError({ operation, cause }), - }); + const operations = yield* makeNativeOperations(environment.browserArtifactsDir); const browserSessionEffect = <A>( operation: string, effect: Effect.Effect<A, BrowserSession.BrowserSessionError>, @@ -1818,132 +2196,53 @@ const make = Effect.fn("PreviewManager.make")(function* () { effect.pipe(Effect.mapError((cause) => new PreviewManagerError({ operation, cause }))); return PreviewManager.of({ - setMainWindow: Effect.fn("PreviewManager.setMainWindow")(function* (window) { - yield* attempt("setMainWindow", () => manager.setMainWindow(window)); - }), + setMainWindow: operations.setMainWindow, getBrowserSession: Effect.fn("PreviewManager.getBrowserSession")(function* (scope) { return yield* browserSessionEffect("getBrowserSession", browserSession.getSession(scope)); }), isBrowserPartition: browserSession.isPartition, - createTab: Effect.fn("PreviewManager.createTab")(function* (tabId) { - return yield* attempt("createTab", () => manager.createTab(tabId)); - }), - closeTab: Effect.fn("PreviewManager.closeTab")(function* (tabId) { - yield* attempt("closeTab", () => manager.closeTab(tabId)); - }), - registerWebview: Effect.fn("PreviewManager.registerWebview")(function* (tabId, webContentsId) { - yield* attempt("registerWebview", () => manager.registerWebview(tabId, webContentsId)); - }), - navigate: Effect.fn("PreviewManager.navigate")(function* (tabId, url) { - yield* attemptPromise("navigate", () => manager.navigate(tabId, url)); - }), - goBack: Effect.fn("PreviewManager.goBack")(function* (tabId) { - yield* attempt("goBack", () => manager.goBack(tabId)); - }), - goForward: Effect.fn("PreviewManager.goForward")(function* (tabId) { - yield* attempt("goForward", () => manager.goForward(tabId)); - }), - refresh: Effect.fn("PreviewManager.refresh")(function* (tabId) { - yield* attempt("refresh", () => manager.refresh(tabId)); - }), - zoomIn: Effect.fn("PreviewManager.zoomIn")(function* (tabId) { - yield* attempt("zoomIn", () => manager.zoomIn(tabId)); - }), - zoomOut: Effect.fn("PreviewManager.zoomOut")(function* (tabId) { - yield* attempt("zoomOut", () => manager.zoomOut(tabId)); - }), - resetZoom: Effect.fn("PreviewManager.resetZoom")(function* (tabId) { - yield* attempt("resetZoom", () => manager.resetZoom(tabId)); - }), - hardReload: Effect.fn("PreviewManager.hardReload")(function* (tabId) { - yield* attempt("hardReload", () => manager.hardReload(tabId)); - }), - openDevTools: Effect.fn("PreviewManager.openDevTools")(function* (tabId) { - yield* attempt("openDevTools", () => manager.openDevTools(tabId)); - }), + createTab: operations.createTab, + closeTab: operations.closeTab, + registerWebview: operations.registerWebview, + navigate: operations.navigate, + goBack: operations.goBack, + goForward: operations.goForward, + refresh: operations.refresh, + zoomIn: operations.zoomIn, + zoomOut: operations.zoomOut, + resetZoom: operations.resetZoom, + hardReload: operations.hardReload, + openDevTools: operations.openDevTools, clearCookies: Effect.fn("PreviewManager.clearCookies")(function* () { yield* browserSessionEffect("clearCookies", browserSession.clearCookies()); }), clearCache: Effect.fn("PreviewManager.clearCache")(function* () { yield* browserSessionEffect("clearCache", browserSession.clearCache()); }), - getBrowserPartition: browserSession.getPartition, - setAnnotationTheme: Effect.fn("PreviewManager.setAnnotationTheme")(function* (theme) { - yield* attempt("setAnnotationTheme", () => manager.setAnnotationTheme(theme)); - }), - pickElement: Effect.fn("PreviewManager.pickElement")(function* (tabId) { - return yield* attemptPromise("pickElement", () => manager.pickElement(tabId)); - }), - cancelPickElement: Effect.fn("PreviewManager.cancelPickElement")(function* (tabId) { - yield* attempt("cancelPickElement", () => manager.cancelPickElement(tabId)); - }), - captureScreenshot: Effect.fn("PreviewManager.captureScreenshot")(function* (tabId) { - return yield* attemptPromise("captureScreenshot", () => manager.captureScreenshot(tabId)); - }), - revealArtifact: Effect.fn("PreviewManager.revealArtifact")(function* (path) { - yield* attempt("revealArtifact", () => manager.revealArtifact(path)); - }), - copyArtifactToClipboard: Effect.fn("PreviewManager.copyArtifactToClipboard")(function* (path) { - yield* attempt("copyArtifactToClipboard", () => manager.copyArtifactToClipboard(path)); + getBrowserPartition: Effect.fn("PreviewManager.getBrowserPartition")(function* (scope) { + return yield* browserSessionEffect("getBrowserPartition", browserSession.getPartition(scope)); }), - startRecording: Effect.fn("PreviewManager.startRecording")(function* (tabId) { - yield* attemptPromise("startRecording", () => manager.startRecording(tabId)); - }), - stopRecording: Effect.fn("PreviewManager.stopRecording")(function* (tabId) { - yield* attemptPromise("stopRecording", () => manager.stopRecording(tabId)); - }), - saveRecording: Effect.fn("PreviewManager.saveRecording")(function* (tabId, mimeType, data) { - return yield* attemptPromise("saveRecording", () => - manager.saveRecording(tabId, mimeType, data), - ); - }), - automationStatus: Effect.fn("PreviewManager.automationStatus")(function* (tabId) { - return yield* attempt("automationStatus", () => manager.automationStatus(tabId)); - }), - automationSnapshot: Effect.fn("PreviewManager.automationSnapshot")(function* (tabId) { - return yield* attemptPromise("automationSnapshot", () => manager.automationSnapshot(tabId)); - }), - automationClick: Effect.fn("PreviewManager.automationClick")(function* (tabId, input) { - yield* attemptPromise("automationClick", () => manager.automationClick(tabId, input)); - }), - automationType: Effect.fn("PreviewManager.automationType")(function* (tabId, input) { - yield* attemptPromise("automationType", () => manager.automationType(tabId, input)); - }), - automationPress: Effect.fn("PreviewManager.automationPress")(function* (tabId, input) { - yield* attemptPromise("automationPress", () => manager.automationPress(tabId, input)); - }), - automationScroll: Effect.fn("PreviewManager.automationScroll")(function* (tabId, input) { - yield* attemptPromise("automationScroll", () => manager.automationScroll(tabId, input)); - }), - automationEvaluate: Effect.fn("PreviewManager.automationEvaluate")(function* (tabId, input) { - return yield* attemptPromise("automationEvaluate", () => - manager.automationEvaluate(tabId, input), - ); - }), - automationWaitFor: Effect.fn("PreviewManager.automationWaitFor")(function* (tabId, input) { - yield* attemptPromise("automationWaitFor", () => manager.automationWaitFor(tabId, input)); - }), - subscribeStateChanges: (listener) => - Effect.acquireRelease( - Effect.sync(() => manager.onStateChange(listener)), - (unsubscribe) => Effect.sync(unsubscribe), - ).pipe(Effect.asVoid), - subscribePointerEvents: (listener) => - Effect.acquireRelease( - Effect.sync(() => manager.onPointerEvent(listener)), - (unsubscribe) => Effect.sync(unsubscribe), - ).pipe(Effect.asVoid), - subscribeRecordingFrames: (listener) => - Effect.acquireRelease( - Effect.sync(() => manager.onRecordingFrame(listener)), - (unsubscribe) => Effect.sync(unsubscribe), - ).pipe(Effect.asVoid), + setAnnotationTheme: operations.setAnnotationTheme, + pickElement: operations.pickElement, + cancelPickElement: operations.cancelPickElement, + captureScreenshot: operations.captureScreenshot, + revealArtifact: operations.revealArtifact, + copyArtifactToClipboard: operations.copyArtifactToClipboard, + startRecording: operations.startRecording, + stopRecording: operations.stopRecording, + saveRecording: operations.saveRecording, + automationStatus: operations.automationStatus, + automationSnapshot: operations.automationSnapshot, + automationClick: operations.automationClick, + automationType: operations.automationType, + automationPress: operations.automationPress, + automationScroll: operations.automationScroll, + automationEvaluate: operations.automationEvaluate, + automationWaitFor: operations.automationWaitFor, + subscribeStateChanges: operations.subscribeStateChanges, + subscribePointerEvents: operations.subscribePointerEvents, + subscribeRecordingFrames: operations.subscribeRecordingFrames, }); -}); +}).pipe(Effect.withSpan("PreviewManager.make")); -export const layer = Layer.effect(PreviewManager, make()); - -/** Exposed for tests. */ -export const __testing = { - PreviewViewManager, -}; +export const layer = Layer.effect(PreviewManager, make); diff --git a/apps/desktop/src/preview/PickPreload.ts b/apps/desktop/src/preview/PickPreload.ts index e8c31a8e1e5..2654b898102 100644 --- a/apps/desktop/src/preview/PickPreload.ts +++ b/apps/desktop/src/preview/PickPreload.ts @@ -1,4 +1,4 @@ -// @effect-diagnostics globalDate:off +// @effect-diagnostics globalDate:off - This isolated Electron preload does not run inside an Effect runtime. import { ipcRenderer } from "electron"; import { getElementContext } from "react-grab/primitives"; import type { diff --git a/apps/desktop/src/preview/PlaywrightInjectedRuntime.test.ts b/apps/desktop/src/preview/PlaywrightInjectedRuntime.test.ts index 02d80baf63c..33915dba0be 100644 --- a/apps/desktop/src/preview/PlaywrightInjectedRuntime.test.ts +++ b/apps/desktop/src/preview/PlaywrightInjectedRuntime.test.ts @@ -1,4 +1,6 @@ -import { describe, expect, it } from "vite-plus/test"; +import { it as effectIt } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { describe, expect } from "vite-plus/test"; import { playwrightInjectedRuntimeInstallExpression, @@ -6,15 +8,19 @@ import { } from "./PlaywrightInjectedRuntime.ts"; describe("playwright injected runtime", () => { - it("extracts the pinned runtime from playwright-core", async () => { - const source = await playwrightInjectedRuntimeSource(); - expect(source.length).toBeGreaterThan(100_000); - expect(source).toContain("InjectedScript"); - }); + effectIt.effect("extracts the pinned runtime from playwright-core", () => + Effect.gen(function* () { + const source = yield* playwrightInjectedRuntimeSource(); + expect(source.length).toBeGreaterThan(100_000); + expect(source).toContain("InjectedScript"); + }), + ); - it("builds an idempotent install expression", async () => { - const expression = await playwrightInjectedRuntimeInstallExpression(); - expect(expression).toContain("__t3PlaywrightInjected"); - expect(expression).toContain('testIdAttributeName":"data-testid'); - }); + effectIt.effect("builds an idempotent install expression", () => + Effect.gen(function* () { + const expression = yield* playwrightInjectedRuntimeInstallExpression(); + expect(expression).toContain("__t3PlaywrightInjected"); + expect(expression).toContain('testIdAttributeName":"data-testid'); + }), + ); }); diff --git a/apps/desktop/src/preview/PlaywrightInjectedRuntime.ts b/apps/desktop/src/preview/PlaywrightInjectedRuntime.ts index d4d7c6bd940..1a4dce14f87 100644 --- a/apps/desktop/src/preview/PlaywrightInjectedRuntime.ts +++ b/apps/desktop/src/preview/PlaywrightInjectedRuntime.ts @@ -1,35 +1,76 @@ -// @effect-diagnostics nodeBuiltinImport:off +// @effect-diagnostics nodeBuiltinImport:off - Extracts Playwright's installed Node bundle for browser injection. +import { readFile } from "node:fs/promises"; import { createRequire } from "node:module"; import { dirname, join } from "node:path"; -import { readFile } from "node:fs/promises"; import { runInNewContext } from "node:vm"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + const require = createRequire(import.meta.url); -let sourcePromise: Promise<string> | null = null; +const encodeUnknownJson = Schema.encodeUnknownEffect(Schema.UnknownFromJsonString); + +export class PlaywrightInjectedRuntimeError extends Data.TaggedError( + "PlaywrightInjectedRuntimeError", +)<{ + readonly operation: string; + readonly cause: unknown; +}> { + override get message() { + return `Playwright injected runtime operation failed: ${this.operation}`; + } +} -export const playwrightInjectedRuntimeSource = (): Promise<string> => { - sourcePromise ??= (async () => { - const packageJsonPath = require.resolve("playwright-core/package.json"); - const coreBundle = await readFile(join(dirname(packageJsonPath), "lib/coreBundle.js"), "utf8"); +const fail = (operation: string, cause: unknown) => + new PlaywrightInjectedRuntimeError({ operation, cause }); + +export const playwrightInjectedRuntimeSource = Effect.fn("PlaywrightInjectedRuntime.source")( + function* () { + const packageJsonPath = yield* Effect.try({ + try: () => require.resolve("playwright-core/package.json"), + catch: (cause) => fail("resolvePackage", cause), + }); + const coreBundle = yield* Effect.tryPromise({ + try: () => readFile(join(dirname(packageJsonPath), "lib/coreBundle.js"), "utf8"), + catch: (cause) => fail("readCoreBundle", cause), + }); const marker = "source3 = "; const start = coreBundle.indexOf(marker); - if (start < 0) throw new Error("Playwright injected runtime marker was not found."); + if (start < 0) { + return yield* fail( + "findSourceMarker", + new Error("Playwright injected runtime marker was not found."), + ); + } const literalStart = start + marker.length; const literalEnd = coreBundle.indexOf(";\n }\n});", literalStart); - if (literalEnd < 0) throw new Error("Playwright injected runtime terminator was not found."); + if (literalEnd < 0) { + return yield* fail( + "findSourceTerminator", + new Error("Playwright injected runtime terminator was not found."), + ); + } const literal = coreBundle.slice(literalStart, literalEnd); - const source = runInNewContext(literal, Object.create(null), { timeout: 1_000 }); + const source = yield* Effect.try({ + try: () => runInNewContext(literal, Object.create(null), { timeout: 1_000 }), + catch: (cause) => fail("evaluateSourceLiteral", cause), + }); if (typeof source !== "string" || source.length < 100_000) { - throw new Error("Playwright injected runtime extraction returned invalid source."); + return yield* fail( + "validateSource", + new Error("Playwright injected runtime extraction returned invalid source."), + ); } return source; - })(); - return sourcePromise; -}; + }, +); -export const playwrightInjectedRuntimeInstallExpression = async (): Promise<string> => { - const source = await playwrightInjectedRuntimeSource(); - const options = { +export const playwrightInjectedRuntimeInstallExpression = Effect.fn( + "PlaywrightInjectedRuntime.installExpression", +)(function* () { + const source = yield* playwrightInjectedRuntimeSource(); + const options = yield* encodeUnknownJson({ isUnderTest: false, sdkLanguage: "javascript", testIdAttributeName: "data-testid", @@ -38,12 +79,12 @@ export const playwrightInjectedRuntimeInstallExpression = async (): Promise<stri shouldPrependErrorPrefix: false, isUtilityWorld: false, customEngines: [], - }; + }).pipe(Effect.mapError((cause) => fail("encodeOptions", cause))); return `(() => { if (globalThis.__t3PlaywrightInjected) return true; const module = { exports: {} }; ${source} - globalThis.__t3PlaywrightInjected = new (module.exports.InjectedScript())(globalThis, ${JSON.stringify(options)}); + globalThis.__t3PlaywrightInjected = new (module.exports.InjectedScript())(globalThis, ${options}); return true; })()`; -}; +}); diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 569ac771c73..2d133517132 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -171,7 +171,7 @@ function makeTestLayer(input: { getBrowserSession: () => Effect.succeed({} as Electron.Session), setMainWindow: () => Effect.void, isBrowserPartition: (partition) => partition.startsWith("persist:t3code-preview-"), - getBrowserPartition: () => "persist:t3code-preview-test", + getBrowserPartition: () => Effect.succeed("persist:t3code-preview-test"), }), ), ), diff --git a/apps/web/src/components/preview/PreviewAutomationOwner.tsx b/apps/web/src/components/preview/PreviewAutomationOwner.tsx index fa8d1e3f715..c5aab637a96 100644 --- a/apps/web/src/components/preview/PreviewAutomationOwner.tsx +++ b/apps/web/src/components/preview/PreviewAutomationOwner.tsx @@ -1,5 +1,4 @@ "use client"; -// @effect-diagnostics cryptoRandomUUID:off import { scopedThreadKey } from "@t3tools/client-runtime"; import type { @@ -10,7 +9,7 @@ import type { PreviewAutomationStatus, ScopedThreadRef, } from "@t3tools/contracts"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useId, useRef } from "react"; import { ensureEnvironmentApi } from "~/environmentApi"; import { selectThreadPreviewState, usePreviewStateStore } from "~/previewStateStore"; @@ -24,11 +23,6 @@ import { import { previewBridge } from "./previewBridge"; -const newAutomationClientId = (): string => - typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" - ? crypto.randomUUID() - : `preview-${Math.random().toString(36).slice(2)}`; - const waitForDesktopOverlay = async ( threadRef: ScopedThreadRef, timeoutMs: number, @@ -118,7 +112,7 @@ export function PreviewAutomationOwner(props: { readonly visible: boolean; }) { const { threadRef, visible } = props; - const [automationClientId] = useState(newAutomationClientId); + const automationClientId = useId(); const ownerStateRef = useRef({ threadRef, visible }); const handlerRef = useRef<(request: PreviewAutomationRequest) => Promise<unknown>>( async () => undefined, diff --git a/apps/web/src/reactGrabBoundary.test.ts b/apps/web/src/reactGrabBoundary.test.ts index 672bd9f3f9b..9481e2dd9fa 100644 --- a/apps/web/src/reactGrabBoundary.test.ts +++ b/apps/web/src/reactGrabBoundary.test.ts @@ -1,13 +1,10 @@ -// @effect-diagnostics nodeBuiltinImport:off -import { readFileSync } from "node:fs"; import { describe, expect, it } from "vite-plus/test"; import packageJson from "../package.json" with { type: "json" }; +import mainSource from "./main.tsx?raw"; describe("React Grab runtime boundary", () => { it("keeps the host renderer free of the React Grab overlay", () => { - const mainSource = readFileSync(new URL("./main.tsx", import.meta.url), "utf8"); - expect(mainSource).not.toMatch(/import\(["']react-grab["']\)/); expect(packageJson.dependencies).not.toHaveProperty("react-grab"); expect(packageJson.devDependencies).not.toHaveProperty("react-grab"); From 2e3f4ee5c018265064105e4ebc5c2910121da51b Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Fri, 12 Jun 2026 18:19:01 -0700 Subject: [PATCH 15/25] Scope preview listeners and control sessions - Tie preview and debugger listeners to Effect scopes - Factor shared automation helpers for snapshot and input handling - Improve cleanup for browser preview sessions and port scanning --- apps/desktop/src/preview/Manager.ts | 1035 +++++++++-------- apps/server/src/mcp/McpHttpServer.test.ts | 2 +- apps/server/src/mcp/McpHttpServer.ts | 155 +-- apps/server/src/mcp/McpSessionRegistry.ts | 68 +- .../src/mcp/PreviewAutomationBroker.test.ts | 6 +- .../server/src/mcp/PreviewAutomationBroker.ts | 102 +- .../src/mcp/toolkits/preview/handlers.ts | 28 +- apps/server/src/mcp/toolkits/preview/tools.ts | 21 + apps/server/src/preview/Manager.ts | 6 +- apps/server/src/preview/PortScanner.test.ts | 8 +- apps/server/src/preview/PortScanner.ts | 129 +- apps/server/src/server.test.ts | 4 +- apps/server/src/ws.ts | 36 +- 13 files changed, 853 insertions(+), 747 deletions(-) diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index a55283fbebb..f82741d908f 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -48,7 +48,7 @@ import * as Path from "effect/Path"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; -import type * as Scope from "effect/Scope"; +import * as Scope from "effect/Scope"; import * as SynchronizedRef from "effect/SynchronizedRef"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; @@ -247,10 +247,7 @@ type PreviewInputSignal = | { readonly kind: "key"; readonly key: string; readonly code: string }; interface ManagedListeners { - navigate: () => void; - failed: (event: Event, code: number, description: string) => void; - humanInput: (_event: unknown, signal?: unknown) => void; - beforeInput: (event: Electron.Event, input: Electron.Input) => void; + readonly scope: Scope.Closeable; } interface PickSession { @@ -260,6 +257,7 @@ interface PickSession { interface BrowserControlSession { readonly webContentsId: number; readonly semaphore: Semaphore.Semaphore; + readonly scope: Scope.Closeable; readonly onMessage: ( event: Electron.Event, method: string, @@ -339,6 +337,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ) { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const parentScope = yield* Scope.Scope; const context = yield* Effect.context<never>(); const runFork = Effect.runForkWith(context); const resolvedArtifactDirectory = path.resolve(artifactDirectory); @@ -622,16 +621,15 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function copy.delete(webContentsId); }), ]); + if (control) { + yield* Scope.close(control.scope, Exit.void).pipe(Effect.ignore); + return; + } yield* Ref.update(diagnosticsRef, (diagnostics) => replaceMap(diagnostics, (copy) => { copy.delete(webContentsId); }), ); - const wc = webContents.fromId(webContentsId); - if (!wc || wc.isDestroyed()) return; - if (control) wc.debugger.off("message", control.onMessage); - if (!wc.debugger.isAttached()) return; - yield* attempt("detachControlSession", () => wc.debugger.detach()).pipe(Effect.ignore); }); const ensureControlSession = Effect.fn("PreviewManager.ensureControlSession")(function* ( @@ -662,87 +660,103 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ), ); } - return Effect.gen(function* () { + const createControlSession = Effect.fn("PreviewManager.createControlSession")(function* () { const semaphore = yield* Semaphore.make(1); + const scope = yield* Scope.fork(parentScope, "sequential"); + const handleDebuggerMessage = Effect.fn("PreviewManager.handleDebuggerMessage")(function* ( + method: string, + params: Record<string, unknown>, + ) { + if (method === "Page.screencastFrame") { + const sessionId = params["sessionId"]; + if (typeof sessionId === "number") { + yield* attemptPromise("ackScreencastFrame", () => + wc.debugger.sendCommand("Page.screencastFrameAck", { sessionId }), + ).pipe(Effect.ignore); + } + const tabId = yield* tabIdForWebContents(wc.id); + const metadata = + typeof params["metadata"] === "object" && params["metadata"] !== null + ? (params["metadata"] as Record<string, unknown>) + : {}; + if (tabId && typeof params["data"] === "string") { + const receivedAt = yield* currentIso; + const listeners = yield* Ref.get(recordingFrameListenersRef); + const frame: DesktopPreviewRecordingFrame = { + tabId, + data: params["data"], + width: typeof metadata["deviceWidth"] === "number" ? metadata["deviceWidth"] : 0, + height: typeof metadata["deviceHeight"] === "number" ? metadata["deviceHeight"] : 0, + receivedAt, + }; + yield* Effect.forEach( + listeners, + (listener) => Effect.sync(() => listener(frame)).pipe(Effect.ignore), + { discard: true }, + ); + } + } + yield* captureDiagnosticMessage(wc.id, method, params); + }); const onMessage: BrowserControlSession["onMessage"] = (_event, method, params) => { - runFork( - Effect.gen(function* () { - if (method === "Page.screencastFrame") { - const sessionId = params["sessionId"]; - if (typeof sessionId === "number") { - yield* attemptPromise("ackScreencastFrame", () => - wc.debugger.sendCommand("Page.screencastFrameAck", { sessionId }), - ).pipe(Effect.ignore); - } - const tabId = yield* tabIdForWebContents(wc.id); - const metadata = - typeof params["metadata"] === "object" && params["metadata"] !== null - ? (params["metadata"] as Record<string, unknown>) - : {}; - if (tabId && typeof params["data"] === "string") { - const receivedAt = yield* currentIso; - const listeners = yield* Ref.get(recordingFrameListenersRef); - const frame: DesktopPreviewRecordingFrame = { - tabId, - data: params["data"], - width: - typeof metadata["deviceWidth"] === "number" ? metadata["deviceWidth"] : 0, - height: - typeof metadata["deviceHeight"] === "number" ? metadata["deviceHeight"] : 0, - receivedAt, - }; - yield* Effect.forEach( - listeners, - (listener) => Effect.sync(() => listener(frame)).pipe(Effect.ignore), - { discard: true }, - ); - } - } - yield* captureDiagnosticMessage(wc.id, method, params); - }), - ); + runFork(handleDebuggerMessage(method, params)); }; - const control: BrowserControlSession = { webContentsId: wc.id, semaphore, onMessage }; - yield* Ref.update(diagnosticsRef, (diagnostics) => - replaceMap(diagnostics, (copy) => { - copy.set(wc.id, { - consoleEntries: [], - networkEntries: [], - requests: new Map(), - }); - }), - ); - yield* attempt("attachDebuggerListeners", () => { - wc.debugger.on("message", onMessage); - wc.debugger.attach("1.3"); - }); - yield* Effect.all( - ["Runtime.enable", "Accessibility.enable", "Network.enable", "Log.enable"].map((method) => - attemptPromise("initializeDebugger", () => wc.debugger.sendCommand(method)), - ), - { concurrency: "unbounded", discard: true }, - ).pipe( - Effect.tapError(() => - Effect.all([ + yield* Scope.addFinalizer( + scope, + Effect.all( + [ Ref.update(diagnosticsRef, (diagnostics) => replaceMap(diagnostics, (copy) => { copy.delete(wc.id); }), ), - attempt("detachFailedDebugger", () => { + attempt("detachControlSession", () => { wc.debugger.off("message", onMessage); if (wc.debugger.isAttached()) wc.debugger.detach(); }).pipe(Effect.ignore), - ]).pipe(Effect.asVoid), + ], + { discard: true }, ), ); - return [ - control, - replaceMap(sessions, (copy) => { - copy.set(wc.id, control); - }), - ] as const; + const control: BrowserControlSession = { + webContentsId: wc.id, + semaphore, + scope, + onMessage, + }; + const initialize = Effect.fn("PreviewManager.initializeControlSession")(function* () { + yield* Ref.update(diagnosticsRef, (diagnostics) => + replaceMap(diagnostics, (copy) => { + copy.set(wc.id, { + consoleEntries: [], + networkEntries: [], + requests: new Map(), + }); + }), + ); + yield* attempt("attachDebuggerListeners", () => { + wc.debugger.on("message", onMessage); + wc.debugger.attach("1.3"); + }); + yield* Effect.all( + ["Runtime.enable", "Accessibility.enable", "Network.enable", "Log.enable"].map( + (method) => + attemptPromise("initializeDebugger", () => wc.debugger.sendCommand(method)), + ), + { concurrency: "unbounded", discard: true }, + ); + return [ + control, + replaceMap(sessions, (copy) => { + copy.set(wc.id, control); + }), + ] as const; + }); + return yield* initialize().pipe( + Effect.onError(() => Scope.close(scope, Exit.void).pipe(Effect.ignore)), + ); }); + return createControlSession(); }); }); @@ -906,25 +920,13 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const detachListeners = Effect.fn("PreviewManager.detachListeners")(function* ( webContentsId: number, ) { - const handlers = yield* Ref.modify(attachedRef, (attached) => [ + const managed = yield* Ref.modify(attachedRef, (attached) => [ attached.get(webContentsId), replaceMap(attached, (copy) => { copy.delete(webContentsId); }), ]); - if (!handlers) return; - const wc = webContents.fromId(webContentsId); - if (!wc || wc.isDestroyed()) return; - yield* attempt("detachListeners", () => { - wc.off("did-navigate", handlers.navigate); - wc.off("did-navigate-in-page", handlers.navigate); - wc.off("page-title-updated", handlers.navigate); - wc.off("did-start-loading", handlers.navigate); - wc.off("did-stop-loading", handlers.navigate); - wc.off("did-fail-load", handlers.failed as never); - wc.off("before-input-event", handlers.beforeInput); - wc.ipc.off(HUMAN_INPUT_CHANNEL, handlers.humanInput); - }).pipe(Effect.ignore); + if (managed) yield* Scope.close(managed.scope, Exit.void).pipe(Effect.ignore); }); const isAppShortcut = (input: Electron.Input): boolean => @@ -987,17 +989,16 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function tabId: string, wc: Electron.WebContents, ) { - const sync = () => - runFork( - Effect.gen(function* () { - if (wc.isDestroyed()) return; - yield* update(tabId, { - navStatus: computeNavStatus(wc), - canGoBack: wc.navigationHistory.canGoBack(), - canGoForward: wc.navigationHistory.canGoForward(), - }); - }), - ); + const scope = yield* Scope.fork(parentScope, "sequential"); + const syncState = Effect.fn("PreviewManager.syncWebContentsState")(function* () { + if (wc.isDestroyed()) return; + yield* update(tabId, { + navStatus: computeNavStatus(wc), + canGoBack: wc.navigationHistory.canGoBack(), + canGoForward: wc.navigationHistory.canGoForward(), + }); + }); + const sync = () => runFork(syncState()); const failed = (_event: Event, code: number, description: string): void => { if (code === -3) return; runFork( @@ -1012,73 +1013,85 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }), ); }; - const humanInput = (_event: unknown, rawSignal?: unknown): void => { - runFork( - Effect.gen(function* () { - if ( - isPreviewInputSignal(rawSignal) && - (yield* consumeExpectedAgentInput(tabId, rawSignal)) - ) { - return; - } - yield* Ref.update(controlEpochRef, (epochs) => - replaceMap(epochs, (copy) => { - copy.set(tabId, (epochs.get(tabId) ?? 0) + 1); - }), - ); - yield* update(tabId, { controller: "human" }); - yield* Effect.sleep(750); - const tabs = yield* SynchronizedRef.get(tabsRef); - if (tabs.get(tabId)?.controller === "human") { - yield* update(tabId, { controller: "none" }); - } + const handleHumanInput = Effect.fn("PreviewManager.handleHumanInput")(function* ( + rawSignal?: unknown, + ) { + if (isPreviewInputSignal(rawSignal) && (yield* consumeExpectedAgentInput(tabId, rawSignal))) { + return; + } + yield* Ref.update(controlEpochRef, (epochs) => + replaceMap(epochs, (copy) => { + copy.set(tabId, (epochs.get(tabId) ?? 0) + 1); }), ); + yield* update(tabId, { controller: "human" }); + yield* Effect.sleep(750); + const tabs = yield* SynchronizedRef.get(tabsRef); + if (tabs.get(tabId)?.controller === "human") { + yield* update(tabId, { controller: "none" }); + } + }); + const humanInput = (_event: unknown, rawSignal?: unknown): void => { + runFork(handleHumanInput(rawSignal)); }; + const forwardShortcut = Effect.fn("PreviewManager.forwardShortcut")(function* ( + event: Electron.Event, + input: Electron.Input, + ) { + const mainWindow = yield* Ref.get(mainWindowRef); + if (!isAppShortcut(input) || Option.isNone(mainWindow) || mainWindow.value.isDestroyed()) { + return; + } + event.preventDefault(); + mainWindow.value.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) : []), + ], + }); + }); const beforeInput = (event: Electron.Event, input: Electron.Input): void => { - runFork( - Effect.gen(function* () { - const mainWindow = yield* Ref.get(mainWindowRef); - if ( - !isAppShortcut(input) || - Option.isNone(mainWindow) || - mainWindow.value.isDestroyed() - ) { - return; - } - event.preventDefault(); - mainWindow.value.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) : []), - ], - }); - }), - ); + runFork(forwardShortcut(event, input)); }; - yield* attempt("attachListeners", () => { - 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", failed as never); - wc.ipc.on(HUMAN_INPUT_CHANNEL, humanInput); - wc.setWindowOpenHandler(({ url }) => { - runFork(attemptPromise("openPreviewWindow", () => wc.loadURL(url)).pipe(Effect.ignore)); - return { action: "deny" }; + yield* Scope.addFinalizer( + scope, + attempt("detachListeners", () => { + wc.off("did-navigate", sync); + wc.off("did-navigate-in-page", sync); + wc.off("page-title-updated", sync); + wc.off("did-start-loading", sync); + wc.off("did-stop-loading", sync); + wc.off("did-fail-load", failed as never); + wc.off("before-input-event", beforeInput); + wc.ipc.off(HUMAN_INPUT_CHANNEL, humanInput); + }).pipe(Effect.ignore), + ); + const install = Effect.fn("PreviewManager.installWebContentsListeners")(function* () { + yield* attempt("attachListeners", () => { + 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", failed as never); + wc.ipc.on(HUMAN_INPUT_CHANNEL, humanInput); + wc.setWindowOpenHandler(({ url }) => { + runFork(attemptPromise("openPreviewWindow", () => wc.loadURL(url)).pipe(Effect.ignore)); + return { action: "deny" }; + }); + wc.on("before-input-event", beforeInput); }); - wc.on("before-input-event", beforeInput); + yield* Ref.update(attachedRef, (attached) => + replaceMap(attached, (copy) => { + copy.set(wc.id, { scope }); + }), + ); }); - yield* Ref.update(attachedRef, (attached) => - replaceMap(attached, (copy) => { - copy.set(wc.id, { navigate: sync, failed, humanInput, beforeInput }); - }), - ); + yield* install().pipe(Effect.onError(() => Scope.close(scope, Exit.void).pipe(Effect.ignore))); }); const setMainWindow = Effect.fn("PreviewManager.setMainWindow")(function* ( @@ -1271,7 +1284,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const annotationTheme = yield* Ref.get(annotationThemeRef); return yield* Effect.callback<PreviewAnnotationPayload | null, PreviewManagerError>( (resume) => { - const cleanup = Effect.gen(function* () { + const cleanup = Effect.fn("PreviewManager.cleanupPickElement")(function* () { yield* attempt("pickElement.cleanup", () => { wc.ipc.removeListener(ELEMENT_PICKED_CHANNEL, onMessage); wc.off("destroyed", onDestroyed); @@ -1283,18 +1296,19 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }), ); }); + const settlePick = Effect.fn("PreviewManager.settlePickElement")(function* ( + payload: PreviewAnnotationPayload | null, + ) { + const active = (yield* Ref.get(pickSessionsRef)).get(tabId); + if (!active || active.cancel !== cancel) return; + yield* cleanup(); + resume(Effect.succeed(payload)); + }); const settle = (payload: PreviewAnnotationPayload | null) => { - runFork( - Effect.gen(function* () { - const active = (yield* Ref.get(pickSessionsRef)).get(tabId); - if (!active || active.cancel !== cancel) return; - yield* cleanup; - resume(Effect.succeed(payload)); - }), - ); + runFork(settlePick(payload)); }; - const cancel = Effect.gen(function* () { - yield* cleanup; + const cancelPickSession = Effect.fn("PreviewManager.cancelPickSession")(function* () { + yield* cleanup(); const tabs = yield* SynchronizedRef.get(tabsRef); const activeTab = tabs.get(tabId); if (activeTab?.webContentsId != null) { @@ -1307,6 +1321,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function } resume(Effect.succeed(null)); }); + const cancel = cancelPickSession(); const onMessage = (_event: Electron.IpcMainEvent, ...args: unknown[]): void => { const payload = args[0]; if (!isPreviewAnnotationPayload(payload)) { @@ -1330,24 +1345,25 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }; const onDestroyed = () => settle(null); const onNavigated = () => settle(null); + const registerPickElement = Effect.fn("PreviewManager.registerPickElement")(function* () { + yield* attempt("pickElement.register", () => { + wc.ipc.on(ELEMENT_PICKED_CHANNEL, onMessage); + wc.once("destroyed", onDestroyed); + wc.once("did-start-navigation", onNavigated); + if (!wc.isFocused()) wc.focus(); + wc.send(START_PICK_CHANNEL, annotationTheme); + }); + yield* Ref.update(pickSessionsRef, (sessions) => + replaceMap(sessions, (copy) => { + copy.set(tabId, { cancel }); + }), + ); + }); runFork( - Effect.gen(function* () { - yield* attempt("pickElement.register", () => { - wc.ipc.on(ELEMENT_PICKED_CHANNEL, onMessage); - wc.once("destroyed", onDestroyed); - wc.once("did-start-navigation", onNavigated); - if (!wc.isFocused()) wc.focus(); - wc.send(START_PICK_CHANNEL, annotationTheme); - }); - yield* Ref.update(pickSessionsRef, (sessions) => - replaceMap(sessions, (copy) => { - copy.set(tabId, { cancel }); - }), - ); - }).pipe( + registerPickElement().pipe( Effect.catch((error: PreviewManagerError) => { resume(Effect.fail(error)); - return cleanup; + return cleanup(); }), ), ); @@ -1401,6 +1417,19 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }; }); + const startScreencast = Effect.fn("PreviewManager.startScreencast")(function* ( + send: SendCommand, + ) { + yield* send("Page.enable"); + yield* send("Page.startScreencast", { + format: "jpeg", + quality: 80, + maxWidth: 1600, + maxHeight: 1200, + everyNthFrame: 1, + }); + }); + const startRecording = Effect.fn("PreviewManager.startRecording")(function* (tabId: string) { const recordingTabId = yield* Ref.get(recordingTabIdRef); if (Option.isSome(recordingTabId) && recordingTabId.value !== tabId) { @@ -1410,18 +1439,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); } const wc = yield* requireWebContents(tabId); - yield* withControlSession(tabId, wc, "recording.start", (send) => - Effect.gen(function* () { - yield* send("Page.enable"); - yield* send("Page.startScreencast", { - format: "jpeg", - quality: 80, - maxWidth: 1600, - maxHeight: 1200, - everyNthFrame: 1, - }); - }), - ); + yield* withControlSession(tabId, wc, "recording.start", startScreencast); yield* Ref.set(recordingTabIdRef, Option.some(tabId)); }); @@ -1493,104 +1511,108 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }; }); + const captureAutomationSnapshot = Effect.fn("PreviewManager.captureAutomationSnapshot")( + function* (tabId: string, wc: Electron.WebContents, send: SendCommand) { + yield* Effect.all([send("Runtime.enable"), send("Accessibility.enable")], { + concurrency: 2, + discard: true, + }); + const page = yield* evaluateWithDebugger<{ + url: string; + title: string; + loading: boolean; + visibleText: string; + interactiveElements: PreviewAutomationSnapshot["interactiveElements"]; + }>( + send, + `(() => { + const selectorFor = (element) => { + if (element.id) return "#" + CSS.escape(element.id); + for (const attribute of ["data-testid", "name"]) { + const value = element.getAttribute(attribute); + if (value) return element.tagName.toLowerCase() + "[" + attribute + "=" + JSON.stringify(value) + "]"; + } + const buildParts = (current, parts = []) => { + if (!current || current.nodeType !== Node.ELEMENT_NODE || parts.length >= 8) { + return parts; + } + const parent = current.parentElement; + const siblings = parent + ? Array.from(parent.children).filter((child) => child.tagName === current.tagName) + : []; + const base = current.tagName.toLowerCase(); + const part = siblings.length > 1 + ? base + ":nth-of-type(" + (siblings.indexOf(current) + 1) + ")" + : base; + return buildParts(parent, [part, ...parts]); + }; + return buildParts(element).join(" > "); + }; + const visible = (element) => { + const style = getComputedStyle(element); + const rect = element.getBoundingClientRect(); + return style.visibility !== "hidden" && style.display !== "none" && rect.width > 0 && rect.height > 0; + }; + const elements = Array.from(document.querySelectorAll( + "a[href],button,input,textarea,select,[role],[tabindex]" + )).filter(visible).slice(0, ${MAX_INTERACTIVE_ELEMENTS}).map((element) => { + const rect = element.getBoundingClientRect(); + return { + tag: element.tagName.toLowerCase(), + role: element.getAttribute("role"), + name: element.getAttribute("aria-label") || element.innerText || element.getAttribute("name") || "", + selector: selectorFor(element), + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height + }; + }); + return { + url: location.href, + title: document.title, + loading: document.readyState !== "complete", + visibleText: (document.body?.innerText || "").slice(0, ${MAX_VISIBLE_TEXT_LENGTH}), + interactiveElements: elements + }; + })()`, + true, + ); + const [accessibility, sourceImage, diagnostics, timelines] = yield* Effect.all([ + send("Accessibility.getFullAXTree"), + attemptPromise("automationSnapshot.capturePage", () => wc.capturePage()), + Ref.get(diagnosticsRef), + Ref.get(actionTimelineRef), + ]); + const sourceSize = sourceImage.getSize(); + const image = + sourceSize.width > MAX_SCREENSHOT_WIDTH + ? sourceImage.resize({ width: MAX_SCREENSHOT_WIDTH }) + : sourceImage; + const size = image.getSize(); + const browserDiagnostics = diagnostics.get(wc.id); + return { + ...page, + accessibilityTree: accessibility, + consoleEntries: [...(browserDiagnostics?.consoleEntries ?? [])], + networkEntries: [...(browserDiagnostics?.networkEntries ?? [])], + actionTimeline: [...(timelines.get(tabId) ?? [])], + screenshot: { + mimeType: "image/png" as const, + data: image.toPNG().toString("base64"), + width: size.width, + height: size.height, + }, + }; + }, + ); + const automationSnapshot = Effect.fn("PreviewManager.automationSnapshot")(function* ( tabId: string, ) { const wc = yield* requireWebContents(tabId); return yield* withControlSession(tabId, wc, "snapshot", (send) => - Effect.gen(function* () { - yield* Effect.all([send("Runtime.enable"), send("Accessibility.enable")], { - concurrency: 2, - discard: true, - }); - const page = yield* evaluateWithDebugger<{ - url: string; - title: string; - loading: boolean; - visibleText: string; - interactiveElements: PreviewAutomationSnapshot["interactiveElements"]; - }>( - send, - `(() => { - const selectorFor = (element) => { - if (element.id) return "#" + CSS.escape(element.id); - for (const attribute of ["data-testid", "name"]) { - const value = element.getAttribute(attribute); - if (value) return element.tagName.toLowerCase() + "[" + attribute + "=" + JSON.stringify(value) + "]"; - } - const buildParts = (current, parts = []) => { - if (!current || current.nodeType !== Node.ELEMENT_NODE || parts.length >= 8) { - return parts; - } - const parent = current.parentElement; - const siblings = parent - ? Array.from(parent.children).filter((child) => child.tagName === current.tagName) - : []; - const base = current.tagName.toLowerCase(); - const part = siblings.length > 1 - ? base + ":nth-of-type(" + (siblings.indexOf(current) + 1) + ")" - : base; - return buildParts(parent, [part, ...parts]); - }; - return buildParts(element).join(" > "); - }; - const visible = (element) => { - const style = getComputedStyle(element); - const rect = element.getBoundingClientRect(); - return style.visibility !== "hidden" && style.display !== "none" && rect.width > 0 && rect.height > 0; - }; - const elements = Array.from(document.querySelectorAll( - "a[href],button,input,textarea,select,[role],[tabindex]" - )).filter(visible).slice(0, ${MAX_INTERACTIVE_ELEMENTS}).map((element) => { - const rect = element.getBoundingClientRect(); - return { - tag: element.tagName.toLowerCase(), - role: element.getAttribute("role"), - name: element.getAttribute("aria-label") || element.innerText || element.getAttribute("name") || "", - selector: selectorFor(element), - x: rect.x, - y: rect.y, - width: rect.width, - height: rect.height - }; - }); - return { - url: location.href, - title: document.title, - loading: document.readyState !== "complete", - visibleText: (document.body?.innerText || "").slice(0, ${MAX_VISIBLE_TEXT_LENGTH}), - interactiveElements: elements - }; - })()`, - true, - ); - const [accessibility, sourceImage, diagnostics, timelines] = yield* Effect.all([ - send("Accessibility.getFullAXTree"), - attemptPromise("automationSnapshot.capturePage", () => wc.capturePage()), - Ref.get(diagnosticsRef), - Ref.get(actionTimelineRef), - ]); - const sourceSize = sourceImage.getSize(); - const image = - sourceSize.width > MAX_SCREENSHOT_WIDTH - ? sourceImage.resize({ width: MAX_SCREENSHOT_WIDTH }) - : sourceImage; - const size = image.getSize(); - const browserDiagnostics = diagnostics.get(wc.id); - return { - ...page, - accessibilityTree: accessibility, - consoleEntries: [...(browserDiagnostics?.consoleEntries ?? [])], - networkEntries: [...(browserDiagnostics?.networkEntries ?? [])], - actionTimeline: [...(timelines.get(tabId) ?? [])], - screenshot: { - mimeType: "image/png" as const, - data: image.toPNG().toString("base64"), - width: size.width, - height: size.height, - }, - }; - }), + captureAutomationSnapshot(tabId, wc, send), ); }); @@ -1657,66 +1679,72 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); }); + const performAutomationClick = Effect.fn("PreviewManager.performAutomationClick")(function* ( + tabId: string, + input: PreviewAutomationClickInput, + send: SendCommand, + ) { + yield* Effect.all( + [send("Runtime.enable"), send("Input.setIgnoreInputEvents", { ignore: false })], + { concurrency: 2, discard: true }, + ); + const point = yield* resolveClickPoint(send, input); + const viewport = yield* evaluateWithDebugger<{ width: number; height: number }>( + send, + "({ width: window.innerWidth, height: window.innerHeight })", + true, + ); + if (point.x < 0 || point.y < 0 || point.x > viewport.width || point.y > viewport.height) { + return yield* fail( + "automationClick", + automationError( + "PreviewAutomationExecutionError", + `Click coordinates (${point.x}, ${point.y}) are outside the preview viewport.`, + ), + ); + } + const moveSequence = yield* nextCounter(pointerSequenceRef); + const moveCreatedAt = yield* currentIso; + yield* emitPointerEvent({ + tabId, + phase: "move", + ...point, + sequence: moveSequence, + createdAt: moveCreatedAt, + }); + yield* Effect.sleep(AGENT_CURSOR_MOVE_MS); + const clickSequence = yield* nextCounter(pointerSequenceRef); + const clickCreatedAt = yield* currentIso; + yield* emitPointerEvent({ + tabId, + phase: "click", + ...point, + sequence: clickSequence, + createdAt: clickCreatedAt, + }); + yield* Effect.sleep(AGENT_CURSOR_CLICK_LEAD_MS); + yield* expectAgentInput(tabId, { kind: "pointer", ...point, button: 0 }); + yield* send("Input.dispatchMouseEvent", { + type: "mousePressed", + ...point, + button: "left", + clickCount: 1, + }); + yield* send("Input.dispatchMouseEvent", { + type: "mouseReleased", + ...point, + button: "left", + clickCount: 1, + }); + }); + const automationClick = Effect.fn("PreviewManager.automationClick")(function* ( tabId: string, input: PreviewAutomationClickInput, ) { const wc = yield* requireWebContents(tabId); yield* withControlSession(tabId, wc, "click", (send) => - Effect.gen(function* () { - yield* Effect.all( - [send("Runtime.enable"), send("Input.setIgnoreInputEvents", { ignore: false })], - { concurrency: 2, discard: true }, - ); - const point = yield* resolveClickPoint(send, input); - const viewport = yield* evaluateWithDebugger<{ width: number; height: number }>( - send, - "({ width: window.innerWidth, height: window.innerHeight })", - true, - ); - if (point.x < 0 || point.y < 0 || point.x > viewport.width || point.y > viewport.height) { - return yield* fail( - "automationClick", - automationError( - "PreviewAutomationExecutionError", - `Click coordinates (${point.x}, ${point.y}) are outside the preview viewport.`, - ), - ); - } - const moveSequence = yield* nextCounter(pointerSequenceRef); - const moveCreatedAt = yield* currentIso; - yield* emitPointerEvent({ - tabId, - phase: "move", - ...point, - sequence: moveSequence, - createdAt: moveCreatedAt, - }); - yield* Effect.sleep(AGENT_CURSOR_MOVE_MS); - const clickSequence = yield* nextCounter(pointerSequenceRef); - const clickCreatedAt = yield* currentIso; - yield* emitPointerEvent({ - tabId, - phase: "click", - ...point, - sequence: clickSequence, - createdAt: clickCreatedAt, - }); - yield* Effect.sleep(AGENT_CURSOR_CLICK_LEAD_MS); - yield* expectAgentInput(tabId, { kind: "pointer", ...point, button: 0 }); - yield* send("Input.dispatchMouseEvent", { - type: "mousePressed", - ...point, - button: "left", - clickCount: 1, - }); - yield* send("Input.dispatchMouseEvent", { - type: "mouseReleased", - ...point, - button: "left", - clickCount: 1, - }); - }), + performAutomationClick(tabId, input, send), ); }); @@ -1769,171 +1797,188 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function } }); + const performAutomationType = Effect.fn("PreviewManager.performAutomationType")(function* ( + tabId: string, + input: PreviewAutomationTypeInput, + send: SendCommand, + ) { + yield* send("Runtime.enable"); + yield* focusAutomationTarget(send, input); + yield* send("Input.insertText", { text: input.text }); + const textJson = yield* encodeJson("automationType.encodeText", input.text); + yield* evaluateWithDebugger( + send, + `(() => { + const element = document.activeElement; + element?.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: ${textJson} })); + element?.dispatchEvent(new Event("change", { bubbles: true })); + })()`, + false, + ); + }); + const automationType = Effect.fn("PreviewManager.automationType")(function* ( tabId: string, input: PreviewAutomationTypeInput, ) { const wc = yield* requireWebContents(tabId); yield* withControlSession(tabId, wc, "type", (send) => - Effect.gen(function* () { - yield* send("Runtime.enable"); - yield* focusAutomationTarget(send, input); - yield* send("Input.insertText", { text: input.text }); - const textJson = yield* encodeJson("automationType.encodeText", input.text); - yield* evaluateWithDebugger( - send, - `(() => { - const element = document.activeElement; - element?.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: ${textJson} })); - element?.dispatchEvent(new Event("change", { bubbles: true })); - })()`, - false, - ); - }), + performAutomationType(tabId, input, send), ); }); + const performAutomationPress = Effect.fn("PreviewManager.performAutomationPress")(function* ( + tabId: string, + input: PreviewAutomationPressInput, + send: SendCommand, + ) { + const modifiers = (input.modifiers ?? []).reduce((value, modifier) => { + switch (modifier) { + case "Alt": + return value | 1; + case "Control": + return value | 2; + case "Meta": + return value | 4; + case "Shift": + return value | 8; + } + }, 0); + const key = input.key; + const text = key.length === 1 ? key : undefined; + const params = { + key, + code: key.length === 1 ? `Key${key.toUpperCase()}` : key, + modifiers, + ...(text ? { text, unmodifiedText: text } : {}), + }; + yield* expectAgentInput(tabId, { kind: "key", key, code: params.code }); + yield* send("Input.dispatchKeyEvent", { type: "keyDown", ...params }); + yield* send("Input.dispatchKeyEvent", { type: "keyUp", ...params }); + }); + const automationPress = Effect.fn("PreviewManager.automationPress")(function* ( tabId: string, input: PreviewAutomationPressInput, ) { const wc = yield* requireWebContents(tabId); yield* withControlSession(tabId, wc, "press", (send) => - Effect.gen(function* () { - const modifiers = (input.modifiers ?? []).reduce((value, modifier) => { - switch (modifier) { - case "Alt": - return value | 1; - case "Control": - return value | 2; - case "Meta": - return value | 4; - case "Shift": - return value | 8; - } - }, 0); - const key = input.key; - const text = key.length === 1 ? key : undefined; - const params = { - key, - code: key.length === 1 ? `Key${key.toUpperCase()}` : key, - modifiers, - ...(text ? { text, unmodifiedText: text } : {}), - }; - yield* expectAgentInput(tabId, { kind: "key", key, code: params.code }); - yield* send("Input.dispatchKeyEvent", { type: "keyDown", ...params }); - yield* send("Input.dispatchKeyEvent", { type: "keyUp", ...params }); - }), + performAutomationPress(tabId, input, send), ); }); + const performAutomationScroll = Effect.fn("PreviewManager.performAutomationScroll")(function* ( + tabId: string, + input: PreviewAutomationScrollInput, + send: SendCommand, + ) { + yield* send("Runtime.enable"); + const locator = automationLocator(input); + if (locator) yield* ensurePlaywrightInjected(send); + const locatorJson = locator + ? yield* encodeJson("automationScroll.encodeLocator", locator) + : null; + const result = yield* evaluateWithDebugger< + { ok: true } | { invalidSelector: true; message: string } | { notFound: true } + >( + send, + `(() => { + try { + const target = ${locatorJson ? `(() => { const injected = globalThis.__t3PlaywrightInjected; return injected.querySelector(injected.parseSelector(${locatorJson}), document, true); })()` : "window"}; + if (!target) return { notFound: true }; + target.scrollBy({ left: ${input.deltaX ?? 0}, top: ${input.deltaY ?? 0}, behavior: "instant" }); + return { ok: true }; + } catch (error) { + return { invalidSelector: true, message: String(error) }; + } + })()`, + true, + ); + if ("invalidSelector" in result) { + return yield* fail( + "automationScroll", + automationError("PreviewAutomationInvalidSelectorError", result.message, { + selector: input.selector ?? "", + }), + ); + } + if ("notFound" in result) { + return yield* fail( + "automationScroll", + automationError( + "PreviewAutomationExecutionError", + `No element matches locator ${locator}.`, + ), + ); + } + }); + const automationScroll = Effect.fn("PreviewManager.automationScroll")(function* ( tabId: string, input: PreviewAutomationScrollInput, ) { const wc = yield* requireWebContents(tabId); yield* withControlSession(tabId, wc, "scroll", (send) => - Effect.gen(function* () { - yield* send("Runtime.enable"); - const locator = automationLocator(input); - if (locator) yield* ensurePlaywrightInjected(send); - const locatorJson = locator - ? yield* encodeJson("automationScroll.encodeLocator", locator) - : null; - const result = yield* evaluateWithDebugger< - { ok: true } | { invalidSelector: true; message: string } | { notFound: true } - >( - send, - `(() => { - try { - const target = ${locatorJson ? `(() => { const injected = globalThis.__t3PlaywrightInjected; return injected.querySelector(injected.parseSelector(${locatorJson}), document, true); })()` : "window"}; - if (!target) return { notFound: true }; - target.scrollBy({ left: ${input.deltaX ?? 0}, top: ${input.deltaY ?? 0}, behavior: "instant" }); - return { ok: true }; - } catch (error) { - return { invalidSelector: true, message: String(error) }; - } - })()`, - true, - ); - if ("invalidSelector" in result) { - return yield* fail( - "automationScroll", - automationError("PreviewAutomationInvalidSelectorError", result.message, { - selector: input.selector ?? "", - }), - ); - } - if ("notFound" in result) { - return yield* fail( - "automationScroll", - automationError( - "PreviewAutomationExecutionError", - `No element matches locator ${locator}.`, - ), - ); - } - }), + performAutomationScroll(tabId, input, send), ); }); + const performAutomationEvaluate = Effect.fn("PreviewManager.performAutomationEvaluate")( + function* (input: PreviewAutomationEvaluateInput, send: SendCommand) { + yield* send("Runtime.enable"); + const value = yield* evaluateWithDebugger( + send, + input.expression, + input.returnByValue ?? true, + input.awaitPromise ?? true, + ); + const serialized = yield* encodeJson("automationEvaluate.encodeResult", value); + if (Buffer.byteLength(serialized, "utf8") > MAX_EVALUATION_BYTES) { + return yield* fail( + "automationEvaluate", + automationError( + "PreviewAutomationResultTooLargeError", + `Evaluation result exceeds ${MAX_EVALUATION_BYTES} bytes.`, + { maximumBytes: MAX_EVALUATION_BYTES }, + ), + ); + } + return value; + }, + ); + const automationEvaluate = Effect.fn("PreviewManager.automationEvaluate")(function* ( tabId: string, input: PreviewAutomationEvaluateInput, ) { const wc = yield* requireWebContents(tabId); return yield* withControlSession(tabId, wc, "evaluate", (send) => - Effect.gen(function* () { - yield* send("Runtime.enable"); - const value = yield* evaluateWithDebugger( - send, - input.expression, - input.returnByValue ?? true, - input.awaitPromise ?? true, - ); - const serialized = yield* encodeJson("automationEvaluate.encodeResult", value); - if (Buffer.byteLength(serialized, "utf8") > MAX_EVALUATION_BYTES) { - return yield* fail( - "automationEvaluate", - automationError( - "PreviewAutomationResultTooLargeError", - `Evaluation result exceeds ${MAX_EVALUATION_BYTES} bytes.`, - { maximumBytes: MAX_EVALUATION_BYTES }, - ), - ); - } - return value; - }), + performAutomationEvaluate(input, send), ); }); - const automationWaitFor = Effect.fn("PreviewManager.automationWaitFor")(function* ( - tabId: string, + const performAutomationWaitFor = Effect.fn("PreviewManager.performAutomationWaitFor")(function* ( input: PreviewAutomationWaitForInput, + send: SendCommand, ) { - const wc = yield* requireWebContents(tabId); const timeoutMs = input.timeoutMs ?? 15_000; - yield* withControlSession(tabId, wc, "waitFor", (send) => - Effect.gen(function* () { - yield* send("Runtime.enable"); - const locator = automationLocator(input); - if (locator) yield* ensurePlaywrightInjected(send); - const [locatorJson, textJson, urlIncludesJson] = yield* Effect.all([ - locator ? encodeJson("automationWaitFor.encodeLocator", locator) : Effect.succeed(null), - input.text - ? encodeJson("automationWaitFor.encodeText", input.text) - : Effect.succeed(null), - input.urlIncludes - ? encodeJson("automationWaitFor.encodeUrl", input.urlIncludes) - : Effect.succeed(null), - ]); - const deadline = (yield* currentMillis) + timeoutMs; - while ((yield* currentMillis) <= deadline) { - const result = yield* evaluateWithDebugger< - { matched: boolean } | { invalidSelector: true; message: string } - >( - send, - `(() => { + yield* send("Runtime.enable"); + const locator = automationLocator(input); + if (locator) yield* ensurePlaywrightInjected(send); + const [locatorJson, textJson, urlIncludesJson] = yield* Effect.all([ + locator ? encodeJson("automationWaitFor.encodeLocator", locator) : Effect.succeed(null), + input.text ? encodeJson("automationWaitFor.encodeText", input.text) : Effect.succeed(null), + input.urlIncludes + ? encodeJson("automationWaitFor.encodeUrl", input.urlIncludes) + : Effect.succeed(null), + ]); + const deadline = (yield* currentMillis) + timeoutMs; + while ((yield* currentMillis) <= deadline) { + const result = yield* evaluateWithDebugger< + { matched: boolean } | { invalidSelector: true; message: string } + >( + send, + `(() => { try { const selectorMatched = ${locatorJson ? `(() => { const injected = globalThis.__t3PlaywrightInjected; return injected.querySelector(injected.parseSelector(${locatorJson}), document, false) !== null; })()` : "true"}; const textMatched = ${ @@ -1947,27 +1992,35 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function return { invalidSelector: true, message: String(error) }; } })()`, - true, - ); - if ("invalidSelector" in result) { - return yield* fail( - "automationWaitFor", - automationError("PreviewAutomationInvalidSelectorError", result.message, { - selector: input.selector ?? "", - }), - ); - } - if (result.matched) return; - yield* Effect.sleep(100); - } + true, + ); + if ("invalidSelector" in result) { return yield* fail( "automationWaitFor", - automationError( - "PreviewAutomationTimeoutError", - `Preview condition did not match within ${timeoutMs}ms.`, - ), + automationError("PreviewAutomationInvalidSelectorError", result.message, { + selector: input.selector ?? "", + }), ); - }), + } + if (result.matched) return; + yield* Effect.sleep(100); + } + return yield* fail( + "automationWaitFor", + automationError( + "PreviewAutomationTimeoutError", + `Preview condition did not match within ${timeoutMs}ms.`, + ), + ); + }); + + const automationWaitFor = Effect.fn("PreviewManager.automationWaitFor")(function* ( + tabId: string, + input: PreviewAutomationWaitForInput, + ) { + const wc = yield* requireWebContents(tabId); + yield* withControlSession(tabId, wc, "waitFor", (send) => + performAutomationWaitFor(input, send), ); }); diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index ebf8bf00c1c..f60652609f5 100644 --- a/apps/server/src/mcp/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -158,7 +158,7 @@ it.effect("registers annotated tools and preserves authenticated request context Effect.provideService(McpSchema.McpServerClient, client), ); expect(press.isError).toBe(false); - expect(press.structuredContent).toBeUndefined(); + expect(press.structuredContent).toBeNull(); expect(press.content).toEqual([{ type: "text", text: "null" }]); }), ).pipe(Effect.provide(TestLayer)), diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 3ae91bf8baf..6cde2017a9e 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -13,8 +13,15 @@ import packageJson from "../../package.json" with { type: "json" }; import * as McpInvocationContext from "./McpInvocationContext.ts"; import * as McpSessionRegistry from "./McpSessionRegistry.ts"; import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts"; -import { PreviewToolkitHandlersLive } from "./toolkits/preview/handlers.ts"; -import { PreviewToolkit } from "./toolkits/preview/tools.ts"; +import { + PreviewSnapshotToolkitHandlersLive, + PreviewStandardToolkitHandlersLive, +} from "./toolkits/preview/handlers.ts"; +import { + PreviewSnapshotTool, + PreviewSnapshotToolkit, + PreviewStandardToolkit, +} from "./toolkits/preview/tools.ts"; const unauthorized = HttpServerResponse.jsonUnsafe( { @@ -81,97 +88,95 @@ const McpAuthMiddlewareLive = HttpRouter.middleware<{ provides: McpInvocationContext.McpInvocationContext; }>()(makeMcpAuthMiddleware).layer; -const registerPreviewToolkit = Effect.fn("McpHttpServer.registerPreviewToolkit")(function* () { +const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot")(function* () { const server = yield* McpServer.McpServer; - const built = yield* PreviewToolkit; - const handleTool = built.handle as unknown as ( - name: keyof typeof built.tools, - payload: unknown, - ) => Effect.Effect< - Stream.Stream<{ readonly encodedResult: unknown }, Error>, - Error, - McpInvocationContext.McpInvocationContext - >; - for (const tool of Object.values(built.tools)) { - yield* server.addTool({ - tool: new McpSchema.Tool({ - name: tool.name, - description: Tool.getDescription(tool), - inputSchema: Tool.getJsonSchema(tool), - annotations: { - ...Context.getOption(tool.annotations, Tool.Title).pipe( - Option.map((title) => ({ title })), - Option.getOrUndefined, - ), - readOnlyHint: Context.get(tool.annotations, Tool.Readonly), - destructiveHint: Context.get(tool.annotations, Tool.Destructive), - idempotentHint: Context.get(tool.annotations, Tool.Idempotent), - openWorldHint: Context.get(tool.annotations, Tool.OpenWorld), - }, - }), - annotations: tool.annotations, - handle: (payload) => - handleTool(tool.name as keyof typeof built.tools, payload).pipe( + const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; + const built = yield* PreviewSnapshotToolkit; + const tool = PreviewSnapshotTool; + yield* server.addTool({ + tool: new McpSchema.Tool({ + name: tool.name, + description: Tool.getDescription(tool), + inputSchema: Tool.getJsonSchema(tool), + annotations: { + ...Context.getOption(tool.annotations, Tool.Title).pipe( + Option.map((title) => ({ title })), + Option.getOrUndefined, + ), + readOnlyHint: Context.get(tool.annotations, Tool.Readonly), + destructiveHint: Context.get(tool.annotations, Tool.Destructive), + idempotentHint: Context.get(tool.annotations, Tool.Idempotent), + openWorldHint: Context.get(tool.annotations, Tool.OpenWorld), + }, + }), + annotations: tool.annotations, + handle: (payload) => + Effect.withFiber((fiber) => { + const invocation = Context.getUnsafe( + fiber.context, + McpInvocationContext.McpInvocationContext, + ); + return built.handle("preview_snapshot", payload).pipe( Stream.unwrap, Stream.run(Sink.last()), Effect.flatMap(Effect.fromOption), + Effect.provideService(PreviewAutomationBroker.PreviewAutomationBroker, broker), + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), Effect.matchCause({ onFailure: (cause) => new McpSchema.CallToolResult({ isError: true, content: [{ type: "text", text: Cause.pretty(cause) }], }), - onSuccess: (result) => { - if (tool.name === "preview_snapshot") { - const snapshot = result.encodedResult as { - readonly screenshot: { - readonly mimeType: "image/png"; - readonly data: string; - readonly width: number; - readonly height: number; - }; - readonly [key: string]: unknown; + onSuccess: ({ encodedResult }) => { + const snapshot = encodedResult as { + readonly screenshot: { + readonly mimeType: "image/png"; + readonly data: string; + readonly width: number; + readonly height: number; }; - const { screenshot, ...page } = snapshot; - const metadata = { - ...page, - screenshot: { - mimeType: screenshot.mimeType, - width: screenshot.width, - height: screenshot.height, - }, - }; - return new McpSchema.CallToolResult({ - isError: false, - structuredContent: metadata, - content: [ - { type: "text", text: JSON.stringify(metadata) }, - { - type: "image", - data: new Uint8Array(Buffer.from(screenshot.data, "base64")), - mimeType: screenshot.mimeType, - }, - ], - }); - } - const encodedResultText = JSON.stringify(result.encodedResult) ?? "null"; + readonly [key: string]: unknown; + }; + const { screenshot, ...page } = snapshot; + const metadata = { + ...page, + screenshot: { + mimeType: screenshot.mimeType, + width: screenshot.width, + height: screenshot.height, + }, + }; return new McpSchema.CallToolResult({ isError: false, - structuredContent: - result.encodedResult !== null && typeof result.encodedResult === "object" - ? result.encodedResult - : undefined, - content: [{ type: "text", text: encodedResultText }], + structuredContent: metadata, + content: [ + { type: "text", text: JSON.stringify(metadata) }, + { + type: "image", + data: new Uint8Array(Buffer.from(screenshot.data, "base64")), + mimeType: screenshot.mimeType, + }, + ], }); }, }), - ) as unknown as Effect.Effect<McpSchema.CallToolResult, never, McpSchema.McpServerClient>, - }); - } + ); + }), + }); }); -export const PreviewToolkitRegistrationLive = Layer.effectDiscard(registerPreviewToolkit()).pipe( - Layer.provide(PreviewToolkitHandlersLive), +const PreviewStandardToolkitRegistrationLive = McpServer.toolkit(PreviewStandardToolkit).pipe( + Layer.provide(PreviewStandardToolkitHandlersLive), +); + +const PreviewSnapshotRegistrationLive = Layer.effectDiscard(registerPreviewSnapshot()).pipe( + Layer.provide(PreviewSnapshotToolkitHandlersLive), +); + +export const PreviewToolkitRegistrationLive = Layer.mergeAll( + PreviewStandardToolkitRegistrationLive, + PreviewSnapshotRegistrationLive, ); const McpTransportLive = McpServer.layerHttp({ diff --git a/apps/server/src/mcp/McpSessionRegistry.ts b/apps/server/src/mcp/McpSessionRegistry.ts index 161b560f746..1ee7d278c62 100644 --- a/apps/server/src/mcp/McpSessionRegistry.ts +++ b/apps/server/src/mcp/McpSessionRegistry.ts @@ -1,9 +1,10 @@ import { ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as Ref from "effect/Ref"; +import * as SynchronizedRef from "effect/SynchronizedRef"; import { HttpServer } from "effect/unstable/http"; import { ServerEnvironment } from "../environment/Services/ServerEnvironment.ts"; @@ -59,15 +60,15 @@ const bytesToHex = (bytes: Uint8Array): string => const tokenFromBytes = (bytes: Uint8Array): string => Buffer.from(bytes).toString("base64url"); -const make = Effect.fn("McpSessionRegistry.make")(function* ( +const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* ( options: McpSessionRegistryOptions = {}, ) { const crypto = yield* Crypto.Crypto; const environment = yield* ServerEnvironment; const environmentId = yield* environment.getEnvironmentId; const httpServer = yield* HttpServer.HttpServer; - const state = yield* Ref.make<RegistryState>({ records: new Map() }); - const now = options.now ?? Date.now; + const state = yield* SynchronizedRef.make<RegistryState>({ records: new Map() }); + const currentTimeMillis = options.now ? Effect.sync(options.now) : Clock.currentTimeMillis; const idleTimeoutMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS; const maximumLifetimeMs = options.maximumLifetimeMs ?? DEFAULT_MAXIMUM_LIFETIME_MS; const endpoint = @@ -81,21 +82,18 @@ const make = Effect.fn("McpSessionRegistry.make")(function* ( .pipe(Effect.map(bytesToHex), Effect.orDie); const pruneExpired = (records: ReadonlyMap<string, CredentialRecord>, timestamp: number) => { - let changed = false; - const next = new Map<string, CredentialRecord>(); - for (const [hash, record] of records) { - if (timestamp <= record.scope.expiresAt && timestamp - record.lastUsedAt <= idleTimeoutMs) { - next.set(hash, record); - } else { - changed = true; - } - } - return changed ? next : records; + const next = new Map( + Array.from(records).filter( + ([, record]) => + timestamp <= record.scope.expiresAt && timestamp - record.lastUsedAt <= idleTimeoutMs, + ), + ); + return next.size === records.size ? records : next; }; const issue: McpSessionRegistryShape["issue"] = Effect.fn("McpSessionRegistry.issue")( function* (request) { - const issuedAt = now(); + const issuedAt = yield* currentTimeMillis; const providerSessionId = yield* crypto.randomUUIDv4.pipe(Effect.orDie); const rawToken = yield* crypto.randomBytes(32).pipe(Effect.map(tokenFromBytes), Effect.orDie); const tokenHash = yield* hashToken(rawToken); @@ -109,7 +107,7 @@ const make = Effect.fn("McpSessionRegistry.make")(function* ( issuedAt, expiresAt, }; - yield* Ref.update(state, ({ records }) => { + yield* SynchronizedRef.update(state, ({ records }) => { const next = new Map(pruneExpired(records, issuedAt)); next.set(tokenHash, { tokenHash, scope, lastUsedAt: issuedAt }); return { records: next }; @@ -132,23 +130,20 @@ const make = Effect.fn("McpSessionRegistry.make")(function* ( function* (rawToken) { if (rawToken.length === 0) return undefined; const tokenHash = yield* hashToken(rawToken); - const timestamp = now(); - let resolved: McpInvocationContext.McpInvocationScope | undefined; - yield* Ref.update(state, ({ records }) => { + const timestamp = yield* currentTimeMillis; + return yield* SynchronizedRef.modify(state, ({ records }) => { const current = pruneExpired(records, timestamp); const record = current.get(tokenHash); - if (!record) return { records: current }; - resolved = record.scope; + if (!record) return [undefined, { records: current }] as const; const next = new Map(current); next.set(tokenHash, { ...record, lastUsedAt: timestamp }); - return { records: next }; + return [record.scope, { records: next }] as const; }); - return resolved; }, ); const revokeWhere = (predicate: (record: CredentialRecord) => boolean) => - Ref.update(state, ({ records }) => ({ + SynchronizedRef.update(state, ({ records }) => ({ records: new Map(Array.from(records).filter(([, record]) => !predicate(record))), })); @@ -163,27 +158,34 @@ const make = Effect.fn("McpSessionRegistry.make")(function* ( revokeThread: Effect.fn("McpSessionRegistry.revokeThread")(function* (threadId) { yield* revokeWhere((record) => record.scope.threadId === threadId); }), - revokeAll: Ref.set(state, { records: new Map() }), + revokeAll: SynchronizedRef.set(state, { records: new Map() }), }); }); let activeMcpSessionRegistry: McpSessionRegistryShape | undefined; -export const layer: Layer.Layer< - McpSessionRegistry, - never, - Crypto.Crypto | ServerEnvironment | HttpServer.HttpServer -> = Layer.effect( - McpSessionRegistry, - make().pipe( +const make = Effect.acquireRelease( + makeWithOptions().pipe( Effect.tap((registry) => Effect.sync(() => { activeMcpSessionRegistry = registry; }), ), ), + (registry) => + Effect.sync(() => { + if (activeMcpSessionRegistry === registry) { + activeMcpSessionRegistry = undefined; + } + }), ); +export const layer: Layer.Layer< + McpSessionRegistry, + never, + Crypto.Crypto | ServerEnvironment | HttpServer.HttpServer +> = Layer.effect(McpSessionRegistry, make); + export const issueActiveMcpCredential = ( request: McpCredentialRequest, ): Effect.Effect<McpIssuedCredential | undefined> => @@ -201,5 +203,5 @@ export const revokeAllActiveMcpCredentials = (): Effect.Effect<void> => /** Exposed for tests. */ export const __testing = { - make, + make: makeWithOptions, }; diff --git a/apps/server/src/mcp/PreviewAutomationBroker.test.ts b/apps/server/src/mcp/PreviewAutomationBroker.test.ts index 7d702cd970b..353353aaef2 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.test.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.test.ts @@ -23,7 +23,7 @@ const scope = { it.effect("routes a request to the focused owner and correlates its response", () => Effect.scoped( Effect.gen(function* () { - const broker = yield* PreviewAutomationBroker.__testing.make(); + const broker = yield* PreviewAutomationBroker.__testing.make; const requests = yield* broker.connect("client-1"); yield* Stream.runForEach(requests, (request) => broker.respond({ @@ -56,7 +56,7 @@ it.effect("routes a request to the focused owner and correlates its response", ( it.effect("rejects calls when no focused owner exists", () => Effect.gen(function* () { - const broker = yield* PreviewAutomationBroker.__testing.make(); + const broker = yield* PreviewAutomationBroker.__testing.make; const error = yield* broker .invoke<void>({ scope, operation: "status", input: {} }) .pipe(Effect.flip); @@ -67,7 +67,7 @@ it.effect("rejects calls when no focused owner exists", () => it.effect("routes interactive commands to a hidden durable browser host", () => Effect.scoped( Effect.gen(function* () { - const broker = yield* PreviewAutomationBroker.__testing.make(); + const broker = yield* PreviewAutomationBroker.__testing.make; const requests = yield* broker.connect("client-hidden"); yield* Stream.runForEach(requests, (request) => broker.respond({ requestId: request.requestId, ok: true }), diff --git a/apps/server/src/mcp/PreviewAutomationBroker.ts b/apps/server/src/mcp/PreviewAutomationBroker.ts index a8ae4a9eec4..e0a7b0c9285 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.ts @@ -21,8 +21,8 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; -import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; +import * as SynchronizedRef from "effect/SynchronizedRef"; import * as McpInvocationContext from "./McpInvocationContext.ts"; @@ -71,6 +71,7 @@ interface BrokerState { readonly clients: ReadonlyMap<string, ClientConnection>; readonly owners: ReadonlyMap<string, PreviewAutomationOwner>; readonly pending: ReadonlyMap<string, PendingRequest>; + readonly requestSequence: number; } const makeResponseError = ( @@ -119,33 +120,35 @@ const makeResponseError = ( } }; -const make = Effect.fn("PreviewAutomationBroker.make")(function* () { - const state = yield* Ref.make<BrokerState>({ +const make = Effect.gen(function* PreviewAutomationBrokerMake() { + const state = yield* SynchronizedRef.make<BrokerState>({ clients: new Map(), owners: new Map(), pending: new Map(), + requestSequence: 0, }); - let requestSequence = 0; const disconnect = Effect.fn("PreviewAutomationBroker.disconnect")(function* ( clientId: string, queue: ClientConnection["queue"], ) { - const toFail: PendingRequest[] = []; - yield* Ref.update(state, (current) => { - if (current.clients.get(clientId)?.queue !== queue) return current; + const toFail = yield* SynchronizedRef.modify(state, (current) => { + if (current.clients.get(clientId)?.queue !== queue) { + return [[] as ReadonlyArray<PendingRequest>, current] as const; + } const clients = new Map(current.clients); const owners = new Map(current.owners); const pending = new Map(current.pending); + const disconnected: PendingRequest[] = []; clients.delete(clientId); owners.delete(clientId); for (const [requestId, entry] of pending) { if (entry.clientId === clientId) { pending.delete(requestId); - toFail.push(entry); + disconnected.push(entry); } } - return { clients, owners, pending }; + return [disconnected, { ...current, clients, owners, pending }] as const; }); yield* Effect.forEach( toFail, @@ -165,12 +168,10 @@ const make = Effect.fn("PreviewAutomationBroker.make")(function* () { "PreviewAutomationBroker.connect", )(function* (clientId) { const queue = yield* Queue.unbounded<import("@t3tools/contracts").PreviewAutomationRequest>(); - let previous: ClientConnection | undefined; - yield* Ref.update(state, (current) => { - previous = current.clients.get(clientId); + const previous = yield* SynchronizedRef.modify(state, (current) => { const clients = new Map(current.clients); clients.set(clientId, { clientId, queue }); - return { ...current, clients }; + return [current.clients.get(clientId), { ...current, clients }] as const; }); if (previous) yield* disconnect(clientId, previous.queue); return Stream.fromQueue(queue).pipe(Stream.ensuring(disconnect(clientId, queue))); @@ -179,7 +180,7 @@ const make = Effect.fn("PreviewAutomationBroker.make")(function* () { const reportOwner: PreviewAutomationBrokerShape["reportOwner"] = Effect.fn( "PreviewAutomationBroker.reportOwner", )(function* (owner) { - yield* Ref.update(state, (current) => { + yield* SynchronizedRef.update(state, (current) => { const owners = new Map(current.owners); owners.set(owner.clientId, owner); return { ...current, owners }; @@ -189,7 +190,7 @@ const make = Effect.fn("PreviewAutomationBroker.make")(function* () { const clearOwner: PreviewAutomationBrokerShape["clearOwner"] = Effect.fn( "PreviewAutomationBroker.clearOwner", )(function* (clientId) { - yield* Ref.update(state, (current) => { + yield* SynchronizedRef.update(state, (current) => { const owners = new Map(current.owners); owners.delete(clientId); return { ...current, owners }; @@ -199,13 +200,12 @@ const make = Effect.fn("PreviewAutomationBroker.make")(function* () { const respond: PreviewAutomationBrokerShape["respond"] = Effect.fn( "PreviewAutomationBroker.respond", )(function* (response) { - let pending: PendingRequest | undefined; - yield* Ref.update(state, (current) => { - pending = current.pending.get(response.requestId); - if (!pending) return current; + const pending = yield* SynchronizedRef.modify(state, (current) => { + const entry = current.pending.get(response.requestId); + if (!entry) return [undefined, current] as const; const next = new Map(current.pending); next.delete(response.requestId); - return { ...current, pending: next }; + return [entry, { ...current, pending: next }] as const; }); if (!pending) return; if (response.ok) { @@ -225,7 +225,7 @@ const make = Effect.fn("PreviewAutomationBroker.make")(function* () { const invoke = Effect.fn("PreviewAutomationBroker.invoke")(function* <A = unknown>( input: Parameters<PreviewAutomationBrokerShape["invoke"]>[0], ): Effect.fn.Return<A, PreviewAutomationError> { - const current = yield* Ref.get(state); + const current = yield* SynchronizedRef.get(state); const candidates = Array.from(current.owners.values()) .filter( (owner) => @@ -256,48 +256,52 @@ const make = Effect.fn("PreviewAutomationBroker.make")(function* () { message: "The browser host does not have an active tab.", }); } - const requestId = `preview-${requestSequence++}`; const timeoutMs = input.timeoutMs ?? 15_000; const deferred = yield* Deferred.make<unknown, PreviewAutomationError>(); - yield* Ref.update(state, (next) => { + const requestId = yield* SynchronizedRef.modify(state, (next) => { + const requestId = `preview-${next.requestSequence}`; const pending = new Map(next.pending); pending.set(requestId, { clientId: owner.clientId, deferred }); - return { ...next, pending }; - }); - const offered = yield* Queue.offer(connection.queue, { - requestId, - threadId: input.scope.threadId, - tabId: input.tabId ?? owner.tabId ?? undefined, - operation: input.operation, - input: input.input, - timeoutMs, + return [requestId, { ...next, pending, requestSequence: next.requestSequence + 1 }] as const; }); - if (!offered) { - return yield* new PreviewAutomationUnavailableError({ - message: "The preview automation client is no longer accepting requests.", - }); - } - const result = yield* Deferred.await(deferred).pipe(Effect.timeoutOption(timeoutMs)); - yield* Ref.update(state, (next) => { + const removePending = SynchronizedRef.update(state, (next) => { + if (!next.pending.has(requestId)) return next; const pending = new Map(next.pending); pending.delete(requestId); return { ...next, pending }; }); - return yield* Option.match(result, { - onNone: () => - Effect.fail( - new PreviewAutomationTimeoutError({ - message: `Preview automation timed out after ${timeoutMs}ms.`, - }), - ), - onSome: (value) => Effect.succeed(value as A), + const awaitResponse = Effect.fn("PreviewAutomationBroker.awaitResponse")(function* () { + const offered = yield* Queue.offer(connection.queue, { + requestId, + threadId: input.scope.threadId, + tabId: input.tabId ?? owner.tabId ?? undefined, + operation: input.operation, + input: input.input, + timeoutMs, + }); + if (!offered) { + return yield* new PreviewAutomationUnavailableError({ + message: "The preview automation client is no longer accepting requests.", + }); + } + const result = yield* Deferred.await(deferred).pipe(Effect.timeoutOption(timeoutMs)); + return yield* Option.match(result, { + onNone: () => + Effect.fail( + new PreviewAutomationTimeoutError({ + message: `Preview automation timed out after ${timeoutMs}ms.`, + }), + ), + onSome: (value) => Effect.succeed(value as A), + }); }); + return yield* awaitResponse().pipe(Effect.ensuring(removePending)); }); return PreviewAutomationBroker.of({ connect, reportOwner, clearOwner, respond, invoke }); -}); +}).pipe(Effect.withSpan("PreviewAutomationBroker.make")); -export const layer = Layer.effect(PreviewAutomationBroker, make()); +export const layer = Layer.effect(PreviewAutomationBroker, make); /** Exposed for tests. */ export const __testing = { diff --git a/apps/server/src/mcp/toolkits/preview/handlers.ts b/apps/server/src/mcp/toolkits/preview/handlers.ts index 56c9a6d98e4..6013b1cac9e 100644 --- a/apps/server/src/mcp/toolkits/preview/handlers.ts +++ b/apps/server/src/mcp/toolkits/preview/handlers.ts @@ -9,7 +9,7 @@ import type { import * as McpInvocationContext from "../../McpInvocationContext.ts"; import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts"; -import { PreviewToolkit } from "./tools.ts"; +import { PreviewSnapshotToolkit, PreviewStandardToolkit, PreviewToolkit } from "./tools.ts"; const invoke = Effect.fn("PreviewToolkit.invoke")(function* <A>( operation: PreviewAutomationOperation, @@ -30,7 +30,7 @@ const invoke = Effect.fn("PreviewToolkit.invoke")(function* <A>( }); }); -export const PreviewToolkitHandlersLive = PreviewToolkit.toLayer({ +const handlers = { preview_status: () => invoke<PreviewAutomationStatus>("status", {}), preview_open: (input) => invoke<PreviewAutomationStatus>("open", { @@ -40,12 +40,24 @@ export const PreviewToolkitHandlersLive = PreviewToolkit.toLayer({ }), preview_navigate: (input) => invoke<PreviewAutomationStatus>("navigate", input, input.timeoutMs), preview_snapshot: () => invoke<PreviewAutomationSnapshot>("snapshot", {}), - preview_click: (input) => invoke<void>("click", input, input.timeoutMs), - preview_type: (input) => invoke<void>("type", input, input.timeoutMs), - preview_press: (input) => invoke<void>("press", input), - preview_scroll: (input) => invoke<void>("scroll", input), - preview_evaluate: (input) => invoke<unknown>("evaluate", input), - preview_wait_for: (input) => invoke<void>("waitFor", input, input.timeoutMs), + preview_click: (input) => invoke<void>("click", input, input.timeoutMs).pipe(Effect.as(null)), + preview_type: (input) => invoke<void>("type", input, input.timeoutMs).pipe(Effect.as(null)), + preview_press: (input) => invoke<void>("press", input).pipe(Effect.as(null)), + preview_scroll: (input) => invoke<void>("scroll", input).pipe(Effect.as(null)), + preview_evaluate: (input) => + invoke<unknown>("evaluate", input).pipe(Effect.map((result) => result ?? null)), + preview_wait_for: (input) => + invoke<void>("waitFor", input, input.timeoutMs).pipe(Effect.as(null)), preview_recording_start: () => invoke<PreviewAutomationRecordingStatus>("recordingStart", {}), preview_recording_stop: () => invoke<PreviewAutomationRecordingArtifact>("recordingStop", {}), +} satisfies Parameters<typeof PreviewToolkit.toLayer>[0]; + +const { preview_snapshot, ...standardHandlers } = handlers; + +export const PreviewStandardToolkitHandlersLive = PreviewStandardToolkit.toLayer(standardHandlers); + +export const PreviewSnapshotToolkitHandlersLive = PreviewSnapshotToolkit.toLayer({ + preview_snapshot, }); + +export const PreviewToolkitHandlersLive = PreviewToolkit.toLayer(handlers); diff --git a/apps/server/src/mcp/toolkits/preview/tools.ts b/apps/server/src/mcp/toolkits/preview/tools.ts index bcfe720a950..fd2fedbb369 100644 --- a/apps/server/src/mcp/toolkits/preview/tools.ts +++ b/apps/server/src/mcp/toolkits/preview/tools.ts @@ -84,6 +84,7 @@ export const PreviewClickTool = browserTool( description: "Click exactly one page target. Prefer locator with a Playwright selector such as role=button[name='Send']; selector accepts legacy CSS; x and y are viewport CSS pixels and must be supplied together. Call preview_snapshot first when the target is unknown.", parameters: PreviewAutomationClickInput, + success: Schema.Null, failure: PreviewAutomationError, dependencies, }).annotate(Tool.Title, "Click preview page"), @@ -94,6 +95,7 @@ export const PreviewTypeTool = browserTool( description: "Insert literal text into one input. Prefer locator with a Playwright role/text selector; selector accepts legacy CSS. If neither is supplied, types into the currently focused element. Set clear=true to replace existing text.", parameters: PreviewAutomationTypeInput, + success: Schema.Null, failure: PreviewAutomationError, dependencies, }).annotate(Tool.Title, "Type into preview page"), @@ -104,6 +106,7 @@ export const PreviewPressTool = browserTool( description: "Press one keyboard key in the active page, for example {key:'Enter'}, {key:'Escape'}, or {key:'a',modifiers:['Meta']}. This targets the page's current focus.", parameters: PreviewAutomationPressInput, + success: Schema.Null, failure: PreviewAutomationError, dependencies, }).annotate(Tool.Title, "Press key in preview page"), @@ -114,6 +117,7 @@ export const PreviewScrollTool = safeBrowserTool( description: "Scroll by CSS pixels. Positive deltaY scrolls down and positive deltaX scrolls right. Without locator/selector it scrolls the viewport; otherwise it scrolls that container. At least one delta is required.", parameters: PreviewAutomationScrollInput, + success: Schema.Null, failure: PreviewAutomationError, dependencies, }).annotate(Tool.Title, "Scroll preview page"), @@ -135,6 +139,7 @@ export const PreviewWaitForTool = readonlyBrowserTool( description: "Wait until all supplied conditions match: a Playwright locator, legacy CSS selector, visible-text substring, and/or URL substring. Provide at least one condition. Defaults to 15 seconds, maximum 60 seconds.", parameters: PreviewAutomationWaitForInput, + success: Schema.Null, failure: PreviewAutomationError, dependencies, }).annotate(Tool.Title, "Wait for preview page condition"), @@ -173,3 +178,19 @@ export const PreviewToolkit = Toolkit.make( PreviewRecordingStartTool, PreviewRecordingStopTool, ); + +export const PreviewStandardToolkit = Toolkit.make( + PreviewStatusTool, + PreviewOpenTool, + PreviewNavigateTool, + PreviewClickTool, + PreviewTypeTool, + PreviewPressTool, + PreviewScrollTool, + PreviewEvaluateTool, + PreviewWaitForTool, + PreviewRecordingStartTool, + PreviewRecordingStopTool, +); + +export const PreviewSnapshotToolkit = Toolkit.make(PreviewSnapshotTool); diff --git a/apps/server/src/preview/Manager.ts b/apps/server/src/preview/Manager.ts index e18bbdaf493..8fa3a3668bf 100644 --- a/apps/server/src/preview/Manager.ts +++ b/apps/server/src/preview/Manager.ts @@ -127,7 +127,7 @@ const buildIdleSnapshot = (input: { updatedAt: input.updatedAt, }); -const make = Effect.fn("PreviewManager.make")(function* () { +const make = Effect.gen(function* PreviewManagerMake() { const stateRef = yield* SynchronizedRef.make<ManagerState>(initialState); // Unbounded PubSub is fine here — events are tiny and we don't want to // block publishers if a subscriber is slow. WS clients backpressure on @@ -357,6 +357,6 @@ const make = Effect.fn("PreviewManager.make")(function* () { events, subscribeEvents: PubSub.subscribe(eventsPubSub), } satisfies PreviewManagerShape; -}); +}).pipe(Effect.withSpan("PreviewManager.make")); -export const layer = Layer.effect(PreviewManager, make()); +export const layer = Layer.effect(PreviewManager, make); diff --git a/apps/server/src/preview/PortScanner.test.ts b/apps/server/src/preview/PortScanner.test.ts index 350268acff6..8b37e86d8a9 100644 --- a/apps/server/src/preview/PortScanner.test.ts +++ b/apps/server/src/preview/PortScanner.test.ts @@ -249,20 +249,18 @@ effectIt.layer(TestPortDiscoveryLive)("PortDiscovery integration (TCP probe fall ); it.effect( - "retain() drives an immediate broadcast to subscribers", + "retain drives an immediate broadcast to subscribers", Effect.fn("PortScannerTest.retainBroadcastsImmediately")(function* () { yield* windowsPlatform; const { port } = yield* commonDevServer; const received: number[] = []; const scanner = yield* PortScanner.PortDiscovery; - const unsubscribe = yield* scanner.subscribe((servers) => + yield* scanner.subscribe((servers) => Effect.sync(() => { for (const server of servers) received.push(server.port); }), ); - const release = yield* scanner.retain(); - unsubscribe(); - release(); + yield* scanner.retain; expect(received).toContain(port); }), ); diff --git a/apps/server/src/preview/PortScanner.ts b/apps/server/src/preview/PortScanner.ts index d2ad4f43c37..c8d9a051ed6 100644 --- a/apps/server/src/preview/PortScanner.ts +++ b/apps/server/src/preview/PortScanner.ts @@ -8,13 +8,13 @@ * Windows / lsof missing: checks a curated list of common dev ports through * the shared Net service. * - * Polling is reference-counted via `retain()`. A single layer-scoped fiber + * Polling is reference-counted via scoped `retain`. A single layer-scoped fiber * polls forever, but each tick is a no-op when the retain count is zero. */ import { ThreadId, type DiscoveredLocalServer } from "@t3tools/contracts"; import * as Net from "@t3tools/shared/Net"; import { LSOF_LOCAL_HOST_TOKENS } from "@t3tools/shared/preview"; -import { Cause, Context, Duration, Effect, Layer, Ref, Schedule } from "effect"; +import { Cause, Context, Duration, Effect, Layer, Ref, Schedule, Scope } from "effect"; import { ProcessRunner } from "../processRunner.ts"; @@ -22,8 +22,8 @@ export interface PortDiscoveryShape { readonly scan: () => Effect.Effect<ReadonlyArray<DiscoveredLocalServer>>; readonly subscribe: ( listener: (servers: ReadonlyArray<DiscoveredLocalServer>) => Effect.Effect<void>, - ) => Effect.Effect<() => void>; - readonly retain: () => Effect.Effect<() => void>; + ) => Effect.Effect<void, never, Scope.Scope>; + readonly retain: Effect.Effect<void, never, Scope.Scope>; readonly registerTerminalProcesses: (input: { readonly threadId: string; readonly terminalId: string; @@ -51,6 +51,15 @@ type Listener = (servers: ReadonlyArray<DiscoveredLocalServer>) => Effect.Effect interface ScannerState { readonly lastSnapshot: ReadonlyArray<DiscoveredLocalServer>; + readonly listeners: ReadonlySet<Listener>; + readonly terminalProcesses: ReadonlyMap< + string, + { + readonly owner: TerminalProcessOwner; + readonly processIds: ReadonlySet<number>; + } + >; + readonly retainCount: number; } interface TerminalProcessOwner { @@ -170,24 +179,15 @@ const serversEqual = ( return true; }; -const make = Effect.fn("PortDiscovery.make")(function* () { +const make = Effect.gen(function* PortDiscoveryMake() { const net = yield* Net.NetService; const processRunner = yield* ProcessRunner; const stateRef = yield* Ref.make<ScannerState>({ lastSnapshot: [], + listeners: new Set(), + terminalProcesses: new Map(), + retainCount: 0, }); - const listeners = new Set<Listener>(); - const terminalProcesses = new Map< - string, - { - readonly owner: TerminalProcessOwner; - readonly processIds: ReadonlySet<number>; - } - >(); - // Plain integer because the release callback returned by `retain()` runs - // outside any Effect context (the WS subscriber's release path) and must - // be a synchronous side-effect-only function. - let retainCount = 0; const probeCommonPorts = Effect.fn("PortDiscovery.probeCommonPorts")(function* () { const results = yield* Effect.forEach( @@ -214,8 +214,9 @@ const make = Effect.fn("PortDiscovery.make")(function* () { }); const scanOnce = Effect.fn("PortDiscovery.scan")(function* () { + const state = yield* Ref.get(stateRef); const terminalByProcessId = new Map<number, TerminalProcessOwner>(); - for (const registration of terminalProcesses.values()) { + for (const registration of state.terminalProcesses.values()) { for (const processId of registration.processIds) { terminalByProcessId.set(processId, registration.owner); } @@ -254,17 +255,23 @@ const make = Effect.fn("PortDiscovery.make")(function* () { return yield* probeCommonPorts(); }); - const broadcast = (servers: ReadonlyArray<DiscoveredLocalServer>): Effect.Effect<void> => - Effect.forEach(Array.from(listeners), (listener) => listener(servers), { discard: true }); + const broadcast = Effect.fn("PortDiscovery.broadcast")(function* ( + servers: ReadonlyArray<DiscoveredLocalServer>, + ) { + const listeners = (yield* Ref.get(stateRef)).listeners; + yield* Effect.forEach(listeners, (listener) => listener(servers), { discard: true }); + }); const pollTick = Effect.fn("PortDiscovery.pollTick")( function* () { - if (retainCount <= 0) return; + if ((yield* Ref.get(stateRef)).retainCount <= 0) return; const next = yield* scanOnce(); - const state = yield* Ref.get(stateRef); - if (serversEqual(state.lastSnapshot, next)) return; - yield* Ref.update(stateRef, (s) => ({ ...s, lastSnapshot: next })); - yield* broadcast(next); + const changed = yield* Ref.modify(stateRef, (state) => + serversEqual(state.lastSnapshot, next) + ? [false, state] + : [true, { ...state, lastSnapshot: next }], + ); + if (changed) yield* broadcast(next); }, Effect.catchCause((cause: Cause.Cause<never>) => Effect.logWarning("preview port scan failed", Cause.pretty(cause)), @@ -275,58 +282,70 @@ const make = Effect.fn("PortDiscovery.make")(function* () { // currently retained, so the cost is one Ref.get every POLL_INTERVAL. yield* Effect.forkScoped(pollTick().pipe(Effect.repeat(Schedule.spaced(POLL_INTERVAL)))); - const retain: PortDiscoveryShape["retain"] = Effect.fn("PortDiscovery.retain")(function* () { - const wasIdle = retainCount === 0; - retainCount += 1; + const acquireRetention = Effect.fn("PortDiscovery.retain")(function* () { + const wasIdle = yield* Ref.modify(stateRef, (state) => [ + state.retainCount === 0, + { ...state, retainCount: state.retainCount + 1 }, + ]); if (wasIdle) { // Run an immediate scan + broadcast so the new retainer doesn't have // to wait up to POLL_INTERVAL for the first emission. yield* pollTick(); } - let released = false; - return () => { - if (released) return; - released = true; - retainCount = Math.max(0, retainCount - 1); - }; }); + const retain: PortDiscoveryShape["retain"] = Effect.acquireRelease(acquireRetention(), () => + Ref.update(stateRef, (state) => ({ + ...state, + retainCount: Math.max(0, state.retainCount - 1), + })), + ); + const subscribe: PortDiscoveryShape["subscribe"] = Effect.fn("PortDiscovery.subscribe")( - function* (listener) { - return yield* Effect.sync(() => { - listeners.add(listener); - return () => { - listeners.delete(listener); - }; - }); - }, + (listener) => + Effect.acquireRelease( + Ref.update(stateRef, (state) => ({ + ...state, + listeners: new Set([...state.listeners, listener]), + })), + () => + Ref.update(stateRef, (state) => { + const listeners = new Set(state.listeners); + listeners.delete(listener); + return { ...state, listeners }; + }), + ), ); const registerTerminalProcesses: PortDiscoveryShape["registerTerminalProcesses"] = Effect.fn( "PortDiscovery.registerTerminalProcesses", )(function* (input) { - yield* Effect.sync(() => { - const owner = { - threadId: ThreadId.make(input.threadId), - terminalId: input.terminalId, - }; - const processIds = new Set( - input.processIds.filter((processId) => Number.isInteger(processId) && processId > 0), - ); + const owner = { + threadId: ThreadId.make(input.threadId), + terminalId: input.terminalId, + }; + const processIds = new Set( + input.processIds.filter((processId) => Number.isInteger(processId) && processId > 0), + ); + yield* Ref.update(stateRef, (state) => { + const terminalProcesses = new Map(state.terminalProcesses); const key = terminalOwnerKey(owner); if (processIds.size === 0) { terminalProcesses.delete(key); - return; + } else { + terminalProcesses.set(key, { owner, processIds }); } - terminalProcesses.set(key, { owner, processIds }); + return { ...state, terminalProcesses }; }); }); const unregisterTerminal: PortDiscoveryShape["unregisterTerminal"] = Effect.fn( "PortDiscovery.unregisterTerminal", )(function* (input) { - yield* Effect.sync(() => { + yield* Ref.update(stateRef, (state) => { + const terminalProcesses = new Map(state.terminalProcesses); terminalProcesses.delete(terminalOwnerKey(input)); + return { ...state, terminalProcesses }; }); }); @@ -337,9 +356,9 @@ const make = Effect.fn("PortDiscovery.make")(function* () { registerTerminalProcesses, unregisterTerminal, } satisfies PortDiscoveryShape; -}); +}).pipe(Effect.withSpan("PortDiscovery.make")); -export const layer = Layer.effect(PortDiscovery, make()); +export const layer = Layer.effect(PortDiscovery, make); /** Exposed for tests. */ export const __testing = { diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index b2166d6f0b2..a1cd119a7a6 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -683,8 +683,8 @@ const buildAppUnderTest = (options?: { }), Layer.mock(PortScanner.PortDiscovery)({ scan: () => Effect.succeed([]), - subscribe: () => Effect.succeed(() => {}), - retain: () => Effect.succeed(() => {}), + subscribe: () => Effect.void, + retain: Effect.void, registerTerminalProcesses: () => Effect.void, unregisterTerminal: () => Effect.void, }), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index c8c3c5de46e..d35ab88fee6 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1425,29 +1425,21 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => observeRpcStream( WS_METHODS.subscribeDiscoveredLocalServers, Stream.callback<DiscoveredLocalServerList>((queue) => - Effect.acquireRelease( - Effect.gen(function* () { - const release = yield* portDiscovery.retain(); - const initial = yield* portDiscovery.scan(); - const initialScannedAt = DateTime.formatIso(yield* DateTime.now); - yield* Queue.offer(queue, { - servers: initial, - scannedAt: initialScannedAt, - }); - const unsubscribe = yield* portDiscovery.subscribe((servers) => - Effect.gen(function* () { - const scannedAt = DateTime.formatIso(yield* DateTime.now); - yield* Queue.offer(queue, { servers, scannedAt }); - }), - ); - return { unsubscribe, release }; - }), - ({ unsubscribe, release }) => - Effect.sync(() => { - unsubscribe(); - release(); + Effect.gen(function* () { + yield* portDiscovery.retain; + const initial = yield* portDiscovery.scan(); + const initialScannedAt = DateTime.formatIso(yield* DateTime.now); + yield* Queue.offer(queue, { + servers: initial, + scannedAt: initialScannedAt, + }); + yield* portDiscovery.subscribe((servers) => + Effect.gen(function* () { + const scannedAt = DateTime.formatIso(yield* DateTime.now); + yield* Queue.offer(queue, { servers, scannedAt }); }), - ), + ); + }), ), { "rpc.aggregate": "preview" }, ), From 54f094034445995f355def54e4f927e3b59ec201 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Fri, 12 Jun 2026 18:27:37 -0700 Subject: [PATCH 16/25] Add SWR preview session state and resubscribe handling - Fetch preview sessions through atom-backed SWR state - Recover browser preview sessions after reconnects - Ignore older streamed snapshots when SWR revalidates --- apps/web/src/browser/HostedBrowserWebview.tsx | 20 +--- .../src/browser/previewWebviewConfigState.ts | 48 +++++++++ .../components/preview/previewSessionState.ts | 71 ++++++++++++ .../components/preview/usePreviewSession.ts | 101 ++++++++++++------ apps/web/src/environmentApi.ts | 2 +- apps/web/src/previewStateStore.test.ts | 25 +++++ apps/web/src/previewStateStore.ts | 4 + packages/contracts/src/ipc.ts | 5 +- 8 files changed, 224 insertions(+), 52 deletions(-) create mode 100644 apps/web/src/browser/previewWebviewConfigState.ts create mode 100644 apps/web/src/components/preview/previewSessionState.ts diff --git a/apps/web/src/browser/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx index e158d7ebad6..276a9090af2 100644 --- a/apps/web/src/browser/HostedBrowserWebview.tsx +++ b/apps/web/src/browser/HostedBrowserWebview.tsx @@ -1,8 +1,8 @@ "use client"; -import type { DesktopPreviewWebviewConfig, ScopedThreadRef } from "@t3tools/contracts"; +import type { ScopedThreadRef } from "@t3tools/contracts"; import { useShallow } from "zustand/react/shallow"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef } from "react"; import { previewBridge } from "~/components/preview/previewBridge"; import { usePreviewBridge } from "~/components/preview/usePreviewBridge"; @@ -10,6 +10,7 @@ import { usePreviewBridge } from "~/components/preview/usePreviewBridge"; import { useBrowserRecordingStore } from "./browserRecording"; import { useBrowserSurfaceStore } from "./browserSurfaceStore"; import { acquireDesktopTab } from "./desktopTabLifetime"; +import { usePreviewWebviewConfig } from "./previewWebviewConfigState"; interface ElectronWebview extends HTMLElement { src: string; @@ -31,7 +32,7 @@ export function HostedBrowserWebview(props: { readonly initialUrl: string | null; }) { const { threadRef, tabId, initialUrl } = props; - const [config, setConfig] = useState<DesktopPreviewWebviewConfig | null>(null); + const config = usePreviewWebviewConfig(threadRef.environmentId); const initialSrcRef = useRef(initialUrl ?? "about:blank"); const webviewRef = useRef<ElectronWebview | null>(null); const presentation = useBrowserSurfaceStore(useShallow((state) => state.byTabId[tabId] ?? null)); @@ -41,19 +42,6 @@ export function HostedBrowserWebview(props: { useEffect(() => acquireDesktopTab(tabId), [tabId]); - useEffect(() => { - let cancelled = false; - void previewBridge - ?.getPreviewConfig(threadRef.environmentId) - .then((next) => { - if (!cancelled) setConfig(next); - }) - .catch(() => undefined); - return () => { - cancelled = true; - }; - }, [threadRef.environmentId]); - const setWebviewRef = useCallback((node: HTMLElement | null) => { webviewRef.current = node as ElectronWebview | null; if (node && !node.hasAttribute("allowpopups")) node.setAttribute("allowpopups", "true"); diff --git a/apps/web/src/browser/previewWebviewConfigState.ts b/apps/web/src/browser/previewWebviewConfigState.ts new file mode 100644 index 00000000000..99a8388ec5a --- /dev/null +++ b/apps/web/src/browser/previewWebviewConfigState.ts @@ -0,0 +1,48 @@ +import { useAtomValue } from "@effect/atom-react"; +import type { DesktopPreviewWebviewConfig, EnvironmentId } from "@t3tools/contracts"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; + +import { previewBridge } from "~/components/preview/previewBridge"; + +const PREVIEW_CONFIG_STALE_TIME_MS = 5 * 60_000; +const PREVIEW_CONFIG_IDLE_TTL_MS = 10 * 60_000; + +class PreviewWebviewConfigError extends Data.TaggedError("PreviewWebviewConfigError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +const previewWebviewConfigAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make( + Effect.tryPromise({ + try: () => { + if (!previewBridge) { + throw new Error("Desktop preview bridge is unavailable."); + } + return previewBridge.getPreviewConfig(environmentId); + }, + catch: (cause) => + new PreviewWebviewConfigError({ + message: "Could not load desktop preview configuration.", + cause, + }), + }), + ).pipe( + Atom.swr({ + staleTime: PREVIEW_CONFIG_STALE_TIME_MS, + revalidateOnMount: true, + }), + Atom.setIdleTTL(PREVIEW_CONFIG_IDLE_TTL_MS), + Atom.withLabel(`preview:webview-config:${environmentId}`), + ), +); + +export function usePreviewWebviewConfig( + environmentId: EnvironmentId, +): DesktopPreviewWebviewConfig | null { + const result = useAtomValue(previewWebviewConfigAtom(environmentId)); + return Option.getOrNull(AsyncResult.value(result)); +} diff --git a/apps/web/src/components/preview/previewSessionState.ts b/apps/web/src/components/preview/previewSessionState.ts new file mode 100644 index 00000000000..3d27a76540e --- /dev/null +++ b/apps/web/src/components/preview/previewSessionState.ts @@ -0,0 +1,71 @@ +import { useAtomValue } from "@effect/atom-react"; +import { parseScopedThreadKey, scopedThreadKey } from "@t3tools/client-runtime"; +import type { PreviewListResult, ScopedThreadRef } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; + +import { ensureEnvironmentApi } from "~/environmentApi"; +import { appAtomRegistry } from "~/rpc/atomRegistry"; + +const PREVIEW_SESSION_STALE_TIME_MS = 5_000; +const PREVIEW_SESSION_IDLE_TTL_MS = 5 * 60_000; + +class PreviewSessionQueryError extends Data.TaggedError("PreviewSessionQueryError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +const previewSessionListAtom = Atom.family((threadKey: string) => + Atom.make( + Effect.tryPromise({ + try: () => { + const threadRef = parseScopedThreadKey(threadKey); + if (!threadRef) { + throw new Error(`Invalid scoped thread key: ${threadKey}`); + } + return ensureEnvironmentApi(threadRef.environmentId).preview.list({ + threadId: threadRef.threadId, + }); + }, + catch: (cause) => + new PreviewSessionQueryError({ + message: "Could not load preview sessions.", + cause, + }), + }), + ).pipe( + Atom.swr({ + staleTime: PREVIEW_SESSION_STALE_TIME_MS, + revalidateOnMount: true, + }), + Atom.setIdleTTL(PREVIEW_SESSION_IDLE_TTL_MS), + Atom.withLabel(`preview:sessions:${threadKey}`), + ), +); + +export interface PreviewSessionQueryState { + readonly data: PreviewListResult | null; + readonly error: string | null; + readonly isPending: boolean; +} + +export function refreshPreviewSessionState(threadRef: ScopedThreadRef): void { + appAtomRegistry.refresh(previewSessionListAtom(scopedThreadKey(threadRef))); +} + +export function usePreviewSessionState(threadRef: ScopedThreadRef): PreviewSessionQueryState { + const result = useAtomValue(previewSessionListAtom(scopedThreadKey(threadRef))); + let error: string | null = null; + if (result._tag === "Failure") { + const cause = Cause.squash(result.cause); + error = cause instanceof Error ? cause.message : "Could not load preview sessions."; + } + return { + data: Option.getOrNull(AsyncResult.value(result)), + error, + isPending: result.waiting, + }; +} diff --git a/apps/web/src/components/preview/usePreviewSession.ts b/apps/web/src/components/preview/usePreviewSession.ts index 1a4446bd330..8e4cd95c934 100644 --- a/apps/web/src/components/preview/usePreviewSession.ts +++ b/apps/web/src/components/preview/usePreviewSession.ts @@ -4,9 +4,12 @@ import { scopedThreadKey } from "@t3tools/client-runtime"; import type { ScopedThreadRef } from "@t3tools/contracts"; import { useEffect } from "react"; -import { ensureEnvironmentApi } from "~/environmentApi"; +import { ensureEnvironmentApi, readEnvironmentApi } from "~/environmentApi"; +import { readEnvironmentConnection, subscribeEnvironmentConnections } from "~/environments/runtime"; import { usePreviewStateStore } from "~/previewStateStore"; +import { refreshPreviewSessionState, usePreviewSessionState } from "./previewSessionState"; + /** * Subscribes to the server's per-thread preview events and replays the * latest snapshot on mount. @@ -16,52 +19,82 @@ import { usePreviewStateStore } from "~/previewStateStore"; * `preview.open` so subsequent events land on a real session. */ export function usePreviewSession(threadRef: ScopedThreadRef): void { + const query = usePreviewSessionState(threadRef); const applyServerSnapshot = usePreviewStateStore((state) => state.applyServerSnapshot); const applyServerEvent = usePreviewStateStore((state) => state.applyServerEvent); useEffect(() => { - if (typeof window === "undefined") return; - const api = ensureEnvironmentApi(threadRef.environmentId); + if (!query.data) return; const threadIdValue = threadRef.threadId; let cancelled = false; + if (query.data.sessions.length > 0) { + for (const snapshot of query.data.sessions) applyServerSnapshot(threadRef, snapshot); + return; + } + if (query.isPending) return; + + // Server has no sessions — try to recover what the renderer remembers + // from before the disconnect. + const localSnapshot = + usePreviewStateStore.getState().byThreadKey[scopedThreadKey(threadRef)]?.snapshot; + const recoverableUrl = + localSnapshot && localSnapshot.navStatus._tag !== "Idle" ? localSnapshot.navStatus.url : null; + if (!recoverableUrl) { + applyServerSnapshot(threadRef, null); + return; + } + const api = ensureEnvironmentApi(threadRef.environmentId); void api.preview - .list({ threadId: threadIdValue }) - .then((result) => { + .open({ threadId: threadIdValue, url: recoverableUrl }) + .then((snapshot) => { if (cancelled) return; - // Pick the most recent session. Server returns sessions sorted by - // `updatedAt` ascending, so the last one is freshest. - const serverSnapshot = result.sessions.at(-1) ?? null; - if (serverSnapshot) { - for (const snapshot of result.sessions) applyServerSnapshot(threadRef, snapshot); - return; - } - // Server has no sessions — try to recover what the renderer - // remembers from before the disconnect. - const localSnapshot = - usePreviewStateStore.getState().byThreadKey[scopedThreadKey(threadRef)]?.snapshot; - const recoverableUrl = - localSnapshot && localSnapshot.navStatus._tag !== "Idle" - ? localSnapshot.navStatus.url - : null; - if (recoverableUrl) { - void api.preview - .open({ threadId: threadIdValue, url: recoverableUrl }) - .catch(() => undefined); - } else { - applyServerSnapshot(threadRef, null); - } + applyServerSnapshot(threadRef, snapshot); + refreshPreviewSessionState(threadRef); }) .catch(() => undefined); - const unsubscribe = api.preview.onEvent((event) => { - if (event.threadId !== threadIdValue) return; - applyServerEvent(threadRef, event); - }); - return () => { cancelled = true; - unsubscribe(); }; - }, [applyServerEvent, applyServerSnapshot, threadRef]); + }, [applyServerSnapshot, query.data, query.isPending, threadRef]); + + useEffect(() => { + if (typeof window === "undefined") return; + let clientIdentity: object | null = null; + let unsubscribeEvents: () => void = () => undefined; + + const attach = () => { + const connection = readEnvironmentConnection(threadRef.environmentId); + const api = readEnvironmentApi(threadRef.environmentId); + const nextIdentity = connection?.client ?? api ?? null; + if (nextIdentity === clientIdentity) return; + + unsubscribeEvents(); + unsubscribeEvents = () => undefined; + clientIdentity = nextIdentity; + if (!api) return; + + refreshPreviewSessionState(threadRef); + unsubscribeEvents = api.preview.onEvent( + (event) => { + if (event.threadId !== threadRef.threadId) return; + applyServerEvent(threadRef, event); + if (event.type === "opened" || event.type === "closed") { + refreshPreviewSessionState(threadRef); + } + }, + { + onResubscribe: () => refreshPreviewSessionState(threadRef), + }, + ); + }; + + const unsubscribeConnections = subscribeEnvironmentConnections(attach); + attach(); + return () => { + unsubscribeConnections(); + unsubscribeEvents(); + }; + }, [applyServerEvent, threadRef]); } diff --git a/apps/web/src/environmentApi.ts b/apps/web/src/environmentApi.ts index 2c2166d5085..4bb3c3654cb 100644 --- a/apps/web/src/environmentApi.ts +++ b/apps/web/src/environmentApi.ts @@ -72,7 +72,7 @@ export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi { reportOwner: (owner) => rpcClient.preview.automation.reportOwner(owner as never), clearOwner: (input) => rpcClient.preview.automation.clearOwner(input as never), }, - onEvent: (callback) => rpcClient.preview.onEvent(callback), + onEvent: (callback, options) => rpcClient.preview.onEvent(callback, options), subscribePorts: (callback, options) => rpcClient.preview.subscribePorts(callback, options), }, }; diff --git a/apps/web/src/previewStateStore.test.ts b/apps/web/src/previewStateStore.test.ts index 3adb954cb98..b2df246f246 100644 --- a/apps/web/src/previewStateStore.test.ts +++ b/apps/web/src/previewStateStore.test.ts @@ -262,6 +262,31 @@ describe("previewStateStore (single-tab)", () => { expect(state.snapshot).toBeNull(); }); + it("does not replace a streamed snapshot with older SWR data", () => { + const store = usePreviewStateStore.getState(); + store.applyServerSnapshot( + ref, + makeSnapshot({ + navStatus: { _tag: "Success", url: "http://localhost:5173/new", title: "New" }, + updatedAt: "2026-01-01T00:00:02.000Z", + }), + ); + store.applyServerSnapshot( + ref, + makeSnapshot({ + navStatus: { _tag: "Success", url: "http://localhost:5173/old", title: "Old" }, + updatedAt: "2026-01-01T00:00:01.000Z", + }), + ); + + const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); + expect(state.snapshot?.navStatus).toEqual({ + _tag: "Success", + url: "http://localhost:5173/new", + title: "New", + }); + }); + it("rememberUrl dedupes and caps at limit", () => { const store = usePreviewStateStore.getState(); for (let i = 0; i < __testing.RECENT_URL_LIMIT + 5; i += 1) { diff --git a/apps/web/src/previewStateStore.ts b/apps/web/src/previewStateStore.ts index d6cacda3af7..75cb45db474 100644 --- a/apps/web/src/previewStateStore.ts +++ b/apps/web/src/previewStateStore.ts @@ -189,6 +189,10 @@ export const usePreviewStateStore = create<PreviewStateStoreState>()((set) => ({ desktopByTabId: {}, }; } + const existing = current.sessions[snapshot.tabId]; + if (existing && existing.updatedAt > snapshot.updatedAt) { + return current; + } const recentlySeenUrls = snapshot && snapshot.navStatus._tag !== "Idle" ? dedupeRecentUrls(current.recentlySeenUrls, snapshot.navStatus.url) diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 1739a27e55b..463879e9a5e 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1190,7 +1190,10 @@ export interface EnvironmentApi { reportOwner: (owner: PreviewAutomationOwner) => Promise<void>; clearOwner: (input: { clientId: string }) => Promise<void>; }; - onEvent: (callback: (event: PreviewEvent) => void) => () => void; + onEvent: ( + callback: (event: PreviewEvent) => void, + options?: { onResubscribe?: () => void }, + ) => () => void; subscribePorts: ( callback: (servers: DiscoveredLocalServerList) => void, options?: { onResubscribe?: () => void }, From 31069db241f1c6bf086fe6d7cc3001283e125ef1 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Fri, 12 Jun 2026 18:33:37 -0700 Subject: [PATCH 17/25] Prevent stale preview snapshots from resurrecting sessions - Track preview store revisions per thread - Ignore stale SWR results while revalidating - Avoid restoring closed sessions from outdated data --- .../components/preview/previewSessionState.ts | 12 ++++++++--- .../components/preview/usePreviewSession.ts | 20 ++++++++++++++----- apps/web/src/previewStateStore.ts | 14 +++++++++++++ 3 files changed, 38 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/preview/previewSessionState.ts b/apps/web/src/components/preview/previewSessionState.ts index 3d27a76540e..0896419571f 100644 --- a/apps/web/src/components/preview/previewSessionState.ts +++ b/apps/web/src/components/preview/previewSessionState.ts @@ -8,6 +8,7 @@ import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { ensureEnvironmentApi } from "~/environmentApi"; +import { readPreviewStateRevision } from "~/previewStateStore"; import { appAtomRegistry } from "~/rpc/atomRegistry"; const PREVIEW_SESSION_STALE_TIME_MS = 5_000; @@ -21,14 +22,16 @@ class PreviewSessionQueryError extends Data.TaggedError("PreviewSessionQueryErro const previewSessionListAtom = Atom.family((threadKey: string) => Atom.make( Effect.tryPromise({ - try: () => { + try: async () => { const threadRef = parseScopedThreadKey(threadKey); if (!threadRef) { throw new Error(`Invalid scoped thread key: ${threadKey}`); } - return ensureEnvironmentApi(threadRef.environmentId).preview.list({ + const revision = readPreviewStateRevision(threadRef); + const result = await ensureEnvironmentApi(threadRef.environmentId).preview.list({ threadId: threadRef.threadId, }); + return { result, revision }; }, catch: (cause) => new PreviewSessionQueryError({ @@ -47,7 +50,10 @@ const previewSessionListAtom = Atom.family((threadKey: string) => ); export interface PreviewSessionQueryState { - readonly data: PreviewListResult | null; + readonly data: { + readonly result: PreviewListResult; + readonly revision: number; + } | null; readonly error: string | null; readonly isPending: boolean; } diff --git a/apps/web/src/components/preview/usePreviewSession.ts b/apps/web/src/components/preview/usePreviewSession.ts index 8e4cd95c934..0e24139c982 100644 --- a/apps/web/src/components/preview/usePreviewSession.ts +++ b/apps/web/src/components/preview/usePreviewSession.ts @@ -6,7 +6,7 @@ import { useEffect } from "react"; import { ensureEnvironmentApi, readEnvironmentApi } from "~/environmentApi"; import { readEnvironmentConnection, subscribeEnvironmentConnections } from "~/environments/runtime"; -import { usePreviewStateStore } from "~/previewStateStore"; +import { readPreviewStateRevision, usePreviewStateStore } from "~/previewStateStore"; import { refreshPreviewSessionState, usePreviewSessionState } from "./previewSessionState"; @@ -24,14 +24,24 @@ export function usePreviewSession(threadRef: ScopedThreadRef): void { const applyServerEvent = usePreviewStateStore((state) => state.applyServerEvent); useEffect(() => { - if (!query.data) return; + // SWR retains stale data while revalidating. Do not project that stale + // snapshot back into the live store because it can resurrect a session + // that was just closed. + if ( + query.isPending || + !query.data || + query.data.revision !== readPreviewStateRevision(threadRef) + ) { + return; + } const threadIdValue = threadRef.threadId; let cancelled = false; - if (query.data.sessions.length > 0) { - for (const snapshot of query.data.sessions) applyServerSnapshot(threadRef, snapshot); + if (query.data.result.sessions.length > 0) { + for (const snapshot of query.data.result.sessions) { + applyServerSnapshot(threadRef, snapshot); + } return; } - if (query.isPending) return; // Server has no sessions — try to recover what the renderer remembers // from before the disconnect. diff --git a/apps/web/src/previewStateStore.ts b/apps/web/src/previewStateStore.ts index 75cb45db474..ab99ce63bb8 100644 --- a/apps/web/src/previewStateStore.ts +++ b/apps/web/src/previewStateStore.ts @@ -49,6 +49,16 @@ const EMPTY_THREAD_PREVIEW_STATE: ThreadPreviewState = Object.freeze({ recentlySeenUrls: [] as string[], }); +const revisionByThreadKey = new Map<string, number>(); + +const bumpPreviewStateRevision = (threadKey: string): void => { + revisionByThreadKey.set(threadKey, (revisionByThreadKey.get(threadKey) ?? 0) + 1); +}; + +export function readPreviewStateRevision(ref: ScopedThreadRef): number { + return revisionByThreadKey.get(scopedThreadKey(ref)) ?? 0; +} + export interface PreviewStateStoreState { byThreadKey: Record<string, ThreadPreviewState>; applyServerEvent: (ref: ScopedThreadRef, event: PreviewEvent) => void; @@ -120,6 +130,7 @@ export const usePreviewStateStore = create<PreviewStateStoreState>()((set) => ({ applyServerEvent: (ref, event) => set((state) => { const threadKey = scopedThreadKey(ref); + bumpPreviewStateRevision(threadKey); let nextByThread = state.byThreadKey; switch (event.type) { case "opened": @@ -177,6 +188,7 @@ export const usePreviewStateStore = create<PreviewStateStoreState>()((set) => ({ applyServerSnapshot: (ref, snapshot) => set((state) => { const threadKey = scopedThreadKey(ref); + bumpPreviewStateRevision(threadKey); const nextByThread = updateThread(state, threadKey, (current) => { if (!snapshot && current.snapshot === null) return current; if (!snapshot) { @@ -226,6 +238,7 @@ export const usePreviewStateStore = create<PreviewStateStoreState>()((set) => ({ removeSession: (ref, tabId) => set((state) => { const threadKey = scopedThreadKey(ref); + bumpPreviewStateRevision(threadKey); return { byThreadKey: updateThread(state, threadKey, (current) => removeSession(current, tabId)), }; @@ -258,6 +271,7 @@ export const usePreviewStateStore = create<PreviewStateStoreState>()((set) => ({ removeThread: (ref) => set((state) => { const threadKey = scopedThreadKey(ref); + bumpPreviewStateRevision(threadKey); if (!(threadKey in state.byThreadKey)) return state; return { byThreadKey: removeThreadKey(state.byThreadKey, threadKey) }; }), From 1adfb37ed74a7a9c3ee6eb2e6398eeeadd0de9ec Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Fri, 12 Jun 2026 19:29:22 -0700 Subject: [PATCH 18/25] Unify browser asset preview routing - Replace attachment and favicon routes with signed asset URLs - Harden workspace and attachment asset resolution - Update browser preview components and shared contracts --- apps/server/src/assets/AssetAccess.test.ts | 150 +++++ apps/server/src/assets/AssetAccess.ts | 287 +++++++++ apps/server/src/attachmentPaths.ts | 2 - apps/server/src/http.ts | 105 +--- .../Layers/ProjectFaviconResolver.test.ts | 3 +- .../project/Layers/ProjectFaviconResolver.ts | 52 +- apps/server/src/server.test.ts | 169 +---- apps/server/src/server.ts | 12 +- apps/server/src/ws.ts | 49 ++ apps/web/src/assets/assetUrls.ts | 89 +++ apps/web/src/browser/openFileInPreview.ts | 36 ++ .../src/components/ChatMarkdown.browser.tsx | 97 ++- apps/web/src/components/ChatMarkdown.tsx | 95 ++- apps/web/src/components/ChatView.browser.tsx | 16 +- apps/web/src/components/ChatView.tsx | 72 ++- .../components/KeybindingsToast.browser.tsx | 3 +- apps/web/src/components/PlanSidebar.tsx | 5 +- apps/web/src/components/ProjectFavicon.tsx | 22 +- .../src/components/chat/MessagesTimeline.tsx | 11 + .../src/components/chat/ProposedPlanCard.tsx | 18 +- .../preview/PreviewChromeRow.browser.tsx | 34 + .../components/preview/PreviewChromeRow.tsx | 32 +- .../src/components/preview/PreviewView.tsx | 12 + .../preview/previewUrlPresentation.test.ts | 45 ++ .../preview/previewUrlPresentation.ts | 27 + apps/web/src/environmentApi.ts | 3 + .../service.threadSubscriptions.test.ts | 1 + apps/web/src/store.ts | 9 - .../t3-code-connect-auth-flow-3-page.html | 592 ++++++++++++++++++ docs/cloud/t3-code-connect-auth-flow.pdf | Bin 0 -> 113502 bytes packages/client-runtime/src/wsRpcClient.ts | 7 + packages/contracts/src/assets.ts | 38 ++ packages/contracts/src/index.ts | 1 + packages/contracts/src/ipc.ts | 4 + packages/contracts/src/rpc.ts | 9 + 35 files changed, 1779 insertions(+), 328 deletions(-) create mode 100644 apps/server/src/assets/AssetAccess.test.ts create mode 100644 apps/server/src/assets/AssetAccess.ts create mode 100644 apps/web/src/assets/assetUrls.ts create mode 100644 apps/web/src/browser/openFileInPreview.ts create mode 100644 apps/web/src/components/preview/previewUrlPresentation.test.ts create mode 100644 apps/web/src/components/preview/previewUrlPresentation.ts create mode 100644 docs/cloud/t3-code-connect-auth-flow-3-page.html create mode 100644 docs/cloud/t3-code-connect-auth-flow.pdf create mode 100644 packages/contracts/src/assets.ts diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts new file mode 100644 index 00000000000..6abd8f48e61 --- /dev/null +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -0,0 +1,150 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; + +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import { ServerConfig } from "../config.ts"; +import { ProjectFaviconResolverLive } from "../project/Layers/ProjectFaviconResolver.ts"; +import { WorkspacePathsLive } from "../workspace/Layers/WorkspacePaths.ts"; +import { ASSET_ROUTE_PREFIX, issueAssetUrl, resolveAsset } from "./AssetAccess.ts"; + +const configLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-asset-access-test-", +}); +const testLayer = Layer.mergeAll( + configLayer, + WorkspacePathsLive, + ProjectFaviconResolverLive.pipe(Layer.provide(WorkspacePathsLive)), + ServerSecretStore.layer.pipe(Layer.provide(configLayer)), +).pipe(Layer.provideMerge(NodeServices.layer)); + +describe("AssetAccess", () => { + it.effect("issues workspace URLs that resolve the entry file and sibling assets", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-workspace-", + }); + const htmlPath = path.join(root, "report.html"); + const cssPath = path.join(root, "report.css"); + yield* fileSystem.writeFileString(htmlPath, '<link rel="stylesheet" href="report.css">'); + yield* fileSystem.writeFileString(cssPath, "body { color: red; }"); + yield* fileSystem.writeFileString(path.join(root, ".env"), "SECRET=value"); + + const result = yield* issueAssetUrl({ + resource: { + _tag: "workspace-file", + threadId: ThreadId.make("thread-1"), + path: htmlPath, + }, + workspaceRoot: root, + }); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separatorIndex = suffix.indexOf("/"); + const token = suffix.slice(0, separatorIndex); + + expect(yield* resolveAsset(token, "report.html")).toEqual({ + kind: "file", + path: htmlPath, + }); + expect(yield* resolveAsset(token, "report.css")).toEqual({ + kind: "file", + path: cssPath, + }); + expect(yield* resolveAsset(token, "../secret.txt")).toBeNull(); + expect(yield* resolveAsset(token, ".env")).toBeNull(); + expect(yield* resolveAsset(`${token}tampered`, "report.html")).toBeNull(); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("rejects workspace files outside the authorized root", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-root-", + }); + const outside = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-outside-", + }); + const htmlPath = path.join(outside, "report.html"); + yield* fileSystem.writeFileString(htmlPath, "<p>outside</p>"); + + const error = yield* issueAssetUrl({ + resource: { + _tag: "workspace-file", + threadId: ThreadId.make("thread-1"), + path: htmlPath, + }, + workspaceRoot: root, + }).pipe(Effect.flip); + expect(error.message).toContain("relative to the project root"); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("issues exact attachment capabilities by attachment id", () => + Effect.gen(function* () { + const config = yield* ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const attachmentId = "thread-1-00000000-0000-4000-8000-000000000001"; + const attachmentPath = path.join(config.attachmentsDir, `${attachmentId}.png`); + yield* fileSystem.makeDirectory(config.attachmentsDir, { recursive: true }); + yield* fileSystem.writeFile(attachmentPath, new Uint8Array([1, 2, 3])); + + const result = yield* issueAssetUrl({ + resource: { _tag: "attachment", attachmentId }, + }); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separatorIndex = suffix.indexOf("/"); + const token = suffix.slice(0, separatorIndex); + + expect(yield* resolveAsset(token, "ignored.png")).toEqual({ + kind: "file", + path: attachmentPath, + }); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("issues project favicon capabilities with a signed fallback", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-favicon-", + }); + const faviconPath = path.join(root, "favicon.svg"); + yield* fileSystem.writeFileString(faviconPath, "<svg />"); + + const faviconResult = yield* issueAssetUrl({ + resource: { _tag: "project-favicon", cwd: root }, + }); + const faviconSuffix = faviconResult.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const faviconSeparatorIndex = faviconSuffix.indexOf("/"); + expect( + yield* resolveAsset( + faviconSuffix.slice(0, faviconSeparatorIndex), + faviconSuffix.slice(faviconSeparatorIndex + 1), + ), + ).toEqual({ kind: "file", path: faviconPath }); + + yield* fileSystem.remove(faviconPath); + const fallbackResult = yield* issueAssetUrl({ + resource: { _tag: "project-favicon", cwd: root }, + }); + const fallbackSuffix = fallbackResult.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const fallbackSeparatorIndex = fallbackSuffix.indexOf("/"); + expect( + yield* resolveAsset( + fallbackSuffix.slice(0, fallbackSeparatorIndex), + fallbackSuffix.slice(fallbackSeparatorIndex + 1), + ), + ).toEqual({ kind: "project-favicon-fallback" }); + }).pipe(Effect.provide(testLayer)), + ); +}); diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts new file mode 100644 index 00000000000..659413f4748 --- /dev/null +++ b/apps/server/src/assets/AssetAccess.ts @@ -0,0 +1,287 @@ +import type { AssetResource } from "@t3tools/contracts"; +import { AssetAccessError } from "@t3tools/contracts"; +import * as Clock from "effect/Clock"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import { + base64UrlDecodeUtf8, + base64UrlEncode, + signPayload, + timingSafeEqualBase64Url, +} from "../auth/utils.ts"; +import { ServerSecretStore } from "../auth/ServerSecretStore.ts"; +import { resolveAttachmentPathById } from "../attachmentStore.ts"; +import { ServerConfig } from "../config.ts"; +import { ProjectFaviconResolver } from "../project/Services/ProjectFaviconResolver.ts"; +import { WorkspacePaths } from "../workspace/Services/WorkspacePaths.ts"; + +export const ASSET_ROUTE_PREFIX = "/api/assets"; +export const FALLBACK_PROJECT_FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#6b728080" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" data-fallback="project-favicon"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-8l-2-2H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2Z"/></svg>`; + +const SIGNING_SECRET_NAME = "asset-access-signing-key"; +const ASSET_TOKEN_TTL_MS = 60 * 60 * 1000; +const PREVIEWABLE_EXTENSIONS = new Set([".htm", ".html", ".pdf"]); +const PREVIEW_ASSET_EXTENSIONS = new Set([ + ...PREVIEWABLE_EXTENSIONS, + ".avif", + ".css", + ".gif", + ".ico", + ".jpeg", + ".jpg", + ".js", + ".mjs", + ".otf", + ".png", + ".svg", + ".ttf", + ".webp", + ".woff", + ".woff2", +]); + +const AssetClaimsSchema = Schema.Union([ + Schema.Struct({ + version: Schema.Literal(1), + kind: Schema.Literal("workspace-file"), + workspaceRoot: Schema.String, + baseRelativePath: Schema.String, + expiresAt: Schema.Number, + }), + Schema.Struct({ + version: Schema.Literal(1), + kind: Schema.Literal("attachment"), + attachmentId: Schema.String, + expiresAt: Schema.Number, + }), + Schema.Struct({ + version: Schema.Literal(1), + kind: Schema.Literal("project-favicon"), + workspaceRoot: Schema.String, + relativePath: Schema.NullOr(Schema.String), + expiresAt: Schema.Number, + }), +]); +type AssetClaims = typeof AssetClaimsSchema.Type; + +const AssetClaimsJson = Schema.fromJsonString(AssetClaimsSchema); +const decodeAssetClaims = Schema.decodeUnknownOption(AssetClaimsJson); +const encodeAssetClaims = Schema.encodeSync(AssetClaimsJson); + +export type ResolvedAsset = + | { readonly kind: "file"; readonly path: string } + | { readonly kind: "project-favicon-fallback" }; + +function decodeClaims(encodedPayload: string): AssetClaims | null { + try { + return Option.getOrNull(decodeAssetClaims(base64UrlDecodeUtf8(encodedPayload))); + } catch { + return null; + } +} + +function decodeRelativePath(value: string): string | null { + try { + return decodeURIComponent(value); + } catch { + return null; + } +} + +const failAccess = (message: string, cause?: unknown) => + new AssetAccessError({ message, ...(cause === undefined ? {} : { cause }) }); + +const resolveCanonicalWorkspaceFile = Effect.fn("AssetAccess.resolveCanonicalWorkspaceFile")( + function* (input: { readonly workspaceRoot: string; readonly relativePath: string }) { + const fileSystem = yield* FileSystem.FileSystem; + const workspacePaths = yield* WorkspacePaths; + const resolved = yield* workspacePaths + .resolveRelativePathWithinRoot(input) + .pipe(Effect.orElseSucceed(() => null)); + if (!resolved) return null; + + const [canonicalRoot, canonicalFile] = yield* Effect.all([ + fileSystem.realPath(input.workspaceRoot).pipe(Effect.orElseSucceed(() => null)), + fileSystem.realPath(resolved.absolutePath).pipe(Effect.orElseSucceed(() => null)), + ]); + if (!canonicalRoot || !canonicalFile) return null; + + const path = yield* Path.Path; + const relative = path.relative(canonicalRoot, canonicalFile); + if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative)) return null; + + const info = yield* fileSystem.stat(canonicalFile).pipe(Effect.orElseSucceed(() => null)); + return info?.type === "File" ? canonicalFile : null; + }, +); + +export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (input: { + readonly resource: AssetResource; + readonly workspaceRoot?: string; +}) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspacePaths = yield* WorkspacePaths; + const expiresAt = (yield* Clock.currentTimeMillis) + ASSET_TOKEN_TTL_MS; + let claims: AssetClaims; + let fileName: string; + + switch (input.resource._tag) { + case "workspace-file": { + if (!input.workspaceRoot) { + return yield* failAccess("Workspace context was not found."); + } + const workspaceRoot = yield* workspacePaths + .normalizeWorkspaceRoot(input.workspaceRoot) + .pipe(Effect.mapError((cause) => failAccess(cause.message, cause))); + const relativePath = path.isAbsolute(input.resource.path) + ? path.relative(workspaceRoot, input.resource.path) + : input.resource.path; + const resolved = yield* workspacePaths + .resolveRelativePathWithinRoot({ workspaceRoot, relativePath }) + .pipe(Effect.mapError((cause) => failAccess(cause.message, cause))); + if (!PREVIEWABLE_EXTENSIONS.has(path.extname(resolved.relativePath).toLowerCase())) { + return yield* failAccess("Only HTML and PDF files can open in the browser."); + } + const canonicalFile = yield* resolveCanonicalWorkspaceFile({ + workspaceRoot, + relativePath: resolved.relativePath, + }); + if (!canonicalFile) { + return yield* failAccess("Workspace asset was not found."); + } + claims = { + version: 1, + kind: "workspace-file", + workspaceRoot: yield* fileSystem + .realPath(workspaceRoot) + .pipe(Effect.mapError((cause) => failAccess("Failed to resolve workspace.", cause))), + baseRelativePath: path.dirname(resolved.relativePath), + expiresAt, + }; + fileName = path.basename(resolved.relativePath); + break; + } + case "attachment": { + const config = yield* ServerConfig; + const attachmentPath = resolveAttachmentPathById({ + attachmentsDir: config.attachmentsDir, + attachmentId: input.resource.attachmentId, + }); + if (!attachmentPath) { + return yield* failAccess("Attachment was not found."); + } + claims = { + version: 1, + kind: "attachment", + attachmentId: input.resource.attachmentId, + expiresAt, + }; + fileName = path.basename(attachmentPath); + break; + } + case "project-favicon": { + const workspaceRoot = yield* workspacePaths + .normalizeWorkspaceRoot(input.resource.cwd) + .pipe(Effect.mapError((cause) => failAccess(cause.message, cause))); + const faviconResolver = yield* ProjectFaviconResolver; + const faviconPath = yield* faviconResolver.resolvePath(workspaceRoot); + const relativePath = faviconPath ? path.relative(workspaceRoot, faviconPath) : null; + if ( + relativePath && + !(yield* resolveCanonicalWorkspaceFile({ workspaceRoot, relativePath })) + ) { + return yield* failAccess("Project favicon was not found."); + } + claims = { + version: 1, + kind: "project-favicon", + workspaceRoot: yield* fileSystem + .realPath(workspaceRoot) + .pipe(Effect.mapError((cause) => failAccess("Failed to resolve workspace.", cause))), + relativePath, + expiresAt, + }; + fileName = relativePath ? path.basename(relativePath) : "favicon.svg"; + break; + } + } + + const secretStore = yield* ServerSecretStore; + const signingSecret = yield* secretStore + .getOrCreateRandom(SIGNING_SECRET_NAME, 32) + .pipe(Effect.mapError((cause) => failAccess(cause.message, cause))); + const encodedPayload = base64UrlEncode(encodeAssetClaims(claims)); + const token = `${encodedPayload}.${signPayload(encodedPayload, signingSecret)}`; + return { + relativeUrl: `${ASSET_ROUTE_PREFIX}/${token}/${encodeURIComponent(fileName)}`, + expiresAt, + }; +}); + +export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( + token: string, + relativePath: string, +) { + const [encodedPayload, signature] = token.split("."); + if (!encodedPayload || !signature) return null; + + const secretStore = yield* ServerSecretStore; + const signingSecret = yield* secretStore + .getOrCreateRandom(SIGNING_SECRET_NAME, 32) + .pipe(Effect.orElseSucceed(() => null)); + if (!signingSecret) return null; + if (!timingSafeEqualBase64Url(signature, signPayload(encodedPayload, signingSecret))) return null; + + const claims = decodeClaims(encodedPayload); + if (!claims || claims.expiresAt <= (yield* Clock.currentTimeMillis)) return null; + + if (claims.kind === "attachment") { + const config = yield* ServerConfig; + const attachmentPath = resolveAttachmentPathById({ + attachmentsDir: config.attachmentsDir, + attachmentId: claims.attachmentId, + }); + if (!attachmentPath) return null; + const fileSystem = yield* FileSystem.FileSystem; + const info = yield* fileSystem.stat(attachmentPath).pipe(Effect.orElseSucceed(() => null)); + return info?.type === "File" + ? ({ kind: "file", path: attachmentPath } satisfies ResolvedAsset) + : null; + } + + if (claims.kind === "project-favicon") { + if (claims.relativePath === null) { + return { kind: "project-favicon-fallback" } satisfies ResolvedAsset; + } + const faviconPath = yield* resolveCanonicalWorkspaceFile({ + workspaceRoot: claims.workspaceRoot, + relativePath: claims.relativePath, + }); + return faviconPath ? ({ kind: "file", path: faviconPath } satisfies ResolvedAsset) : null; + } + + const decodedPath = decodeRelativePath(relativePath); + if (decodedPath === null) return null; + const path = yield* Path.Path; + const segments = decodedPath.split(/[\\/]/); + if ( + decodedPath.length === 0 || + decodedPath.includes("\0") || + segments.some((segment) => segment === "." || segment === ".." || segment.startsWith(".")) || + !PREVIEW_ASSET_EXTENSIONS.has(path.extname(decodedPath).toLowerCase()) + ) { + return null; + } + const joinedRelativePath = + claims.baseRelativePath === "." ? decodedPath : path.join(claims.baseRelativePath, decodedPath); + const workspaceFile = yield* resolveCanonicalWorkspaceFile({ + workspaceRoot: claims.workspaceRoot, + relativePath: joinedRelativePath, + }); + return workspaceFile ? ({ kind: "file", path: workspaceFile } satisfies ResolvedAsset) : null; +}); diff --git a/apps/server/src/attachmentPaths.ts b/apps/server/src/attachmentPaths.ts index 8c6999a7341..dc7db435426 100644 --- a/apps/server/src/attachmentPaths.ts +++ b/apps/server/src/attachmentPaths.ts @@ -1,8 +1,6 @@ // @effect-diagnostics nodeBuiltinImport:off import NodePath from "node:path"; -export const ATTACHMENTS_ROUTE_PREFIX = "/attachments"; - export function normalizeAttachmentRelativePath(rawRelativePath: string): string | null { const normalized = NodePath.normalize(rawRelativePath).replace(/^[/\\]+/, ""); if (normalized.length === 0 || normalized.startsWith("..") || normalized.includes("\0")) { diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 517d57168c3..5197ad34296 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -24,15 +24,13 @@ import { import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; import { OtlpTracer } from "effect/unstable/observability"; -import { - ATTACHMENTS_ROUTE_PREFIX, - normalizeAttachmentRelativePath, - resolveAttachmentRelativePath, -} from "./attachmentPaths.ts"; -import { resolveAttachmentPathById } from "./attachmentStore.ts"; import { resolveStaticDir, ServerConfig } from "./config.ts"; +import { + ASSET_ROUTE_PREFIX, + FALLBACK_PROJECT_FAVICON_SVG, + resolveAsset, +} from "./assets/AssetAccess.ts"; import { BrowserTraceCollector } from "./observability/Services/BrowserTraceCollector.ts"; -import { ProjectFaviconResolver } from "./project/Services/ProjectFaviconResolver.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import { annotateEnvironmentRequest, @@ -43,8 +41,6 @@ import { import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts"; import { browserApiCorsAllowedHeaders, browserApiCorsAllowedMethods } from "./httpCors.ts"; -const PROJECT_FAVICON_CACHE_CONTROL = "public, max-age=3600"; -const FALLBACK_PROJECT_FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#6b728080" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" data-fallback="project-favicon"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-8l-2-2H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2Z"/></svg>`; const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces"; const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]); @@ -169,107 +165,50 @@ export const otlpTracesProxyRouteLayer = HttpRouter.add( ), ); -export const attachmentsRouteLayer = HttpRouter.add( +export const assetRouteLayer = HttpRouter.add( "GET", - `${ATTACHMENTS_ROUTE_PREFIX}/*`, + `${ASSET_ROUTE_PREFIX}/*`, Effect.gen(function* () { - yield* authenticateRawRouteWithScope(AuthOrchestrationReadScope); const request = yield* HttpServerRequest.HttpServerRequest; const url = HttpServerRequest.toURL(request); if (Option.isNone(url)) { return HttpServerResponse.text("Bad Request", { status: 400 }); } - const config = yield* ServerConfig; - const rawRelativePath = url.value.pathname.slice(ATTACHMENTS_ROUTE_PREFIX.length); - const normalizedRelativePath = normalizeAttachmentRelativePath(rawRelativePath); - if (!normalizedRelativePath) { - return HttpServerResponse.text("Invalid attachment path", { status: 400 }); - } - - const isIdLookup = - !normalizedRelativePath.includes("/") && !normalizedRelativePath.includes("."); - const filePath = isIdLookup - ? resolveAttachmentPathById({ - attachmentsDir: config.attachmentsDir, - attachmentId: normalizedRelativePath, - }) - : resolveAttachmentRelativePath({ - attachmentsDir: config.attachmentsDir, - relativePath: normalizedRelativePath, - }); - if (!filePath) { - return HttpServerResponse.text(isIdLookup ? "Not Found" : "Invalid attachment path", { - status: isIdLookup ? 404 : 400, - }); - } - - const fileSystem = yield* FileSystem.FileSystem; - const fileInfo = yield* fileSystem.stat(filePath).pipe(Effect.orElseSucceed(() => null)); - if (!fileInfo || fileInfo.type !== "File") { + const suffix = url.value.pathname.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separatorIndex = suffix.indexOf("/"); + if (separatorIndex <= 0) { return HttpServerResponse.text("Not Found", { status: 404 }); } - return yield* HttpServerResponse.file(filePath, { - status: 200, - headers: { - "Cache-Control": "public, max-age=31536000, immutable", - }, - }).pipe( - Effect.orElseSucceed(() => HttpServerResponse.text("Internal Server Error", { status: 500 })), + const asset = yield* resolveAsset( + suffix.slice(0, separatorIndex), + suffix.slice(separatorIndex + 1), ); - }).pipe( - Effect.catchTags({ - EnvironmentAuthInvalidError: HttpServerRespondable.toResponse, - EnvironmentInternalError: HttpServerRespondable.toResponse, - EnvironmentScopeRequiredError: HttpServerRespondable.toResponse, - }), - ), -); - -export const projectFaviconRouteLayer = HttpRouter.add( - "GET", - "/api/project-favicon", - Effect.gen(function* () { - yield* authenticateRawRouteWithScope(AuthOrchestrationReadScope); - const request = yield* HttpServerRequest.HttpServerRequest; - const url = HttpServerRequest.toURL(request); - if (Option.isNone(url)) { - return HttpServerResponse.text("Bad Request", { status: 400 }); - } - - const projectCwd = url.value.searchParams.get("cwd"); - if (!projectCwd) { - return HttpServerResponse.text("Missing cwd parameter", { status: 400 }); + if (!asset) { + return HttpServerResponse.text("Not Found", { status: 404 }); } - - const faviconResolver = yield* ProjectFaviconResolver; - const faviconFilePath = yield* faviconResolver.resolvePath(projectCwd); - if (!faviconFilePath) { + if (asset.kind === "project-favicon-fallback") { return HttpServerResponse.text(FALLBACK_PROJECT_FAVICON_SVG, { status: 200, contentType: "image/svg+xml", headers: { - "Cache-Control": PROJECT_FAVICON_CACHE_CONTROL, + "Cache-Control": "private, max-age=3600", + "X-Content-Type-Options": "nosniff", }, }); } - return yield* HttpServerResponse.file(faviconFilePath, { + return yield* HttpServerResponse.file(asset.path, { status: 200, headers: { - "Cache-Control": PROJECT_FAVICON_CACHE_CONTROL, + "Cache-Control": "private, max-age=3600", + "X-Content-Type-Options": "nosniff", }, }).pipe( Effect.orElseSucceed(() => HttpServerResponse.text("Internal Server Error", { status: 500 })), ); - }).pipe( - Effect.catchTags({ - EnvironmentAuthInvalidError: HttpServerRespondable.toResponse, - EnvironmentInternalError: HttpServerRespondable.toResponse, - EnvironmentScopeRequiredError: HttpServerRespondable.toResponse, - }), - ), + }), ); export const staticAndDevRouteLayer = HttpRouter.add( diff --git a/apps/server/src/project/Layers/ProjectFaviconResolver.test.ts b/apps/server/src/project/Layers/ProjectFaviconResolver.test.ts index c983aca4ba7..5c0e5d95742 100644 --- a/apps/server/src/project/Layers/ProjectFaviconResolver.test.ts +++ b/apps/server/src/project/Layers/ProjectFaviconResolver.test.ts @@ -7,9 +7,10 @@ import * as Path from "effect/Path"; import { ProjectFaviconResolver } from "../Services/ProjectFaviconResolver.ts"; import { ProjectFaviconResolverLive } from "./ProjectFaviconResolver.ts"; +import { WorkspacePathsLive } from "../../workspace/Layers/WorkspacePaths.ts"; const TestLayer = Layer.empty.pipe( - Layer.provideMerge(ProjectFaviconResolverLive), + Layer.provideMerge(ProjectFaviconResolverLive.pipe(Layer.provide(WorkspacePathsLive))), Layer.provideMerge(NodeServices.layer), ); diff --git a/apps/server/src/project/Layers/ProjectFaviconResolver.ts b/apps/server/src/project/Layers/ProjectFaviconResolver.ts index cdfddd5438a..a994d1a7e8c 100644 --- a/apps/server/src/project/Layers/ProjectFaviconResolver.ts +++ b/apps/server/src/project/Layers/ProjectFaviconResolver.ts @@ -7,6 +7,7 @@ import { ProjectFaviconResolver, type ProjectFaviconResolverShape, } from "../Services/ProjectFaviconResolver.ts"; +import { WorkspacePaths } from "../../workspace/Services/WorkspacePaths.ts"; // Well-known favicon paths checked in order. const FAVICON_CANDIDATES = [ @@ -61,28 +62,32 @@ function extractIconHref(source: string): string | null { export const makeProjectFaviconResolver = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const workspacePaths = yield* WorkspacePaths; - const resolveIconHref = (projectCwd: string, href: string): string[] => { + const resolveIconHref = (href: string): string[] => { const clean = href.replace(/^\//, ""); - return [path.join(projectCwd, "public", clean), path.join(projectCwd, clean)]; - }; - - const isPathWithinProject = (projectCwd: string, candidatePath: string): boolean => { - const relative = path.relative(path.resolve(projectCwd), path.resolve(candidatePath)); - return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); + return [path.join("public", clean), clean]; }; const findExistingFile = Effect.fn("ProjectFaviconResolver.findExistingFile")(function* ( projectCwd: string, - candidates: ReadonlyArray<string>, + relativeCandidates: ReadonlyArray<string>, ): Effect.fn.Return<string | null> { - for (const candidate of candidates) { - if (!isPathWithinProject(projectCwd, candidate)) { + for (const relativePath of relativeCandidates) { + const candidate = yield* workspacePaths + .resolveRelativePathWithinRoot({ + workspaceRoot: projectCwd, + relativePath, + }) + .pipe(Effect.orElseSucceed(() => null)); + if (!candidate) { continue; } - const stats = yield* fileSystem.stat(candidate).pipe(Effect.orElseSucceed(() => null)); + const stats = yield* fileSystem + .stat(candidate.absolutePath) + .pipe(Effect.orElseSucceed(() => null)); if (stats?.type === "File") { - return candidate; + return candidate.absolutePath; } } return null; @@ -91,18 +96,31 @@ export const makeProjectFaviconResolver = Effect.gen(function* () { const resolvePath: ProjectFaviconResolverShape["resolvePath"] = Effect.fn( "ProjectFaviconResolver.resolvePath", )(function* (cwd: string): Effect.fn.Return<string | null> { + const projectCwd = yield* workspacePaths + .normalizeWorkspaceRoot(cwd) + .pipe(Effect.orElseSucceed(() => null)); + if (!projectCwd) { + return null; + } for (const candidate of FAVICON_CANDIDATES) { - const resolved = path.join(cwd, candidate); - const existing = yield* findExistingFile(cwd, [resolved]); + const existing = yield* findExistingFile(projectCwd, [candidate]); if (existing) { return existing; } } for (const sourceFile of ICON_SOURCE_FILES) { - const sourcePath = path.join(cwd, sourceFile); + const sourcePath = yield* workspacePaths + .resolveRelativePathWithinRoot({ + workspaceRoot: projectCwd, + relativePath: sourceFile, + }) + .pipe(Effect.orElseSucceed(() => null)); + if (!sourcePath) { + continue; + } const source = yield* fileSystem - .readFileString(sourcePath) + .readFileString(sourcePath.absolutePath) .pipe(Effect.orElseSucceed(() => null)); if (!source) { continue; @@ -111,7 +129,7 @@ export const makeProjectFaviconResolver = Effect.gen(function* () { if (!href) { continue; } - const existing = yield* findExistingFile(cwd, resolveIconHref(cwd, href)); + const existing = yield* findExistingFile(projectCwd, resolveIconHref(href)); if (existing) { return existing; } diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index a1cd119a7a6..40c9c7cd9a8 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -71,7 +71,6 @@ const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); import type { ServerConfigShape } from "./config.ts"; import { deriveServerPaths, ServerConfig } from "./config.ts"; import { makeRoutesLayer } from "./server.ts"; -import { resolveAttachmentRelativePath } from "./attachmentPaths.ts"; import { CheckpointDiffQuery, type CheckpointDiffQueryShape, @@ -516,7 +515,7 @@ const buildAppUnderTest = (options?: { Layer.provide(WorkspacePathsLive), Layer.provide(workspaceEntriesLayer), ), - ProjectFaviconResolverLive, + ProjectFaviconResolverLive.pipe(Layer.provide(WorkspacePathsLive)), ); const gitWorkflowLayer = GitWorkflowService.layer.pipe( Layer.provideMerge(vcsDriverRegistryLayer), @@ -1279,61 +1278,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("serves project favicon requests before the dev URL redirect", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const projectDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-router-project-favicon-", - }); - yield* fileSystem.writeFileString( - path.join(projectDir, "favicon.svg"), - "<svg>router-project-favicon</svg>", - ); - - yield* buildAppUnderTest({ - config: { devUrl: new URL("http://127.0.0.1:5173") }, - }); - - const response = yield* HttpClient.get( - `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`, - { - headers: { - cookie: yield* getAuthenticatedSessionCookieHeader(), - }, - }, - ); - - assert.equal(response.status, 200); - assert.equal(yield* response.text, "<svg>router-project-favicon</svg>"); - }).pipe(Effect.provide(NodeHttpServer.layerTest)), - ); - - it.effect("serves the fallback project favicon when no icon exists", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const projectDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-router-project-favicon-fallback-", - }); - - yield* buildAppUnderTest({ - config: { devUrl: new URL("http://127.0.0.1:5173") }, - }); - - const response = yield* HttpClient.get( - `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`, - { - headers: { - cookie: yield* getAuthenticatedSessionCookieHeader(), - }, - }, - ); - - assert.equal(response.status, 200); - assert.include(yield* response.text, 'data-fallback="project-favicon"'); - }).pipe(Effect.provide(NodeHttpServer.layerTest)), - ); - it.effect("serves the public environment descriptor without requiring auth", () => Effect.gen(function* () { yield* buildAppUnderTest(); @@ -3195,28 +3139,10 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }, }); const wsTicketBody = (yield* wsTicketResponse.json) as { readonly ticket: string }; - const faviconResponse = yield* HttpClient.get("/api/project-favicon?cwd=/tmp", { - headers: { - authorization: `Bearer ${tokenBody.access_token ?? ""}`, - }, - }); - const faviconBody = (yield* faviconResponse.json) as { - readonly _tag: string; - readonly code: string; - readonly requiredScope: string; - readonly traceId: string; - }; - assert.equal(overbroadPairingResponse.status, 403); assert.equal(overbroadPairingBody.requiredScope, "orchestration:read"); assert.equal(pairingResponse.status, 200); assert.equal(wsTicketResponse.status, 200); - assert.equal(faviconResponse.status, 403); - assert.equal(faviconBody._tag, "EnvironmentScopeRequiredError"); - assert.equal(faviconBody.code, "insufficient_scope"); - assert.equal(faviconBody.requiredScope, "orchestration:read"); - assert.equal(typeof faviconBody.traceId, "string"); - const wsUrl = `${yield* getWsServerUrl("/ws", { authenticated: false })}?wsTicket=${encodeURIComponent(wsTicketBody.ticket)}`; const rpcError = yield* Effect.flip( Effect.scoped(withWsRpcClient(wsUrl, (client) => client[WS_METHODS.serverGetConfig]({}))), @@ -3760,29 +3686,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect( - "does not accept session tokens via query parameters on authenticated HTTP routes", - () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const projectDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-router-project-favicon-query-token-", - }); - - yield* buildAppUnderTest(); - - const { cookie } = yield* bootstrapBrowserSession(); - assert.isDefined(cookie); - const sessionToken = extractSessionTokenFromSetCookie(cookie ?? ""); - - const response = yield* HttpClient.get( - `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}&token=${encodeURIComponent(sessionToken)}`, - ); - - assert.equal(response.status, 401); - }).pipe(Effect.provide(NodeHttpServer.layerTest)), - ); - it.effect("accepts websocket rpc handshake with a bootstrapped browser session cookie", () => Effect.gen(function* () { yield* buildAppUnderTest(); @@ -3853,60 +3756,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("serves attachment files from state dir", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const attachmentId = "thread-11111111-1111-4111-8111-111111111111"; - - const config = yield* buildAppUnderTest(); - const attachmentPath = resolveAttachmentRelativePath({ - attachmentsDir: config.attachmentsDir, - relativePath: `${attachmentId}.bin`, - }); - assert.isNotNull(attachmentPath, "Attachment path should be resolvable"); - - yield* fileSystem.makeDirectory(path.dirname(attachmentPath), { recursive: true }); - yield* fileSystem.writeFileString(attachmentPath, "attachment-ok"); - - const response = yield* HttpClient.get(`/attachments/${attachmentId}`, { - headers: { - cookie: yield* getAuthenticatedSessionCookieHeader(), - }, - }); - assert.equal(response.status, 200); - assert.equal(yield* response.text, "attachment-ok"); - }).pipe(Effect.provide(NodeHttpServer.layerTest)), - ); - - it.effect("serves attachment files for URL-encoded paths", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - - const config = yield* buildAppUnderTest(); - const attachmentPath = resolveAttachmentRelativePath({ - attachmentsDir: config.attachmentsDir, - relativePath: "thread%20folder/message%20folder/file%20name.png", - }); - assert.isNotNull(attachmentPath, "Attachment path should be resolvable"); - - yield* fileSystem.makeDirectory(path.dirname(attachmentPath), { recursive: true }); - yield* fileSystem.writeFileString(attachmentPath, "attachment-encoded-ok"); - - const response = yield* HttpClient.get( - "/attachments/thread%20folder/message%20folder/file%20name.png", - { - headers: { - cookie: yield* getAuthenticatedSessionCookieHeader(), - }, - }, - ); - assert.equal(response.status, 200); - assert.equal(yield* response.text, "attachment-encoded-ok"); - }).pipe(Effect.provide(NodeHttpServer.layerTest)), - ); - it.effect("proxies browser OTLP trace exports through the server", () => Effect.gen(function* () { const upstreamRequests: Array<{ @@ -4196,22 +4045,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("returns 404 for missing attachment id lookups", () => - Effect.gen(function* () { - yield* buildAppUnderTest(); - - const response = yield* HttpClient.get( - "/attachments/missing-11111111-1111-4111-8111-111111111111", - { - headers: { - cookie: yield* getAuthenticatedSessionCookieHeader(), - }, - }, - ); - assert.equal(response.status, 404); - }).pipe(Effect.provide(NodeHttpServer.layerTest)), - ); - it.effect("routes websocket rpc server.upsertKeybinding", () => Effect.gen(function* () { const rule: KeybindingRule = { diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index c565c91f1a4..3a95f906866 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -6,9 +6,8 @@ import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; import { ServerConfig } from "./config.ts"; import { - attachmentsRouteLayer, otlpTracesProxyRouteLayer, - projectFaviconRouteLayer, + assetRouteLayer, serverEnvironmentHttpApiLayer, staticAndDevRouteLayer, browserApiCorsLayer, @@ -264,6 +263,10 @@ const WorkspaceLayerLive = Layer.mergeAll( WorkspaceFileSystemLayerLive, ); +const ProjectFaviconResolverLayerLive = ProjectFaviconResolverLive.pipe( + Layer.provide(WorkspacePathsLive), +); + const AuthLayerLive = EnvironmentAuth.layer.pipe( Layer.provideMerge(PersistenceLayerLive), Layer.provide(ServerSecretStore.layer), @@ -313,7 +316,7 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( Layer.provideMerge(OpenCodeRuntimeLive), Layer.provideMerge(ServerSettingsLive), Layer.provideMerge(WorkspaceLayerLive), - Layer.provideMerge(ProjectFaviconResolverLive), + Layer.provideMerge(ProjectFaviconResolverLayerLive), Layer.provideMerge(RepositoryIdentityResolverLive), Layer.provideMerge(ServerEnvironmentLive), Layer.provideMerge(AuthLayerLive), @@ -350,9 +353,8 @@ export const makeRoutesLayer = Layer.mergeAll( Layer.provide(serverEnvironmentHttpApiLayer), Layer.provide(environmentAuthenticatedAuthLayer), ), - attachmentsRouteLayer, otlpTracesProxyRouteLayer, - projectFaviconRouteLayer, + assetRouteLayer, staticAndDevRouteLayer, websocketRpcRouteLayer, ), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index d35ab88fee6..2823923e033 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -40,6 +40,7 @@ import { type RelayClientInstallProgressEvent, OrchestrationReplayEventsError, FilesystemBrowseError, + AssetAccessError, EnvironmentAuthorizationError, ThreadId, type TerminalAttachStreamEvent, @@ -73,6 +74,7 @@ import { redactServerSettingsForClient, ServerSettingsService } from "./serverSe import { TerminalManager } from "./terminal/Services/Manager.ts"; import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; import * as PreviewManager from "./preview/Manager.ts"; +import { issueAssetUrl } from "./assets/AssetAccess.ts"; import * as PortScanner from "./preview/PortScanner.ts"; import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries.ts"; import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem.ts"; @@ -162,6 +164,7 @@ const RPC_REQUIRED_SCOPE = new Map<string, AuthEnvironmentScope>([ [WS_METHODS.projectsWriteFile, AuthOrchestrationOperateScope], [WS_METHODS.shellOpenInEditor, AuthOrchestrationOperateScope], [WS_METHODS.filesystemBrowse, AuthOrchestrationReadScope], + [WS_METHODS.assetsCreateUrl, AuthOrchestrationReadScope], [WS_METHODS.subscribeVcsStatus, AuthOrchestrationReadScope], [WS_METHODS.vcsRefreshStatus, AuthOrchestrationReadScope], [WS_METHODS.vcsPull, AuthOrchestrationOperateScope], @@ -1203,6 +1206,52 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => ), { "rpc.aggregate": "workspace" }, ), + [WS_METHODS.assetsCreateUrl]: (input) => + observeRpcEffect( + WS_METHODS.assetsCreateUrl, + Effect.gen(function* () { + if (input.resource._tag !== "workspace-file") { + return yield* issueAssetUrl({ resource: input.resource }); + } + const thread = yield* projectionSnapshotQuery + .getThreadShellById(input.resource.threadId) + .pipe( + Effect.mapError( + (cause) => + new AssetAccessError({ + message: "Failed to resolve workspace context.", + cause, + }), + ), + ); + if (Option.isNone(thread)) { + return yield* new AssetAccessError({ + message: "Workspace context was not found.", + }); + } + const project = yield* projectionSnapshotQuery + .getProjectShellById(thread.value.projectId) + .pipe( + Effect.mapError( + (cause) => + new AssetAccessError({ + message: "Failed to resolve workspace context.", + cause, + }), + ), + ); + if (Option.isNone(project)) { + return yield* new AssetAccessError({ + message: "Workspace context was not found.", + }); + } + return yield* issueAssetUrl({ + resource: input.resource, + workspaceRoot: thread.value.worktreePath ?? project.value.workspaceRoot, + }); + }), + { "rpc.aggregate": "workspace" }, + ), [WS_METHODS.subscribeVcsStatus]: (input) => observeRpcStream( WS_METHODS.subscribeVcsStatus, diff --git a/apps/web/src/assets/assetUrls.ts b/apps/web/src/assets/assetUrls.ts new file mode 100644 index 00000000000..e4fba2c5b99 --- /dev/null +++ b/apps/web/src/assets/assetUrls.ts @@ -0,0 +1,89 @@ +import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; +import { useEffect, useMemo, useState } from "react"; + +import { readEnvironmentApi } from "~/environmentApi"; +import { readEnvironmentConnection } from "~/environments/runtime"; + +const REFRESH_MARGIN_MS = 30_000; + +interface CachedAssetUrl { + readonly url: string; + readonly expiresAt: number; +} + +const assetUrlCache = new Map<string, CachedAssetUrl>(); +const assetUrlRequests = new Map<string, Promise<CachedAssetUrl>>(); + +function assetCacheKey(environmentId: EnvironmentId, resource: AssetResource): string { + return `${environmentId}:${JSON.stringify(resource)}`; +} + +export async function resolveAssetUrl( + environmentId: EnvironmentId, + resource: AssetResource, +): Promise<CachedAssetUrl> { + const key = assetCacheKey(environmentId, resource); + const cached = assetUrlCache.get(key); + if (cached && cached.expiresAt - REFRESH_MARGIN_MS > Date.now()) { + return cached; + } + + const inFlight = assetUrlRequests.get(key); + if (inFlight) { + return inFlight; + } + + const request = (async () => { + const api = readEnvironmentApi(environmentId); + const connection = readEnvironmentConnection(environmentId); + if (!api || !connection) { + throw new Error("Environment is not connected."); + } + const result = await api.assets.createUrl({ resource }); + const cachedResult = { + url: new URL(result.relativeUrl, connection.knownEnvironment.target.httpBaseUrl).toString(), + expiresAt: result.expiresAt, + }; + assetUrlCache.set(key, cachedResult); + return cachedResult; + })().finally(() => { + assetUrlRequests.delete(key); + }); + assetUrlRequests.set(key, request); + return request; +} + +export function useAssetUrl(environmentId: EnvironmentId, resource: AssetResource): string | null { + const resourceJson = JSON.stringify(resource); + const stableResource = useMemo(() => JSON.parse(resourceJson) as AssetResource, [resourceJson]); + const key = assetCacheKey(environmentId, stableResource); + const [url, setUrl] = useState<string | null>(() => assetUrlCache.get(key)?.url ?? null); + + useEffect(() => { + let cancelled = false; + let refreshTimer: ReturnType<typeof setTimeout> | undefined; + + const load = () => { + void resolveAssetUrl(environmentId, stableResource) + .then((result) => { + if (cancelled) return; + setUrl(result.url); + refreshTimer = setTimeout( + load, + Math.max(0, result.expiresAt - Date.now() - REFRESH_MARGIN_MS), + ); + }) + .catch(() => { + if (!cancelled) setUrl(null); + }); + }; + load(); + + return () => { + cancelled = true; + if (refreshTimer) clearTimeout(refreshTimer); + }; + }, [environmentId, key, stableResource]); + + return url; +} diff --git a/apps/web/src/browser/openFileInPreview.ts b/apps/web/src/browser/openFileInPreview.ts new file mode 100644 index 00000000000..6fcc8ec9954 --- /dev/null +++ b/apps/web/src/browser/openFileInPreview.ts @@ -0,0 +1,36 @@ +import type { ScopedThreadRef } from "@t3tools/contracts"; + +import { readEnvironmentApi } from "~/environmentApi"; +import { resolveAssetUrl } from "~/assets/assetUrls"; +import { isPreviewSupportedInRuntime, usePreviewStateStore } from "~/previewStateStore"; +import { useRightPanelStore } from "~/rightPanelStore"; + +export const isBrowserPreviewFile = (path: string): boolean => + /\.(?:html?|pdf)$/i.test(path.split(/[?#]/, 1)[0] ?? ""); + +export async function openUrlInPreview(threadRef: ScopedThreadRef, url: string): Promise<void> { + const api = readEnvironmentApi(threadRef.environmentId); + if (!api) { + throw new Error("Environment is not connected."); + } + + const snapshot = await api.preview.open({ threadId: threadRef.threadId, url }); + usePreviewStateStore.getState().applyServerSnapshot(threadRef, snapshot); + usePreviewStateStore.getState().rememberUrl(threadRef, url); + useRightPanelStore.getState().openBrowser(threadRef, snapshot.tabId); +} + +export async function openFileInPreview( + threadRef: ScopedThreadRef, + filePath: string, +): Promise<void> { + if (!isPreviewSupportedInRuntime()) { + throw new Error("The integrated browser is unavailable in this runtime."); + } + const asset = await resolveAssetUrl(threadRef.environmentId, { + _tag: "workspace-file", + threadId: threadRef.threadId, + path: filePath, + }); + await openUrlInPreview(threadRef, asset.url); +} diff --git a/apps/web/src/components/ChatMarkdown.browser.tsx b/apps/web/src/components/ChatMarkdown.browser.tsx index e047392c12d..a93a6d231fd 100644 --- a/apps/web/src/components/ChatMarkdown.browser.tsx +++ b/apps/web/src/components/ChatMarkdown.browser.tsx @@ -4,11 +4,24 @@ import { page } from "vite-plus/test/browser"; import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { render } from "vitest-browser-react"; -const { openInPreferredEditorMock, readLocalApiMock } = vi.hoisted(() => ({ +const { + contextMenuShowMock, + openFileInPreviewMock, + openInPreferredEditorMock, + openUrlInPreviewMock, + readLocalApiMock, +} = vi.hoisted(() => ({ + contextMenuShowMock: vi.fn(), + openFileInPreviewMock: vi.fn(async () => undefined), openInPreferredEditorMock: vi.fn(async () => "vscode"), + openUrlInPreviewMock: vi.fn(async () => undefined), readLocalApiMock: vi.fn(() => ({ + contextMenu: { show: contextMenuShowMock }, server: { getConfig: vi.fn(async () => ({ availableEditors: ["vscode"] })) }, - shell: { openInEditor: vi.fn(async () => undefined) }, + shell: { + openExternal: vi.fn(async () => undefined), + openInEditor: vi.fn(async () => undefined), + }, })), })); @@ -23,12 +36,32 @@ vi.mock("../localApi", () => ({ readLocalApi: readLocalApiMock, })); +vi.mock("../previewStateStore", async (importOriginal) => ({ + ...(await importOriginal<typeof import("../previewStateStore")>()), + isPreviewSupportedInRuntime: () => true, +})); + +vi.mock("../browser/openFileInPreview", async (importOriginal) => ({ + ...(await importOriginal<typeof import("../browser/openFileInPreview")>()), + openFileInPreview: openFileInPreviewMock, + openUrlInPreview: openUrlInPreviewMock, +})); + import ChatMarkdown from "./ChatMarkdown"; import { serializeTableElementToCsv, serializeTableElementToMarkdown } from "../markdown-clipboard"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; + +const threadRef = { + environmentId: EnvironmentId.make("environment-test"), + threadId: ThreadId.make("thread-test"), +}; describe("ChatMarkdown", () => { afterEach(() => { openInPreferredEditorMock.mockClear(); + openFileInPreviewMock.mockClear(); + openUrlInPreviewMock.mockClear(); + contextMenuShowMock.mockReset(); readLocalApiMock.mockClear(); localStorage.clear(); document.body.innerHTML = ""; @@ -155,6 +188,66 @@ describe("ChatMarkdown", () => { } }); + it("opens web links in the integrated browser from the context menu", async () => { + contextMenuShowMock.mockResolvedValue("open-in-browser"); + const screen = await render( + <ChatMarkdown + text="[OpenAI](https://openai.com/docs)" + cwd="/repo/project" + threadRef={threadRef} + />, + ); + + try { + const link = page.getByRole("link", { name: "OpenAI" }).element(); + link.dispatchEvent( + new MouseEvent("contextmenu", { + bubbles: true, + cancelable: true, + clientX: 12, + clientY: 24, + }), + ); + + await vi.waitFor(() => { + expect(contextMenuShowMock).toHaveBeenCalled(); + expect(openUrlInPreviewMock).toHaveBeenCalledWith(threadRef, "https://openai.com/docs"); + }); + } finally { + await screen.unmount(); + } + }); + + it("offers integrated browser opening for HTML file links", async () => { + contextMenuShowMock.mockResolvedValue("open-in-browser"); + const filePath = "/repo/project/report.html"; + const screen = await render( + <ChatMarkdown text="[report.html](report.html)" cwd="/repo/project" threadRef={threadRef} />, + ); + + try { + const link = page.getByRole("link", { name: "report.html" }).element(); + link.dispatchEvent( + new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 4, clientY: 8 }), + ); + + await vi.waitFor(() => { + expect(contextMenuShowMock).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + id: "open-in-browser", + label: "Open in integrated browser", + }), + ]), + { x: 4, y: 8 }, + ); + expect(openFileInPreviewMock).toHaveBeenCalledWith(threadRef, filePath); + }); + } finally { + await screen.unmount(); + } + }); + it("keeps a favicon with the leading segment of a wrapping URL", async () => { const url = "https://github.com/pingdotgg/t3code/pull/3017/changes"; const screen = await render( diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index a9b4ae5372b..3aba45249fa 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -8,7 +8,7 @@ import { Minimize2Icon, WrapTextIcon, } from "lucide-react"; -import type { ServerProviderSkill } from "@t3tools/contracts"; +import type { ScopedThreadRef, ServerProviderSkill } from "@t3tools/contracts"; import React, { Children, Suspense, @@ -62,6 +62,12 @@ import { } from "../markdown-links"; import { readLocalApi } from "../localApi"; import { cn } from "../lib/utils"; +import { isPreviewSupportedInRuntime } from "../previewStateStore"; +import { + isBrowserPreviewFile, + openFileInPreview, + openUrlInPreview, +} from "../browser/openFileInPreview"; class CodeHighlightErrorBoundary extends React.Component< { fallback: ReactNode; children: ReactNode }, @@ -87,6 +93,7 @@ class CodeHighlightErrorBoundary extends React.Component< interface ChatMarkdownProps { text: string; cwd: string | undefined; + threadRef?: ScopedThreadRef | undefined; isStreaming?: boolean; skills?: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>; className?: string; @@ -670,6 +677,7 @@ interface MarkdownFileLinkProps { label: string; copyMarkdown: string; theme: "light" | "dark"; + threadRef?: ScopedThreadRef | undefined; className?: string | undefined; } @@ -936,6 +944,54 @@ function MarkdownExternalLinkContent({ ); } +function MarkdownExternalLink({ + href, + threadRef, + children, + ...props +}: React.ComponentProps<"a"> & { + href: string; + threadRef?: ScopedThreadRef | undefined; +}) { + const handleContextMenu = useCallback( + async (event: ReactMouseEvent<HTMLAnchorElement>) => { + if (!threadRef || !isPreviewSupportedInRuntime()) return; + event.preventDefault(); + event.stopPropagation(); + + const api = readLocalApi(); + if (!api) return; + const clicked = await api.contextMenu.show( + [ + { id: "open-in-browser", label: "Open in integrated browser" }, + { id: "open-external", label: "Open in system browser" }, + ] as const, + { x: event.clientX, y: event.clientY }, + ); + if (clicked === "open-in-browser") { + void openUrlInPreview(threadRef, href).catch((error) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to open link in browser", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + }); + } else if (clicked === "open-external") { + void api.shell.openExternal(href); + } + }, + [href, threadRef], + ); + + return ( + <a {...props} href={href} onContextMenu={handleContextMenu}> + {children} + </a> + ); +} + const MarkdownFileLink = memo(function MarkdownFileLink({ href, targetPath, @@ -944,6 +1000,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ label, copyMarkdown, theme, + threadRef, className, }: MarkdownFileLinkProps) { const handleOpen = useCallback(() => { @@ -967,6 +1024,19 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ }); }, [targetPath]); + const handleOpenInBrowser = useCallback(() => { + if (!threadRef) return; + void openFileInPreview(threadRef, iconPath).catch((error) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to open file in browser", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + }); + }, [iconPath, threadRef]); + const handleCopy = useCallback((value: string, title: string) => { if (typeof window === "undefined" || !navigator.clipboard?.writeText) { toastManager.add( @@ -1007,9 +1077,14 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ const api = readLocalApi(); if (!api) return; + const canOpenInBrowser = + Boolean(threadRef) && isPreviewSupportedInRuntime() && isBrowserPreviewFile(iconPath); const clicked = await api.contextMenu.show( [ { id: "open", label: "Open in editor" }, + ...(canOpenInBrowser + ? ([{ id: "open-in-browser", label: "Open in integrated browser" }] as const) + : []), { id: "copy-relative", label: "Copy relative path" }, { id: "copy-full", label: "Copy full path" }, ] as const, @@ -1020,6 +1095,10 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ handleOpen(); return; } + if (clicked === "open-in-browser") { + handleOpenInBrowser(); + return; + } if (clicked === "copy-relative") { handleCopy(displayPath, "Relative path"); return; @@ -1028,7 +1107,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ handleCopy(targetPath, "Full path"); } }, - [displayPath, handleCopy, handleOpen, targetPath], + [displayPath, handleCopy, handleOpen, handleOpenInBrowser, iconPath, targetPath, threadRef], ); return ( @@ -1074,6 +1153,8 @@ function areMarkdownFileLinkPropsEqual( previous.label === next.label && previous.copyMarkdown === next.copyMarkdown && previous.theme === next.theme && + previous.threadRef?.environmentId === next.threadRef?.environmentId && + previous.threadRef?.threadId === next.threadRef?.threadId && previous.className === next.className ); } @@ -1081,6 +1162,7 @@ function areMarkdownFileLinkPropsEqual( function ChatMarkdown({ text, cwd, + threadRef, isStreaming = false, skills = EMPTY_MARKDOWN_SKILLS, className, @@ -1137,9 +1219,10 @@ function ChatMarkdown({ const isSameDocumentLink = href?.startsWith("#") ?? false; const onClick = props.onClick; const link = ( - <a + <MarkdownExternalLink {...props} - href={href} + href={href ?? ""} + threadRef={faviconHost && isPreviewSupportedInRuntime() ? threadRef : undefined} target={isSameDocumentLink ? undefined : "_blank"} rel={isSameDocumentLink ? undefined : "noopener noreferrer"} onClick={(event) => { @@ -1156,7 +1239,7 @@ function ChatMarkdown({ ) : ( children )} - </a> + </MarkdownExternalLink> ); if (!faviconHost || !href) { return link; @@ -1194,6 +1277,7 @@ function ChatMarkdown({ label={labelParts.join(" · ")} copyMarkdown={`[${fileLinkMeta.basename}](${normalizedHref})`} theme={resolvedTheme} + threadRef={threadRef} className={props.className} /> ); @@ -1238,6 +1322,7 @@ function ChatMarkdown({ fileLinkParentSuffixByPath, isStreaming, markdownFileLinkMetaByHref, + threadRef, resolvedTheme, skills, ], diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 249434a60d7..9e939e838ae 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -250,6 +250,18 @@ function createMockEnvironmentApi(input: { filesystem: { browse: input.browse, }, + assets: { + createUrl: vi.fn(async ({ resource }) => ({ + relativeUrl: `/api/assets/test/${encodeURIComponent( + resource._tag === "attachment" + ? resource.attachmentId + : resource._tag === "project-favicon" + ? "favicon.svg" + : (resource.path.split(/[\\/]/).at(-1) ?? "asset"), + )}`, + expiresAt: Date.now() + 60_000, + })), + }, sourceControl: {} as EnvironmentApi["sourceControl"], vcs: {} as EnvironmentApi["vcs"], git: {} as EnvironmentApi["git"], @@ -373,7 +385,6 @@ function createSnapshotForTargetUser(options: { name: `attachment-${attachmentIndex + 1}.png`, mimeType: "image/png", sizeBytes: 128, - previewUrl: `/attachments/attachment-${attachmentIndex + 1}`, })) : undefined; @@ -1153,14 +1164,13 @@ const worker = setupWorker( }); }), ...createAuthenticatedSessionHandlers(() => fixture.serverConfig.auth), - http.get("*/attachments/:attachmentId", () => + http.get("*/api/assets/test/:assetName", () => HttpResponse.text(ATTACHMENT_SVG, { headers: { "Content-Type": "image/svg+xml", }, }), ), - http.get("*/api/project-favicon", () => new HttpResponse(null, { status: 204 })), ); async function nextFrame(): Promise<void> { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index b2686f59c70..9b99cc8272f 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -37,6 +37,7 @@ import { useShallow } from "zustand/react/shallow"; import { useVcsStatus } from "~/lib/vcsStatusState"; import { usePrimaryEnvironmentId } from "../environments/primary"; import { readEnvironmentApi } from "../environmentApi"; +import { resolveAssetUrl } from "../assets/assetUrls"; import { isElectron } from "../env"; import { PreviewAutomationOwner } from "./preview/PreviewAutomationOwner"; import { readLocalApi } from "../localApi"; @@ -1915,8 +1916,64 @@ export default function ChatView(props: ChatViewProps) { }); }, []); const serverMessages = activeThread?.messages; + const [attachmentAssetUrlById, setAttachmentAssetUrlById] = useState<Record<string, string>>({}); useEffect(() => { - if (typeof Image === "undefined" || !serverMessages || serverMessages.length === 0) { + if (!serverMessages) return; + const attachmentIds = [ + ...new Set( + serverMessages.flatMap( + (message) => + message.attachments?.flatMap((attachment) => + attachment.type === "image" && !attachment.previewUrl ? [attachment.id] : [], + ) ?? [], + ), + ), + ].filter((attachmentId) => !attachmentAssetUrlById[attachmentId]); + if (attachmentIds.length === 0) return; + + let cancelled = false; + void Promise.all( + attachmentIds.map(async (attachmentId) => { + const asset = await resolveAssetUrl(environmentId, { + _tag: "attachment", + attachmentId, + }); + return [attachmentId, asset.url] as const; + }), + ) + .then((entries) => { + if (!cancelled) { + setAttachmentAssetUrlById((current) => ({ ...current, ...Object.fromEntries(entries) })); + } + }) + .catch(() => undefined); + + return () => { + cancelled = true; + }; + }, [attachmentAssetUrlById, environmentId, serverMessages]); + const serverMessagesWithAssetUrls = useMemo(() => { + if (!serverMessages || Object.keys(attachmentAssetUrlById).length === 0) { + return serverMessages; + } + return serverMessages.map((message) => { + if (!message.attachments) return message; + let changed = false; + const attachments = message.attachments.map((attachment) => { + const previewUrl = attachmentAssetUrlById[attachment.id]; + if (!previewUrl || attachment.previewUrl === previewUrl) return attachment; + changed = true; + return { ...attachment, previewUrl }; + }); + return changed ? { ...message, attachments } : message; + }); + }, [attachmentAssetUrlById, serverMessages]); + useEffect(() => { + if ( + typeof Image === "undefined" || + !serverMessagesWithAssetUrls || + serverMessagesWithAssetUrls.length === 0 + ) { return; } @@ -1929,7 +1986,7 @@ export default function ChatView(props: ChatViewProps) { continue; } - const serverMessage = serverMessages.find( + const serverMessage = serverMessagesWithAssetUrls.find( (message) => message.id === messageId && message.role === "user", ); if (!serverMessage?.attachments || serverMessage.attachments.length === 0) { @@ -1995,9 +2052,13 @@ export default function ChatView(props: ChatViewProps) { cleanup(); } }; - }, [attachmentPreviewHandoffByMessageId, clearAttachmentPreviewHandoff, serverMessages]); + }, [ + attachmentPreviewHandoffByMessageId, + clearAttachmentPreviewHandoff, + serverMessagesWithAssetUrls, + ]); const timelineMessages = useMemo(() => { - const messages = serverMessages ?? []; + const messages = serverMessagesWithAssetUrls ?? []; const serverMessagesWithPreviewHandoff = Object.keys(attachmentPreviewHandoffByMessageId).length === 0 ? messages @@ -2048,7 +2109,7 @@ export default function ChatView(props: ChatViewProps) { return serverMessagesWithPreviewHandoff; } return [...serverMessagesWithPreviewHandoff, ...pendingMessages]; - }, [serverMessages, attachmentPreviewHandoffByMessageId, optimisticUserMessages]); + }, [serverMessagesWithAssetUrls, attachmentPreviewHandoffByMessageId, optimisticUserMessages]); const timelineEntries = useMemo( () => deriveTimelineEntries(timelineMessages, activeThread?.proposedPlans ?? [], workLogEntries), @@ -4760,6 +4821,7 @@ export default function ChatView(props: ChatViewProps) { activeProposedPlan={sidebarProposedPlan} label={planSidebarLabel} environmentId={environmentId} + threadRef={activeThreadRef} markdownCwd={gitCwd ?? undefined} workspaceRoot={activeWorkspaceRoot} timestampFormat={timestampFormat} diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index b7aa6d7a645..01f501b0a1b 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -322,8 +322,7 @@ const worker = setupWorker( }); }), ...createAuthenticatedSessionHandlers(() => fixture.serverConfig.auth), - http.get("*/attachments/:attachmentId", () => new HttpResponse(null, { status: 204 })), - http.get("*/api/project-favicon", () => new HttpResponse(null, { status: 204 })), + http.get("*/api/assets/*", () => new HttpResponse(null, { status: 204 })), ); function sendServerConfigUpdatedPush(issues: ServerConfig["issues"]) { diff --git a/apps/web/src/components/PlanSidebar.tsx b/apps/web/src/components/PlanSidebar.tsx index e87361c92d8..498f1912c71 100644 --- a/apps/web/src/components/PlanSidebar.tsx +++ b/apps/web/src/components/PlanSidebar.tsx @@ -1,5 +1,5 @@ import { memo, useState, useCallback } from "react"; -import type { EnvironmentId } from "@t3tools/contracts"; +import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; import { type TimestampFormat } from "@t3tools/contracts/settings"; import { Badge } from "./ui/badge"; import { Button } from "./ui/button"; @@ -56,6 +56,7 @@ interface PlanSidebarProps { activeProposedPlan: LatestProposedPlanState | null; label?: string; environmentId: EnvironmentId; + threadRef?: ScopedThreadRef | undefined; markdownCwd: string | undefined; workspaceRoot: string | undefined; timestampFormat: TimestampFormat; @@ -68,6 +69,7 @@ const PlanSidebar = memo(function PlanSidebar({ activeProposedPlan, label = "Plan", environmentId, + threadRef, markdownCwd, workspaceRoot, timestampFormat, @@ -257,6 +259,7 @@ const PlanSidebar = memo(function PlanSidebar({ <ChatMarkdown text={displayedPlanMarkdown ?? ""} cwd={markdownCwd} + threadRef={threadRef} isStreaming={false} /> </div> diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx index ad47e01bb11..0b849bc563e 100644 --- a/apps/web/src/components/ProjectFavicon.tsx +++ b/apps/web/src/components/ProjectFavicon.tsx @@ -1,7 +1,7 @@ import type { EnvironmentId } from "@t3tools/contracts"; import { FolderIcon } from "lucide-react"; -import { useState } from "react"; -import { resolveEnvironmentHttpUrl } from "../environments/runtime"; +import { useEffect, useState } from "react"; +import { useAssetUrl } from "../assets/assetUrls"; const loadedProjectFaviconSrcs = new Set<string>(); @@ -10,20 +10,16 @@ export function ProjectFavicon(input: { cwd: string; className?: string; }) { - const src = (() => { - try { - return resolveEnvironmentHttpUrl({ - environmentId: input.environmentId, - pathname: "/api/project-favicon", - searchParams: { cwd: input.cwd }, - }); - } catch { - return null; - } - })(); + const src = useAssetUrl(input.environmentId, { + _tag: "project-favicon", + cwd: input.cwd, + }); const [status, setStatus] = useState<"loading" | "loaded" | "error">(() => src && loadedProjectFaviconSrcs.has(src) ? "loaded" : "loading", ); + useEffect(() => { + setStatus(src && loadedProjectFaviconSrcs.has(src) ? "loaded" : "loading"); + }, [src]); if (!src) { return ( diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index a56330c5923..216c7ca2a4d 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1,9 +1,11 @@ import { type EnvironmentId, type MessageId, + type ScopedThreadRef, type ServerProviderSkill, type TurnId, } from "@t3tools/contracts"; +import { parseScopedThreadKey } from "@t3tools/client-runtime"; import { createContext, Fragment, @@ -109,6 +111,7 @@ import { interface TimelineRowSharedState { timestampFormat: TimestampFormat; routeThreadKey: string; + threadRef: ScopedThreadRef | null; markdownCwd: string | undefined; resolvedTheme: "light" | "dark"; workspaceRoot: string | undefined; @@ -305,6 +308,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ () => ({ timestampFormat, routeThreadKey, + threadRef: parseScopedThreadKey(routeThreadKey), markdownCwd, resolvedTheme, workspaceRoot, @@ -562,6 +566,7 @@ function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "mess <ChatMarkdown text={messageText} cwd={ctx.markdownCwd} + threadRef={ctx.threadRef ?? undefined} isStreaming={Boolean(row.message.streaming)} skills={ctx.skills} /> @@ -625,6 +630,7 @@ function ProposedPlanTimelineRow({ <ProposedPlanCard planMarkdown={row.proposedPlan.planMarkdown} environmentId={ctx.activeThreadEnvironmentId} + threadRef={ctx.threadRef ?? undefined} cwd={ctx.markdownCwd} workspaceRoot={ctx.workspaceRoot} /> @@ -1028,6 +1034,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { skills: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>; markdownCwd: string | undefined; }) { + const ctx = use(TimelineRowCtx); const renderInlineMarkdownSegment = (text: string, key: string) => { const leadingWhitespace = /^\s+/.exec(text)?.[0] ?? ""; const textWithoutLeadingWhitespace = text.slice(leadingWhitespace.length); @@ -1044,6 +1051,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { <ChatMarkdown text={content} cwd={props.markdownCwd} + threadRef={ctx.threadRef ?? undefined} skills={props.skills} className="text-foreground" lineBreaks @@ -1065,6 +1073,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { <ChatMarkdown text={segment.text.trim()} cwd={props.markdownCwd} + threadRef={ctx.threadRef ?? undefined} skills={props.skills} className="text-foreground" lineBreaks @@ -1152,6 +1161,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { key="user-message-terminal-context-inline-text" text={props.text} cwd={props.markdownCwd} + threadRef={ctx.threadRef ?? undefined} skills={props.skills} className="text-foreground" lineBreaks @@ -1176,6 +1186,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { <ChatMarkdown text={props.text} cwd={props.markdownCwd} + threadRef={ctx.threadRef ?? undefined} skills={props.skills} className="text-foreground" lineBreaks diff --git a/apps/web/src/components/chat/ProposedPlanCard.tsx b/apps/web/src/components/chat/ProposedPlanCard.tsx index e53ee93b913..9b5c37099a1 100644 --- a/apps/web/src/components/chat/ProposedPlanCard.tsx +++ b/apps/web/src/components/chat/ProposedPlanCard.tsx @@ -1,5 +1,5 @@ import { memo, useState, useId } from "react"; -import type { EnvironmentId } from "@t3tools/contracts"; +import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; import { buildCollapsedProposedPlanPreviewMarkdown, buildProposedPlanMarkdownFilename, @@ -31,11 +31,13 @@ import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; export const ProposedPlanCard = memo(function ProposedPlanCard({ planMarkdown, environmentId, + threadRef, cwd, workspaceRoot, }: { planMarkdown: string; environmentId: EnvironmentId; + threadRef?: ScopedThreadRef | undefined; cwd: string | undefined; workspaceRoot: string | undefined; }) { @@ -163,9 +165,19 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({ <div className="mt-4"> <div className={cn("relative", canCollapse && !expanded && "max-h-104 overflow-hidden")}> {canCollapse && !expanded ? ( - <ChatMarkdown text={collapsedPreview ?? ""} cwd={cwd} isStreaming={false} /> + <ChatMarkdown + text={collapsedPreview ?? ""} + cwd={cwd} + threadRef={threadRef} + isStreaming={false} + /> ) : ( - <ChatMarkdown text={displayedPlanMarkdown} cwd={cwd} isStreaming={false} /> + <ChatMarkdown + text={displayedPlanMarkdown} + cwd={cwd} + threadRef={threadRef} + isStreaming={false} + /> )} {canCollapse && !expanded ? ( <div className="pointer-events-none absolute inset-x-0 bottom-0 h-24 bg-linear-to-t from-card/95 via-card/80 to-transparent" /> diff --git a/apps/web/src/components/preview/PreviewChromeRow.browser.tsx b/apps/web/src/components/preview/PreviewChromeRow.browser.tsx index f9a0e16e378..d92839b55fa 100644 --- a/apps/web/src/components/preview/PreviewChromeRow.browser.tsx +++ b/apps/web/src/components/preview/PreviewChromeRow.browser.tsx @@ -38,4 +38,38 @@ describe("PreviewChromeRow", () => { previouslyFocused.remove(); }); + + it("shows a friendly asset label until the URL input receives focus", async () => { + const fullUrl = "http://127.0.0.1:3773/api/assets/token/report.pdf"; + await render( + <PreviewChromeRow + {...defaultProps} + url={fullUrl} + displayUrl="Local environment · report.pdf" + />, + ); + const input = page.getByRole("textbox"); + + await expect.element(input).toHaveValue("Local environment · report.pdf"); + + await input.click(); + + await expect.element(input).toHaveValue(fullUrl); + + input.element().dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + + await expect.element(input).toHaveValue("Local environment · report.pdf"); + }); + + it("shows only the host for regular URLs until the input receives focus", async () => { + const fullUrl = "https://t3.chat/chat/18378834-f776-4507-ada7-6f79"; + await render(<PreviewChromeRow {...defaultProps} url={fullUrl} displayUrl="t3.chat" />); + const input = page.getByRole("textbox"); + + await expect.element(input).toHaveValue("t3.chat"); + + await input.click(); + + await expect.element(input).toHaveValue(fullUrl); + }); }); diff --git a/apps/web/src/components/preview/PreviewChromeRow.tsx b/apps/web/src/components/preview/PreviewChromeRow.tsx index 9064dc71d2a..1452e6c7588 100644 --- a/apps/web/src/components/preview/PreviewChromeRow.tsx +++ b/apps/web/src/components/preview/PreviewChromeRow.tsx @@ -3,7 +3,6 @@ import { ArrowRight, Camera, ExternalLink, - Globe, MousePointerClick, RotateCw, } from "lucide-react"; @@ -23,6 +22,7 @@ import { cn } from "~/lib/utils"; interface Props { url: string; + displayUrl?: string | undefined; loading: boolean; loadProgress: number; canGoBack: boolean; @@ -61,6 +61,7 @@ const NOOP = () => {}; export function PreviewChromeRow({ url, + displayUrl, loading, loadProgress, canGoBack, @@ -84,6 +85,7 @@ export function PreviewChromeRow({ }: Props) { const inputRef = useRef<HTMLInputElement | null>(null); const [draft, setDraft] = useState(url); + const [inputFocused, setInputFocused] = useState(false); // Sync the input with external URL changes, but only when the user isn't // actively typing (preserves in-progress edits during navigation events). @@ -96,7 +98,6 @@ export function PreviewChromeRow({ const node = inputRef.current; if (!node) return; node.focus(); - node.select(); }, [focusUrlNonce]); const submit = (event?: FormEvent | KeyboardEvent) => { @@ -167,14 +168,23 @@ export function PreviewChromeRow({ </Tooltip> </div> - <InputGroup className="h-7 flex-1 rounded-md"> - <InputGroupAddon align="inline-start"> - <Globe className="size-3.5 text-muted-foreground" aria-hidden /> - </InputGroupAddon> + <InputGroup className="group/address h-7 flex-1 rounded-md border-transparent bg-transparent shadow-none before:shadow-none hover:bg-muted/40 focus-within:bg-background"> <InputGroupInput ref={inputRef} - value={draft} + value={inputFocused ? draft : (displayUrl ?? draft)} + className={cn( + onOpenInBrowser && !inputFocused && "group-hover/address:pe-7 transition-[padding]", + )} onChange={(event) => setDraft(event.target.value)} + onFocus={() => { + setDraft(url); + setInputFocused(true); + queueMicrotask(() => inputRef.current?.select()); + }} + onBlur={() => { + setDraft(url); + setInputFocused(false); + }} onKeyDown={(event) => { if (event.key === "Enter") submit(event); if (event.key === "Escape") { @@ -187,10 +197,14 @@ export function PreviewChromeRow({ spellCheck={false} disabled={inputDisabled} data-preview-url-input + title={!inputFocused && displayUrl ? url : undefined} size="sm" /> - {onOpenInBrowser ? ( - <InputGroupAddon align="inline-end"> + {onOpenInBrowser && !inputFocused ? ( + <InputGroupAddon + align="inline-end" + className="pointer-events-none absolute inset-y-0 right-0 opacity-0 transition-opacity group-hover/address:pointer-events-auto group-hover/address:opacity-100" + > <Tooltip> <TooltipTrigger render={ diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index a330382e21d..c70f2d8d28f 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -14,11 +14,13 @@ import { import { ensureLocalApi } from "~/localApi"; import { selectThreadPreviewState, usePreviewStateStore } from "~/previewStateStore"; import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver"; +import { readEnvironmentConnection } from "~/environments/runtime"; import { previewBridge } from "./previewBridge"; import { subscribePreviewAction } from "./previewActionBus"; import { openPreviewSession } from "./openPreviewSession"; import { PreviewChromeRow } from "./PreviewChromeRow"; +import { formatPreviewUrl } from "./previewUrlPresentation"; import { PreviewEmptyState } from "./PreviewEmptyState"; import { PreviewMoreMenu } from "./PreviewMoreMenu"; import { PreviewUnreachable } from "./PreviewUnreachable"; @@ -87,6 +89,15 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, const showEmptyState = shouldShowPreviewEmptyState(snapshot); const controller = desktopOverlay?.controller ?? "none"; const loadProgress = useLoadingProgress(loading); + const environmentConnection = readEnvironmentConnection(threadRef.environmentId); + const displayUrl = + url && environmentConnection + ? (formatPreviewUrl({ + url, + environmentLabel: environmentConnection.knownEnvironment.label, + environmentHttpBaseUrl: environmentConnection.knownEnvironment.target.httpBaseUrl, + }) ?? undefined) + : undefined; const handleSubmitUrl = useCallback( async (next: string) => { @@ -501,6 +512,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, > <PreviewChromeRow url={url} + displayUrl={displayUrl} loading={loading} loadProgress={loadProgress} canGoBack={canGoBack} diff --git a/apps/web/src/components/preview/previewUrlPresentation.test.ts b/apps/web/src/components/preview/previewUrlPresentation.test.ts new file mode 100644 index 00000000000..c8c3a3245a2 --- /dev/null +++ b/apps/web/src/components/preview/previewUrlPresentation.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { formatPreviewUrl } from "./previewUrlPresentation"; + +describe("formatPreviewUrl", () => { + it("formats signed asset URLs with the environment label and decoded filename", () => { + expect( + formatPreviewUrl({ + url: "http://127.0.0.1:3773/api/assets/token/architecture%20brief.pdf", + environmentLabel: "Local environment", + environmentHttpBaseUrl: "http://127.0.0.1:3773", + }), + ).toBe("Local environment · architecture brief.pdf"); + }); + + it("does not alias assets from another origin", () => { + expect( + formatPreviewUrl({ + url: "https://example.com/api/assets/token/report.pdf", + environmentLabel: "Local environment", + environmentHttpBaseUrl: "http://127.0.0.1:3773", + }), + ).toBe("example.com"); + }); + + it("formats regular preview URLs as their exact host", () => { + expect( + formatPreviewUrl({ + url: "http://127.0.0.1:5173/dashboard", + environmentLabel: "Local environment", + environmentHttpBaseUrl: "http://127.0.0.1:3773", + }), + ).toBe("127.0.0.1:5173"); + }); + + it("does not compact non-http URLs", () => { + expect( + formatPreviewUrl({ + url: "file:///tmp/report.pdf", + environmentLabel: "Local environment", + environmentHttpBaseUrl: "http://127.0.0.1:3773", + }), + ).toBeNull(); + }); +}); diff --git a/apps/web/src/components/preview/previewUrlPresentation.ts b/apps/web/src/components/preview/previewUrlPresentation.ts new file mode 100644 index 00000000000..0ae3c1900aa --- /dev/null +++ b/apps/web/src/components/preview/previewUrlPresentation.ts @@ -0,0 +1,27 @@ +interface PreviewUrlPresentationInput { + readonly url: string; + readonly environmentLabel: string; + readonly environmentHttpBaseUrl: string; +} + +export function formatPreviewUrl(input: PreviewUrlPresentationInput): string | null { + try { + const url = new URL(input.url); + const environmentUrl = new URL(input.environmentHttpBaseUrl); + if (url.origin === environmentUrl.origin && url.pathname.startsWith("/api/assets/")) { + const encodedFileName = url.pathname.split("/").at(-1); + if (!encodedFileName) { + return null; + } + const fileName = decodeURIComponent(encodedFileName); + if (!fileName || fileName === "." || fileName === "..") { + return null; + } + return `${input.environmentLabel} · ${fileName}`; + } + + return url.protocol === "http:" || url.protocol === "https:" ? url.host : null; + } catch { + return null; + } +} diff --git a/apps/web/src/environmentApi.ts b/apps/web/src/environmentApi.ts index 4bb3c3654cb..49de33091a2 100644 --- a/apps/web/src/environmentApi.ts +++ b/apps/web/src/environmentApi.ts @@ -25,6 +25,9 @@ export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi { filesystem: { browse: rpcClient.filesystem.browse, }, + assets: { + createUrl: rpcClient.assets.createUrl, + }, sourceControl: { lookupRepository: rpcClient.sourceControl.lookupRepository, cloneRepository: rpcClient.sourceControl.cloneRepository, diff --git a/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts b/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts index 94292da1d96..2f223c4b1b4 100644 --- a/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts +++ b/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts @@ -127,6 +127,7 @@ vi.mock("@t3tools/client-runtime", async (importOriginal) => { filesystem: { browse: vi.fn(), }, + assets: { createUrl: vi.fn() }, sourceControl: { lookupRepository: vi.fn(), cloneRepository: vi.fn(), diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 12b621e3900..9a9b05f92f2 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -34,7 +34,6 @@ import { type ThreadTurnState, type TurnDiffSummary, } from "./types"; -import { resolveEnvironmentHttpUrl } from "./environments/runtime"; import { sanitizeThreadErrorMessage } from "./rpc/transportError"; import { getThreadFromEnvironmentState } from "./threadDerivation"; const isProviderDriverKindValue = Schema.is(ProviderDriverKind); @@ -173,10 +172,6 @@ function mapMessage(environmentId: EnvironmentId, message: OrchestrationMessage) name: attachment.name, mimeType: attachment.mimeType, sizeBytes: attachment.sizeBytes, - previewUrl: resolveEnvironmentHttpUrl({ - environmentId, - pathname: attachmentPreviewRoutePath(attachment.id), - }), })); return { @@ -1042,10 +1037,6 @@ function toLegacyProvider(providerName: string | null): ProviderDriverKind { return ProviderDriverKind.make("codex"); } -function attachmentPreviewRoutePath(attachmentId: string): string { - return `/attachments/${encodeURIComponent(attachmentId)}`; -} - function updateThreadState( state: EnvironmentState, threadId: ThreadId, diff --git a/docs/cloud/t3-code-connect-auth-flow-3-page.html b/docs/cloud/t3-code-connect-auth-flow-3-page.html new file mode 100644 index 00000000000..9fe7bd59081 --- /dev/null +++ b/docs/cloud/t3-code-connect-auth-flow-3-page.html @@ -0,0 +1,592 @@ +<!doctype html> +<html lang="en"> + <head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <title>T3 Connect Architecture Brief + + + +
+
Architecture brief · page 1 of 3
+

T3 Connect Control Plane and Managed Endpoint Flow

+

+ T3 Connect links a locally authorized T3 environment to a signed-in cloud user, provisions a + managed HTTPS/WSS endpoint, and brokers proof-bound remote connections. The environment + remains the access authority; the relay coordinates identity, endpoint lifecycle, and + notifications. +

+
+ +
+
+

Security Invariant

+

Cloud identity alone is not an environment login. Remote access requires:

+
    +
  • a Clerk-authenticated T3 Connect user;
  • +
  • an active user/environment relay link;
  • +
  • a DPoP private key held by the remote client;
  • +
  • a relay-signed mint request accepted locally; and
  • +
  • an environment token bound to the client's DPoP key.
  • +
+
+ The relay cannot mint an environment access token by itself. The local environment + issues the bootstrap credential and final access token. +
+
+
+

Trust Boundaries

+
    +
  • Clerk: user identity and OAuth/PKCE.
  • +
  • Relay Worker: links, endpoint allocations, proofs, and notifications.
  • +
  • Environment: local sessions, signing keys, and access tokens.
  • +
  • Relay client: exposes only the validated loopback HTTP server.
  • +
  • Remote client: retains the DPoP private key and environment token.
  • +
+
+ The relay remains a trusted mint broker because it holds the cloud-mint signing key. + DPoP limits credential reuse but does not neutralize compromise of that authority. +
+
+ +
+

Current Topology

+
+
+

Clients

+ Desktop web UI
Mobile app
Headless CLI

+ Clerk bearer / relay DPoP +
+
+
+

T3 Code Relay

+ Cloudflare Worker
Hyperdrive + Postgres
Tunnel/DNS APIs
APNs queue +
+
+
+

Linked Environment

+ Managed HTTPS/WSS endpoint
Relay client
Loopback server
Local secrets +
+
+
+ Steady-state HTTP and WebSocket traffic goes directly from the remote client through the + managed endpoint. The relay handles sparse health and bootstrap requests, not the active + session data path. +
+
+ +
+

Authentication Boundaries

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
BoundaryAuthenticationPurpose
Client → relay link managementClerk bearer / CLI OAuthCreate, list, or remove links.
Client → protected relay APIsRelay DPoP token + proofStatus, connect, and mobile registration.
Relay → environmentShort-lived relay-signed JWTSigned health and credential-mint requests.
Client → environmentEnvironment token + DPoPNormal HTTPS APIs and WSS ticket issuance.
Environment → relayEnvironment bearer + signed proofPublish redacted agent activity.
+
+
+ +
+ +
+
Core workflows · page 2 of 3
+

Link, Bootstrap, and Operate

+

+ Every workflow preserves local authority while making cloud coordination retry-safe and + suitable for desktop, mobile, and headless use. +

+
+ +
+
+

1. Link a Local Environment

+
+
+ AuthenticateDesktop/mobile uses a Clerk bearer. CLI uses browser PKCE and + persists desired link state. +
+
+ Prove locallyRelay issues a short-lived challenge. The local environment + validates loopback origin and signs it. +
+
+ ReconcileRelay verifies proof and capabilities, then creates or reuses the + tunnel, ingress, and CNAME. +
+
+ ActivateRelay returns endpoint config and connector token. Environment persists + config and starts the relay client. +
+
+
+ The local proof endpoint requires relay:write, rejects forwarded authority + headers, and atomically persists the environment key pair. +
+
+ +
+

2. Remote Connection Bootstrap

+
+
+ Proof keyClient creates a DPoP key and exchanges its Clerk JWT for a relay DPoP + access token. +
+
+ Mint requestClient calls connect. Relay sends an audience-bound, short-lived + signed proof to the managed endpoint. +
+
+ Local issueEnvironment validates user, scope, lifetime, nonce, and DPoP + thumbprint, then returns a signed bootstrap credential. +
+
+ Direct sessionClient redeems locally for an environment token, gets a one-time + WSS ticket, and opens the WebSocket. +
+
+
+ The relay validates the environment response signature, nonce, endpoint, and DPoP + binding before returning bootstrap data to the client. +
+
+ +
+

Managed Endpoint Lifecycle

+
    +
  1. + Reserve deterministic hostname and tunnel name for + (userId, environmentId). +
  2. +
  3. + Reuse or create the named tunnel; rewrite ingress to the validated loopback origin. +
  4. +
  5. + Reconcile CNAME records and remove duplicates; recover safely from create conflicts. +
  6. +
  7. Mark ready only after resource IDs and namespace checks are persisted.
  8. +
+
+
+

Unlink Cleanup

+
    +
  • Delete managed DNS and tunnel resources.
  • +
  • Remove the allocation and revoke the user link.
  • +
  • Revoke publication credentials after the final active link disappears.
  • +
  • Stop the local relay client and clear persisted relay configuration.
  • +
  • Retain checkpoints when teardown fails so cleanup can be retried.
  • +
+
+ +
+

3. Agent Activity Notifications

+
+
+ ProjectEnvironment creates a narrow publishable thread state and redacts/caps + failure details. +
+
+ SignEnvironment posts with its bearer credential and a per-state signed proof. +
+
+ PersistRelay checks credential, signature, scope, expiry, and nonce, then + stores current state. +
+
+ DeliverQueue workers send push notifications or Live Activity updates through + APNs. +
+
+
+
+ +
+ +
+
Controls and operations · page 3 of 3
+

Hardening and Deployment Contract

+

+ The design constrains tunnel origins, proof audiences, egress destinations, redirects, + credential exposure, and teardown behavior. +

+
+ +
+
+

SSRF and Tunnel Controls

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
RiskImplemented control
Arbitrary tunnel upstreamBoth sides accept managed origins only for loopback hosts and valid ports.
Forwarded-origin confusion + Link proof requires the exact loopback URL and rejects forwarded host/protocol + headers. +
Stored endpoint replacement + Status/connect resolve ready allocations from Postgres and require the managed DNS + namespace. +
Redirected relay egressRelay health and mint requests use manual redirect handling.
Impersonated tunnel backendRelay verifies signed environment responses, nonce, and request binding.
Stolen access token replayRelay and environment tokens require DPoP proofs and replay guards.
Connector token exposure + Relay client is spawned without a shell and receives the token in + TUNNEL_TOKEN. +
+
+ +
+

Credential Ownership

+
    +
  • Environment Ed25519 private key stays local.
  • +
  • + Cloud-mint private key stays in hosted relay config; only its public key is installed + locally. +
  • +
  • + Relay environment bearer is hashed in Postgres and stored plaintext only locally. +
  • +
  • Remote DPoP private keys and environment access tokens remain client-side.
  • +
  • WebSocket tickets are short-lived and one-time.
  • +
+
+
+

Operational Profile

+
    +
  • One named tunnel and DNS record per active user/environment allocation.
  • +
  • HTTPS request/response and WSS only; no arbitrary TCP exposure.
  • +
  • Relay traffic is limited to health and bootstrap control calls.
  • +
  • + Active sessions are interactive and WebSocket-heavy; inactive links are nearly idle. +
  • +
  • + Dedicated registrable tunnel domain and Public Suffix List entry are required before + broad untrusted use. +
  • +
+
+ +
+

Deployment and Runtime

+ + + + + + + + + + + + + + + + + + + + + +
LayerContract
Relay + Cloudflare Worker, tunnel/DNS bindings, Hyperdrive, PlanetScale Postgres, APNs + queue + DLQ, replay-row pruning cron, and Axiom OTLP traces. +
Environment + Loopback server, atomic secret store, relay-client lifecycle, signed health/mint + responses, OAuth exchange, and WebSocket tickets. +
Clients + Secure relay URL, Clerk configuration, DPoP key management, environment token + storage, and direct managed-endpoint traffic. +
+
+ +
+

Release Gate

+
+
+

Identity

+ Keep private signing keys in their owning trust domain. Validate issuer, audience, + scope, expiry, nonce, and proof-key binding. +
+
+

Network

+ Permit loopback tunnel origins only. Resolve relay egress from ready allocations and + disable redirects. +
+
+

Lifecycle

+ Make provisioning idempotent, preserve failed-cleanup checkpoints, and revoke shared + credentials only after the final link disappears. +
+
+
+ Standards basis: RFC 8252 for native-app OAuth, RFC 8693 for token exchange, and RFC + 9449 for DPoP proof of possession. +
+
+
+ +
+ + diff --git a/docs/cloud/t3-code-connect-auth-flow.pdf b/docs/cloud/t3-code-connect-auth-flow.pdf new file mode 100644 index 0000000000000000000000000000000000000000..42c604f29ce1564cdee6b1bdef626e0517d6bf44 GIT binary patch literal 113502 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 literal 0 HcmV?d00001 diff --git a/packages/client-runtime/src/wsRpcClient.ts b/packages/client-runtime/src/wsRpcClient.ts index ca01302cbe3..3e4e9f343ce 100644 --- a/packages/client-runtime/src/wsRpcClient.ts +++ b/packages/client-runtime/src/wsRpcClient.ts @@ -102,6 +102,9 @@ export interface WsRpcClient { readonly filesystem: { readonly browse: RpcUnaryMethod; }; + readonly assets: { + readonly createUrl: RpcUnaryMethod; + }; readonly sourceControl: { readonly lookupRepository: RpcUnaryMethod; readonly cloneRepository: RpcUnaryMethod; @@ -272,6 +275,10 @@ export function createWsRpcClient( filesystem: { browse: (input) => transport.request((client) => client[WS_METHODS.filesystemBrowse](input)), }, + assets: { + createUrl: (input) => + transport.request((client) => client[WS_METHODS.assetsCreateUrl](input)), + }, sourceControl: { lookupRepository: (input) => transport.request((client) => client[WS_METHODS.sourceControlLookupRepository](input)), diff --git a/packages/contracts/src/assets.ts b/packages/contracts/src/assets.ts new file mode 100644 index 00000000000..bd1ac0a53ec --- /dev/null +++ b/packages/contracts/src/assets.ts @@ -0,0 +1,38 @@ +import * as Schema from "effect/Schema"; + +import { ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; + +const ASSET_PATH_MAX_LENGTH = 1024; + +export const AssetResource = Schema.Union([ + Schema.TaggedStruct("workspace-file", { + threadId: ThreadId, + path: TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH)), + }), + Schema.TaggedStruct("attachment", { + attachmentId: TrimmedNonEmptyString.check(Schema.isMaxLength(256)), + }), + Schema.TaggedStruct("project-favicon", { + cwd: TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH)), + }), +]); +export type AssetResource = typeof AssetResource.Type; + +export const AssetCreateUrlInput = Schema.Struct({ + resource: AssetResource, +}); +export type AssetCreateUrlInput = typeof AssetCreateUrlInput.Type; + +export const AssetCreateUrlResult = Schema.Struct({ + relativeUrl: TrimmedNonEmptyString.check(Schema.isMaxLength(4096)), + expiresAt: Schema.Number, +}); +export type AssetCreateUrlResult = typeof AssetCreateUrlResult.Type; + +export class AssetAccessError extends Schema.TaggedErrorClass()( + "AssetAccessError", + { + message: TrimmedNonEmptyString, + cause: Schema.optional(Schema.Defect()), + }, +) {} diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 4ffb7642d17..43270efdec7 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -21,6 +21,7 @@ export * from "./orchestration.ts"; export * from "./editor.ts"; export * from "./project.ts"; export * from "./filesystem.ts"; +export * from "./assets.ts"; export * from "./review.ts"; export * from "./preview.ts"; export * from "./previewAutomation.ts"; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 463879e9a5e..92f669c44f0 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -20,6 +20,7 @@ import type { } from "./git.ts"; import type { ReviewDiffPreviewInput, ReviewDiffPreviewResult } from "./review.ts"; import type { FilesystemBrowseInput, FilesystemBrowseResult } from "./filesystem.ts"; +import type { AssetCreateUrlInput, AssetCreateUrlResult } from "./assets.ts"; import type { ProjectSearchEntriesInput, ProjectSearchEntriesResult, @@ -1115,6 +1116,9 @@ export interface EnvironmentApi { filesystem: { browse: (input: FilesystemBrowseInput) => Promise; }; + assets: { + createUrl: (input: AssetCreateUrlInput) => Promise; + }; sourceControl: { lookupRepository: ( input: SourceControlRepositoryLookupInput, diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 189b1bdb679..b1dc36651e3 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -13,6 +13,7 @@ import { FilesystemBrowseResult, FilesystemBrowseError, } from "./filesystem.ts"; +import { AssetAccessError, AssetCreateUrlInput, AssetCreateUrlResult } from "./assets.ts"; import { GitActionProgressEvent, VcsSwitchRefInput, @@ -148,6 +149,7 @@ export const WS_METHODS = { // Filesystem methods filesystemBrowse: "filesystem.browse", + assetsCreateUrl: "assets.createUrl", // VCS methods vcsPull: "vcs.pull", @@ -365,6 +367,12 @@ export const WsFilesystemBrowseRpc = Rpc.make(WS_METHODS.filesystemBrowse, { error: Schema.Union([FilesystemBrowseError, EnvironmentAuthorizationError]), }); +export const WsAssetsCreateUrlRpc = Rpc.make(WS_METHODS.assetsCreateUrl, { + payload: AssetCreateUrlInput, + success: AssetCreateUrlResult, + error: Schema.Union([AssetAccessError, EnvironmentAuthorizationError]), +}); + export const WsSubscribeVcsStatusRpc = Rpc.make(WS_METHODS.subscribeVcsStatus, { payload: VcsStatusInput, success: VcsStatusStreamEvent, @@ -672,6 +680,7 @@ export const WsRpcGroup = RpcGroup.make( WsProjectsWriteFileRpc, WsShellOpenInEditorRpc, WsFilesystemBrowseRpc, + WsAssetsCreateUrlRpc, WsSubscribeVcsStatusRpc, WsVcsPullRpc, WsVcsRefreshStatusRpc, From 5644b61244fc19652d2c35ccbe9e0cd553c8277e Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Fri, 12 Jun 2026 19:41:59 -0700 Subject: [PATCH 19/25] Fix preview CI test fixtures --- apps/web/src/components/ChatView.browser.tsx | 31 ++++++++++++++++--- .../service.addSavedEnvironment.test.ts | 6 ++++ .../runtime/service.savedEnvironments.test.ts | 3 ++ apps/web/src/localApi.test.ts | 19 ++++++++++++ 4 files changed, 54 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 9e939e838ae..11d1ea121d1 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -23,6 +23,7 @@ import { ServerConfig as ServerConfigSchema, } from "@t3tools/contracts"; import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime"; +import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; import { createModelCapabilities, createModelSelection } from "@t3tools/shared/model"; import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; import * as Option from "effect/Option"; @@ -2064,6 +2065,14 @@ describe("ChatView timeline estimator parity (full app)", () => { targetMessageId: "msg-user-open-empty-terminal-drawer" as MessageId, targetText: "open empty terminal drawer", }), + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + keybindings: DEFAULT_RESOLVED_KEYBINDINGS.filter( + (binding) => binding.command === "terminal.toggle", + ), + }; + }, }); try { @@ -2107,6 +2116,15 @@ describe("ChatView timeline estimator parity (full app)", () => { targetMessageId: "msg-user-open-inline-terminal-panel" as MessageId, targetText: "open inline terminal panel", }), + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + keybindings: DEFAULT_RESOLVED_KEYBINDINGS.filter( + (binding) => + binding.command === "rightPanel.toggle" || binding.command === "terminal.toggle", + ), + }; + }, }); try { @@ -2195,12 +2213,15 @@ describe("ChatView timeline estimator parity (full app)", () => { { timeout: 8_000, interval: 16 }, ); - const drawerToggle = await waitForElement( - () => - document.querySelector('button[aria-label="Toggle terminal drawer"]'), - "Unable to find terminal drawer toggle.", + window.dispatchEvent( + new KeyboardEvent("keydown", { + key: "j", + metaKey: isMacPlatform(navigator.platform), + ctrlKey: !isMacPlatform(navigator.platform), + bubbles: true, + cancelable: true, + }), ); - drawerToggle.click(); await vi.waitFor(() => { expect( diff --git a/apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts b/apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts index e7f2e60b58f..f099338ab97 100644 --- a/apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts +++ b/apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts @@ -157,6 +157,9 @@ vi.mock("@t3tools/client-runtime", async (importOriginal) => { orchestration: { subscribeThread: vi.fn(() => () => {}), }, + preview: { + subscribePorts: vi.fn(() => () => undefined), + }, })), fetchRemoteEnvironmentDescriptor: mockFetchRemoteEnvironmentDescriptor, fetchRemoteSessionState: mockFetchRemoteSessionState, @@ -654,6 +657,9 @@ describe("addSavedEnvironment", () => { terminal: { onMetadata: vi.fn(() => () => undefined), }, + preview: { + subscribePorts: vi.fn(() => () => undefined), + }, }, ensureBootstrapped: async () => undefined, reconnect: vi.fn(async () => { diff --git a/apps/web/src/environments/runtime/service.savedEnvironments.test.ts b/apps/web/src/environments/runtime/service.savedEnvironments.test.ts index e7c15ec6b32..592bc31e260 100644 --- a/apps/web/src/environments/runtime/service.savedEnvironments.test.ts +++ b/apps/web/src/environments/runtime/service.savedEnvironments.test.ts @@ -193,6 +193,9 @@ function createClient() { close: vi.fn(async () => undefined), onMetadata: vi.fn(() => () => undefined), }, + preview: { + subscribePorts: vi.fn(() => () => undefined), + }, projects: { searchEntries: vi.fn(async () => []), writeFile: vi.fn(async () => undefined), diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index e50dbd9f5f8..f81a7259c93 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -61,6 +61,25 @@ const rpcClientMock = { filesystem: { browse: vi.fn(), }, + assets: { + createUrl: vi.fn(), + }, + preview: { + open: vi.fn(), + navigate: vi.fn(), + refresh: vi.fn(), + close: vi.fn(), + list: vi.fn(), + reportStatus: vi.fn(), + automation: { + connect: vi.fn(() => () => undefined), + respond: vi.fn(), + reportOwner: vi.fn(), + clearOwner: vi.fn(), + }, + onEvent: vi.fn(() => () => undefined), + subscribePorts: vi.fn(() => () => undefined), + }, sourceControl: { lookupRepository: vi.fn(), cloneRepository: vi.fn(), From 2a52615ef16b624bd88afd33f4ccb8de22d2e68a Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Fri, 12 Jun 2026 19:46:00 -0700 Subject: [PATCH 20/25] Fix terminal browser test mock --- apps/web/src/components/ThreadTerminalDrawer.browser.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/web/src/components/ThreadTerminalDrawer.browser.tsx b/apps/web/src/components/ThreadTerminalDrawer.browser.tsx index 56482c44e8e..5db71b630c9 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.browser.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.browser.tsx @@ -119,6 +119,13 @@ vi.mock("@xterm/xterm", () => ({ })); vi.mock("~/environmentApi", () => ({ + ensureEnvironmentApi: (environmentId: string) => { + const api = readEnvironmentApiMock(environmentId); + if (!api) { + throw new Error(`Environment API not found for ${environmentId}`); + } + return api; + }, readEnvironmentApi: readEnvironmentApiMock, })); From aafb7a31fb96b2a062cfae3503f2e476930b302f Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Fri, 12 Jun 2026 19:54:08 -0700 Subject: [PATCH 21/25] Restore terminal drawer header toggle --- apps/web/src/components/ChatView.browser.tsx | 57 +++++--------------- apps/web/src/components/ChatView.tsx | 3 ++ apps/web/src/components/chat/ChatHeader.tsx | 31 ++++++++++- 3 files changed, 47 insertions(+), 44 deletions(-) diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 11d1ea121d1..81b9c74231c 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -23,7 +23,6 @@ import { ServerConfig as ServerConfigSchema, } from "@t3tools/contracts"; import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime"; -import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; import { createModelCapabilities, createModelSelection } from "@t3tools/shared/model"; import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; import * as Option from "effect/Option"; @@ -2065,26 +2064,15 @@ describe("ChatView timeline estimator parity (full app)", () => { targetMessageId: "msg-user-open-empty-terminal-drawer" as MessageId, targetText: "open empty terminal drawer", }), - configureFixture: (nextFixture) => { - nextFixture.serverConfig = { - ...nextFixture.serverConfig, - keybindings: DEFAULT_RESOLVED_KEYBINDINGS.filter( - (binding) => binding.command === "terminal.toggle", - ), - }; - }, }); try { - window.dispatchEvent( - new KeyboardEvent("keydown", { - key: "j", - metaKey: isMacPlatform(navigator.platform), - ctrlKey: !isMacPlatform(navigator.platform), - bubbles: true, - cancelable: true, - }), + const terminalToggle = await waitForElement( + () => + document.querySelector('button[aria-label="Toggle terminal drawer"]'), + "Unable to find terminal drawer toggle.", ); + terminalToggle.click(); await vi.waitFor( () => { @@ -2116,28 +2104,14 @@ describe("ChatView timeline estimator parity (full app)", () => { targetMessageId: "msg-user-open-inline-terminal-panel" as MessageId, targetText: "open inline terminal panel", }), - configureFixture: (nextFixture) => { - nextFixture.serverConfig = { - ...nextFixture.serverConfig, - keybindings: DEFAULT_RESOLVED_KEYBINDINGS.filter( - (binding) => - binding.command === "rightPanel.toggle" || binding.command === "terminal.toggle", - ), - }; - }, }); try { - window.dispatchEvent( - new KeyboardEvent("keydown", { - key: "b", - altKey: true, - metaKey: isMacPlatform(navigator.platform), - ctrlKey: !isMacPlatform(navigator.platform), - bubbles: true, - cancelable: true, - }), + const rightPanelToggle = await waitForElement( + () => document.querySelector('button[aria-label="Toggle right panel"]'), + "Unable to find right panel toggle.", ); + rightPanelToggle.click(); const addSurface = await waitForElement( () => document.querySelector('button[aria-label="Add panel surface"]'), @@ -2213,15 +2187,12 @@ describe("ChatView timeline estimator parity (full app)", () => { { timeout: 8_000, interval: 16 }, ); - window.dispatchEvent( - new KeyboardEvent("keydown", { - key: "j", - metaKey: isMacPlatform(navigator.platform), - ctrlKey: !isMacPlatform(navigator.platform), - bubbles: true, - cancelable: true, - }), + const drawerToggle = await waitForElement( + () => + document.querySelector('button[aria-label="Toggle terminal drawer"]'), + "Unable to find terminal drawer toggle.", ); + drawerToggle.click(); await vi.waitFor(() => { expect( diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 9b99cc8272f..bfeff47dec4 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -4501,6 +4501,8 @@ export default function ChatView(props: ChatViewProps) { } keybindings={keybindings} availableEditors={availableEditors} + terminalAvailable={Boolean(activeProject)} + terminalOpen={Boolean(terminalUiState.terminalOpen)} rightPanelAvailable={Boolean(activeProject)} rightPanelOpen={rightPanelOpen} gitCwd={gitCwd} @@ -4508,6 +4510,7 @@ export default function ChatView(props: ChatViewProps) { onAddProjectScript={saveProjectScript} onUpdateProjectScript={updateProjectScript} onDeleteProjectScript={deleteProjectScript} + onToggleTerminal={toggleTerminalVisibility} onToggleRightPanel={toggleRightPanel} /> diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 8421fc18464..787913e7b29 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -9,7 +9,7 @@ import { scopeThreadRef } from "@t3tools/client-runtime"; import { memo } from "react"; import GitActionsControl from "../GitActionsControl"; import { type DraftId } from "~/composerDraftStore"; -import { PanelRightIcon } from "lucide-react"; +import { PanelBottomIcon, PanelRightIcon } from "lucide-react"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import ProjectScriptsControl, { type NewProjectScriptInput } from "../ProjectScriptsControl"; import { Toggle } from "../ui/toggle"; @@ -29,6 +29,8 @@ interface ChatHeaderProps { preferredScriptId: string | null; keybindings: ResolvedKeybindingsConfig; availableEditors: ReadonlyArray; + terminalAvailable: boolean; + terminalOpen: boolean; rightPanelAvailable: boolean; rightPanelOpen: boolean; gitCwd: string | null; @@ -36,6 +38,7 @@ interface ChatHeaderProps { onAddProjectScript: (input: NewProjectScriptInput) => Promise; onUpdateProjectScript: (scriptId: string, input: NewProjectScriptInput) => Promise; onDeleteProjectScript: (scriptId: string) => Promise; + onToggleTerminal: () => void; onToggleRightPanel: () => void; } @@ -62,6 +65,8 @@ export const ChatHeader = memo(function ChatHeader({ preferredScriptId, keybindings, availableEditors, + terminalAvailable, + terminalOpen, rightPanelAvailable, rightPanelOpen, gitCwd, @@ -69,6 +74,7 @@ export const ChatHeader = memo(function ChatHeader({ onAddProjectScript, onUpdateProjectScript, onDeleteProjectScript, + onToggleTerminal, onToggleRightPanel, }: ChatHeaderProps) { const primaryEnvironmentId = usePrimaryEnvironmentId(); @@ -77,6 +83,7 @@ export const ChatHeader = memo(function ChatHeader({ activeThreadEnvironmentId, primaryEnvironmentId, }); + const terminalShortcutLabel = shortcutLabelForCommand(keybindings, "terminal.toggle"); const rightPanelShortcutLabel = shortcutLabelForCommand(keybindings, "rightPanel.toggle"); return ( @@ -123,6 +130,28 @@ export const ChatHeader = memo(function ChatHeader({ {...(draftId ? { draftId } : {})} /> )} + + + + + } + /> + + {terminalAvailable + ? `Toggle terminal drawer${terminalShortcutLabel ? ` (${terminalShortcutLabel})` : ""}` + : "Terminal drawer is unavailable"} + + Date: Fri, 12 Jun 2026 19:58:15 -0700 Subject: [PATCH 22/25] Use real preview tooltips --- apps/web/src/components/RightPanelTabs.tsx | 25 ++++--- .../components/preview/PreviewChromeRow.tsx | 68 +++++++++++-------- 2 files changed, 54 insertions(+), 39 deletions(-) diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 7c07b61d931..b4d5c9cbb45 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -7,6 +7,7 @@ import { isElectron } from "~/env"; import type { RightPanelSurface } from "~/rightPanelStore"; import { cn } from "~/lib/utils"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; import { faviconUrlForOrigin } from "~/lib/favicon"; import { PreviewPanelShell, type PreviewPanelMode } from "./preview/PreviewPanelShell"; @@ -185,15 +186,21 @@ export function RightPanelTabs(props: RightPanelTabsProps) { : "text-muted-foreground hover:bg-accent/60 hover:text-foreground", )} > - + + props.onActivate(surface)} + > + + {title} + + } + /> + {title} + + + +
+ +
0
+
+ + + diff --git a/.plans/browser-phase-0/spikes/host-preload.cjs b/.plans/browser-phase-0/spikes/host-preload.cjs new file mode 100644 index 00000000000..b47cc89c9c5 --- /dev/null +++ b/.plans/browser-phase-0/spikes/host-preload.cjs @@ -0,0 +1,51 @@ +const { contextBridge, ipcRenderer } = require("electron"); + +let recording = null; + +ipcRenderer.on("phase0:recording-frame", async (_event, dataUrl) => { + if (!recording) return; + const response = await fetch(dataUrl); + const bitmap = await createImageBitmap(await response.blob()); + recording.context.drawImage(bitmap, 0, 0, recording.canvas.width, recording.canvas.height); + bitmap.close(); +}); + +contextBridge.exposeInMainWorld("phase0", { + invokeCdp: () => ipcRenderer.invoke("phase0:cdp-evaluate"), + startRecording: ({ width, height, fps }) => { + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const context = canvas.getContext("2d"); + const stream = canvas.captureStream(fps); + const mimeTypes = [ + "video/mp4;codecs=avc1.42E01E", + "video/mp4;codecs=h264", + "video/mp4", + "video/webm;codecs=vp9", + "video/webm;codecs=vp8", + "video/webm", + ]; + const mimeType = mimeTypes.find((candidate) => MediaRecorder.isTypeSupported(candidate)); + if (!mimeType) throw new Error("No supported WebM MediaRecorder codec"); + const recorder = new MediaRecorder(stream, { mimeType, videoBitsPerSecond: 2_000_000 }); + const chunks = []; + recorder.addEventListener("dataavailable", (event) => { + if (event.data.size > 0) chunks.push(event.data); + }); + recorder.start(250); + recording = { canvas, context, recorder, chunks, mimeType }; + return { mimeType }; + }, + stopRecording: async () => { + if (!recording) throw new Error("Recording was not started"); + const activeRecording = recording; + recording = null; + await new Promise((resolve) => { + activeRecording.recorder.addEventListener("stop", resolve, { once: true }); + activeRecording.recorder.stop(); + }); + const bytes = new Uint8Array(await new Blob(activeRecording.chunks).arrayBuffer()); + return { mimeType: activeRecording.mimeType, bytes }; + }, +}); diff --git a/.plans/browser-phase-0/spikes/host.html b/.plans/browser-phase-0/spikes/host.html new file mode 100644 index 00000000000..6b22de49262 --- /dev/null +++ b/.plans/browser-phase-0/spikes/host.html @@ -0,0 +1,12 @@ + + + Phase0 Host + + + + diff --git a/.plans/browser-phase-0/spikes/run-electron-spikes.mjs b/.plans/browser-phase-0/spikes/run-electron-spikes.mjs new file mode 100644 index 00000000000..4f3d9cbade4 --- /dev/null +++ b/.plans/browser-phase-0/spikes/run-electron-spikes.mjs @@ -0,0 +1,191 @@ +import { spawn } from "node:child_process"; +import { createRequire } from "node:module"; +import { createServer } from "node:net"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { readFile, rm } from "node:fs/promises"; + +const here = dirname(fileURLToPath(import.meta.url)); +const repositoryRoot = resolve(here, "../../.."); +const requireDesktop = createRequire(join(repositoryRoot, "apps/desktop/package.json")); +const requireWeb = createRequire(join(repositoryRoot, "apps/web/package.json")); +const electronBinary = requireDesktop("electron"); +const { chromium } = requireWeb("playwright"); +const playwrightPackage = requireWeb.resolve("playwright/package.json"); +const playwrightVersion = JSON.parse(await readFile(playwrightPackage, "utf8")).version; +const playwrightCoreBundle = resolve( + dirname(playwrightPackage), + `../../../playwright-core@${playwrightVersion}/node_modules/playwright-core/lib/coreBundle.js`, +); + +async function reservePort() { + return new Promise((resolvePort, reject) => { + const server = createServer(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + server.close(() => resolvePort(port)); + }); + }); +} + +function launchHost({ mode, port, output }) { + const childEnvironment = { ...process.env }; + delete childEnvironment.ELECTRON_RUN_AS_NODE; + const command = { + electronPath: electronBinary, + args: [ + join(here, "electron-webview-bootstrap.cjs"), + `--mode=${mode}`, + ...(port ? [`--port=${port}`] : []), + ...(output ? [`--output=${output}`] : []), + `--playwright-core-bundle=${playwrightCoreBundle}`, + ], + }; + const child = spawn( + command.electronPath, + command.args, + { cwd: repositoryRoot, env: childEnvironment, stdio: ["ignore", "pipe", "pipe"] }, + ); + child.stderr.on("data", (chunk) => process.stderr.write(chunk)); + return child; +} + +async function waitForResult(child, output, timeoutMs = 20_000) { + const resultPath = join(output, "result.json"); + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + return JSON.parse(await readFile(resultPath, "utf8")); + } catch { + await new Promise((resolveDelay) => setTimeout(resolveDelay, 100)); + } + } + child.kill("SIGTERM"); + throw new Error(`Timed out waiting for ${resultPath}`); +} + +async function runAutomation(hostMode = "automation") { + const port = await reservePort(); + const output = join(repositoryRoot, ".plans/browser-phase-0/results", hostMode); + await rm(output, { recursive: true, force: true }); + const child = launchHost({ mode: hostMode, port, output }); + const hostResult = await waitForResult(child, output); + let browser; + try { + const endpoint = `http://127.0.0.1:${port}`; + let lastError; + for (let attempt = 0; attempt < 30; attempt += 1) { + try { + browser = await chromium.connectOverCDP(endpoint); + break; + } catch (error) { + lastError = error; + await new Promise((resolveDelay) => setTimeout(resolveDelay, 100)); + } + } + if (!browser) throw lastError; + const targets = await fetch(`${endpoint}/json/list`).then((response) => response.json()); + const pages = browser.contexts().flatMap((context) => context.pages()); + const pageMetadata = await Promise.all( + pages.map(async (page) => ({ url: page.url(), title: await page.title().catch(() => "") })), + ); + const guestPage = pages.find((page, index) => pageMetadata[index]?.title === "Phase0 Guest"); + if (!guestPage) { + const guestTarget = targets.find((target) => target.title === "Phase0 Guest"); + let directTargetAttachment; + if (guestTarget?.webSocketDebuggerUrl) { + try { + const directBrowser = await chromium.connectOverCDP(guestTarget.webSocketDebuggerUrl); + const directPages = directBrowser.contexts().flatMap((context) => context.pages()); + directTargetAttachment = { + success: true, + pages: await Promise.all( + directPages.map(async (page) => ({ url: page.url(), title: await page.title().catch(() => "") })), + ), + }; + await directBrowser.close(); + } catch (error) { + directTargetAttachment = { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + } + return { + hostResult, + targets: targets.map(({ id, title, type, url }) => ({ id, title, type, url })), + pageMetadata, + directTargetAttachment, + success: false, + reason: "Guest webview was not exposed as a Playwright Page", + }; + } + const button = guestPage.getByRole("button", { name: "Increment count" }); + await button.click(); + const countAfterClick = await guestPage.locator("#count").textContent(); + const input = guestPage.getByRole("textbox", { name: "Message" }); + await input.fill("semantic locator attached"); + const inputValue = await input.inputValue(); + const expectedCount = String(Number(hostResult.semanticProbe?.state?.count ?? "0") + 1); + return { + hostResult, + pageMetadata, + success: countAfterClick === expectedCount && inputValue === "semantic locator attached", + countAfterClick, + inputValue, + }; + } finally { + await browser?.close().catch(() => undefined); + child.kill("SIGTERM"); + } +} + +async function runManagedMode(mode) { + const output = join(repositoryRoot, ".plans/browser-phase-0/results", mode); + await rm(output, { recursive: true, force: true }); + const child = launchHost({ mode, output }); + return waitForResult(child, output, mode === "recording" ? 30_000 : 20_000); +} + +const requestedMode = process.argv[2] ?? "all"; +const result = {}; +if (requestedMode === "all" || requestedMode === "automation") result.automation = await runAutomation(); +if (requestedMode === "all" || requestedMode === "view-automation") { + result.viewAutomation = await runAutomation("view-automation"); +} +if (requestedMode === "all" || requestedMode === "hidden") result.hidden = await runManagedMode("hidden"); +if (requestedMode === "all" || requestedMode === "recording") result.recording = await runManagedMode("recording"); +if (requestedMode === "all" || requestedMode === "offscreen-recording") { + result.offscreenRecording = await runManagedMode("offscreen-recording"); +} +if (requestedMode === "all" || requestedMode === "covered-recording") { + result.coveredRecording = await runManagedMode("covered-recording"); +} +if (requestedMode === "all" || requestedMode === "media-recorder") { + result.mediaRecorder = await runManagedMode("media-recorder"); +} +if (requestedMode === "all" || requestedMode === "latency") result.latency = await runManagedMode("latency"); +if (requestedMode === "all" || requestedMode === "injected-runtime") { + result.injectedRuntime = await runManagedMode("injected-runtime"); +} +if (requestedMode === "all" || requestedMode === "renderer-reload") { + result.rendererReload = await runManagedMode("renderer-reload"); +} +if (requestedMode === "all" || requestedMode === "input-origin") { + result.inputOrigin = await runManagedMode("input-origin"); +} +if (requestedMode === "all" || requestedMode === "recording-endurance") { + result.recordingEndurance = await runManagedMode("recording-endurance"); +} +if (requestedMode === "all" || requestedMode === "view-hidden") { + result.viewHidden = await runManagedMode("view-hidden"); +} +if (requestedMode === "all" || requestedMode === "view-detached") { + result.viewDetached = await runManagedMode("view-detached"); +} +if (requestedMode === "all" || requestedMode === "view-recording") { + result.viewRecording = await runManagedMode("view-recording"); +} +process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); diff --git a/.plans/browser-phase-0/spikes/run-gateway-spike.mjs b/.plans/browser-phase-0/spikes/run-gateway-spike.mjs new file mode 100644 index 00000000000..ba3157cfece --- /dev/null +++ b/.plans/browser-phase-0/spikes/run-gateway-spike.mjs @@ -0,0 +1,243 @@ +import { createServer, request } from "node:http"; +import { connect as connectTcp, createServer as createTcpServer } from "node:net"; +import { createRequire } from "node:module"; +import { readdirSync } from "node:fs"; +import { join, resolve } from "node:path"; + +const repositoryRoot = resolve(import.meta.dirname, "../../.."); +const pnpmDirectory = join(repositoryRoot, "node_modules/.pnpm"); +const wsPackageDirectory = readdirSync(pnpmDirectory) + .filter((name) => name.startsWith("ws@8.")) + .sort() + .at(-1); +if (!wsPackageDirectory) throw new Error("The locked ws 8 package is not installed"); +const requireWs = createRequire(join(pnpmDirectory, wsPackageDirectory, "node_modules/ws/package.json")); +const { WebSocket, WebSocketServer } = requireWs("ws"); + +function listen(server) { + return new Promise((resolvePort, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + resolvePort(address.port); + }); + }); +} + +function listenTcp(server) { + return new Promise((resolvePort, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => resolvePort(server.address().port)); + }); +} + +function close(server) { + return new Promise((resolveClose, reject) => server.close((error) => (error ? reject(error) : resolveClose()))); +} + +function summarize(values) { + const sorted = [...values].sort((left, right) => left - right); + const percentile = (fraction) => sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * fraction))]; + return { + count: sorted.length, + medianMs: percentile(0.5), + p95Ms: percentile(0.95), + maxMs: sorted.at(-1), + meanMs: sorted.reduce((sum, value) => sum + value, 0) / sorted.length, + }; +} + +const upstreamWebSockets = new WebSocketServer({ noServer: true }); +upstreamWebSockets.on("connection", (socket) => { + socket.on("message", (message) => socket.send(`upstream:${message}`)); +}); +const upstream = createServer((incoming, response) => { + response.writeHead(200, { + "content-type": incoming.url === "/app" ? "text/html" : "application/json", + "x-upstream-host": incoming.headers.host ?? "", + }); + response.end( + incoming.url === "/app" + ? "Remote Preview

remote-loopback-ok

" + : JSON.stringify({ ok: true, url: incoming.url }), + ); +}); +upstream.on("upgrade", (incoming, socket, head) => { + upstreamWebSockets.handleUpgrade(incoming, socket, head, (webSocket) => { + upstreamWebSockets.emit("connection", webSocket, incoming); + }); +}); +const upstreamPort = await listen(upstream); + +const gatewayWebSockets = new WebSocketServer({ noServer: true }); +gatewayWebSockets.on("connection", (clientSocket, incoming) => { + const upstreamSocket = new WebSocket(`ws://127.0.0.1:${upstreamPort}${incoming.url}`); + const pending = []; + clientSocket.on("message", (message) => { + if (upstreamSocket.readyState === WebSocket.OPEN) upstreamSocket.send(message); + else pending.push(message); + }); + upstreamSocket.on("open", () => pending.splice(0).forEach((message) => upstreamSocket.send(message))); + upstreamSocket.on("message", (message) => clientSocket.send(message)); + const closeBoth = () => { + if (clientSocket.readyState < WebSocket.CLOSING) clientSocket.close(); + if (upstreamSocket.readyState < WebSocket.CLOSING) upstreamSocket.close(); + }; + clientSocket.on("close", closeBoth); + upstreamSocket.on("close", closeBoth); + upstreamSocket.on("error", closeBoth); +}); + +const gateway = createServer((incoming, response) => { + const upstreamRequest = request( + { + hostname: "127.0.0.1", + port: upstreamPort, + path: incoming.url, + method: incoming.method, + headers: { ...incoming.headers, host: `127.0.0.1:${upstreamPort}` }, + }, + (upstreamResponse) => { + response.writeHead(upstreamResponse.statusCode ?? 502, upstreamResponse.headers); + upstreamResponse.pipe(response); + }, + ); + upstreamRequest.on("error", (error) => response.destroy(error)); + incoming.pipe(upstreamRequest); +}); +gateway.on("upgrade", (incoming, socket, head) => { + gatewayWebSockets.handleUpgrade(incoming, socket, head, (webSocket) => { + gatewayWebSockets.emit("connection", webSocket, incoming); + }); +}); +const gatewayPort = await listen(gateway); + +const tunnelWebSockets = new WebSocketServer({ noServer: true }); +tunnelWebSockets.on("connection", (tunnelSocket) => { + const upstreamSocket = connectTcp({ host: "127.0.0.1", port: upstreamPort }); + const pending = []; + tunnelSocket.on("message", (message) => { + const bytes = Buffer.from(message); + if (upstreamSocket.readyState === "open") upstreamSocket.write(bytes); + else pending.push(bytes); + }); + upstreamSocket.on("connect", () => pending.splice(0).forEach((bytes) => upstreamSocket.write(bytes))); + upstreamSocket.on("data", (bytes) => { + if (tunnelSocket.readyState === WebSocket.OPEN) tunnelSocket.send(bytes, { binary: true }); + }); + const closeBoth = () => { + if (!upstreamSocket.destroyed) upstreamSocket.destroy(); + if (tunnelSocket.readyState < WebSocket.CLOSING) tunnelSocket.close(); + }; + upstreamSocket.on("close", closeBoth); + upstreamSocket.on("error", closeBoth); + tunnelSocket.on("close", closeBoth); +}); +const tunnelServer = createServer(); +tunnelServer.on("upgrade", (incoming, socket, head) => { + tunnelWebSockets.handleUpgrade(incoming, socket, head, (webSocket) => { + tunnelWebSockets.emit("connection", webSocket, incoming); + }); +}); +const tunnelPort = await listen(tunnelServer); + +const desktopLoopback = createTcpServer((browserSocket) => { + const tunnelSocket = new WebSocket(`ws://127.0.0.1:${tunnelPort}/tcp`); + const pending = []; + browserSocket.on("data", (bytes) => { + if (tunnelSocket.readyState === WebSocket.OPEN) tunnelSocket.send(bytes, { binary: true }); + else pending.push(bytes); + }); + tunnelSocket.on("open", () => pending.splice(0).forEach((bytes) => tunnelSocket.send(bytes, { binary: true }))); + tunnelSocket.on("message", (message) => browserSocket.write(Buffer.from(message))); + const closeBoth = () => { + if (!browserSocket.destroyed) browserSocket.destroy(); + if (tunnelSocket.readyState < WebSocket.CLOSING) tunnelSocket.close(); + }; + browserSocket.on("close", closeBoth); + browserSocket.on("error", closeBoth); + tunnelSocket.on("close", closeBoth); + tunnelSocket.on("error", closeBoth); +}); +const desktopLoopbackPort = await listenTcp(desktopLoopback); + +async function measureFetch(url, count) { + const durations = []; + for (let index = 0; index < count; index += 1) { + const startedAt = performance.now(); + const response = await fetch(url); + await response.arrayBuffer(); + durations.push(performance.now() - startedAt); + } + return summarize(durations); +} + +function websocketRoundTrips(url, count) { + return new Promise((resolveResult, reject) => { + const socket = new WebSocket(url); + const durations = []; + let startedAt = 0; + let completed = 0; + socket.on("open", () => { + startedAt = performance.now(); + socket.send(String(completed)); + }); + socket.on("message", (message) => { + durations.push(performance.now() - startedAt); + if (String(message) !== `upstream:${completed}`) { + reject(new Error(`Unexpected WebSocket response: ${message}`)); + return; + } + completed += 1; + if (completed === count) { + socket.close(); + resolveResult(summarize(durations)); + return; + } + startedAt = performance.now(); + socket.send(String(completed)); + }); + socket.on("error", reject); + }); +} + +try { + const appResponse = await fetch(`http://127.0.0.1:${gatewayPort}/app`); + const appBody = await appResponse.text(); + const directHttp = await measureFetch(`http://127.0.0.1:${upstreamPort}/bench`, 100); + const gatewayHttp = await measureFetch(`http://127.0.0.1:${gatewayPort}/bench`, 100); + const gatewayWebSocket = await websocketRoundTrips(`ws://127.0.0.1:${gatewayPort}/hmr`, 100); + const tunnelAppResponse = await fetch(`http://127.0.0.1:${desktopLoopbackPort}/app`); + const tunnelAppBody = await tunnelAppResponse.text(); + const rawTunnelHttp = await measureFetch(`http://127.0.0.1:${desktopLoopbackPort}/bench`, 100); + const rawTunnelWebSocket = await websocketRoundTrips( + `ws://127.0.0.1:${desktopLoopbackPort}/hmr`, + 100, + ); + process.stdout.write( + `${JSON.stringify( + { + success: appBody.includes("remote-loopback-ok"), + upstreamPort, + gatewayPort, + responseHeaders: Object.fromEntries(appResponse.headers), + directHttp, + gatewayHttp, + addedHttpMedianMs: gatewayHttp.medianMs - directHttp.medianMs, + gatewayWebSocket, + rawTcpTunnel: { + success: tunnelAppBody.includes("remote-loopback-ok"), + environmentTunnelPort: tunnelPort, + desktopLoopbackPort, + http: rawTunnelHttp, + addedHttpMedianMs: rawTunnelHttp.medianMs - directHttp.medianMs, + webSocket: rawTunnelWebSocket, + }, + }, + null, + 2, + )}\n`, + ); +} finally { + await Promise.all([close(desktopLoopback), close(tunnelServer), close(gateway), close(upstream)]); +} diff --git a/.plans/browser-phase-0/spikes/run-tunnel-security-spike.mjs b/.plans/browser-phase-0/spikes/run-tunnel-security-spike.mjs new file mode 100644 index 00000000000..2ed963fc13a --- /dev/null +++ b/.plans/browser-phase-0/spikes/run-tunnel-security-spike.mjs @@ -0,0 +1,98 @@ +import { spawn } from "node:child_process"; +import { createServer } from "node:http"; +import { createRequire } from "node:module"; +import { join, resolve } from "node:path"; + +const repositoryRoot = resolve(import.meta.dirname, "../../.."); +const requireDesktop = createRequire(join(repositoryRoot, "apps/desktop/package.json")); +const electronBinary = requireDesktop("electron"); + +function listen(server) { + return new Promise((resolvePort, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => resolvePort(server.address().port)); + }); +} + +function close(server) { + return new Promise((resolveClose, reject) => + server.close((error) => (error ? reject(error) : resolveClose())), + ); +} + +const observed = []; +const previewHandler = (request, response) => { + observed.push({ url: request.url, origin: request.headers.origin ?? null }); + if (request.url === "/absolute-redirect") { + response.writeHead(302, { location: "https://remote.example.test/callback" }); + response.end(); + return; + } + response.writeHead(200, { "content-type": "text/html" }); + response.end("Preview target

preview target

"); +}; +const previewA = createServer(previewHandler); +const previewB = createServer(previewHandler); +const malicious = createServer((_request, response) => { + response.writeHead(200, { "content-type": "text/html" }); + response.end("Untrusted page

untrusted page

"); +}); + +const [previewAPort, previewBPort, maliciousPort] = await Promise.all([ + listen(previewA), + listen(previewB), + listen(malicious), +]); +const previewAUrl = `http://127.0.0.1:${previewAPort}`; +const previewBUrl = `http://127.0.0.1:${previewBPort}`; +const maliciousUrl = `http://127.0.0.1:${maliciousPort}`; + +const childEnvironment = { ...process.env }; +delete childEnvironment.ELECTRON_RUN_AS_NODE; +const child = spawn( + electronBinary, + [join(import.meta.dirname, "tunnel-security-electron.cjs"), maliciousUrl, previewAUrl, previewBUrl], + { cwd: repositoryRoot, env: childEnvironment, stdio: ["ignore", "pipe", "pipe"] }, +); +let stdout = ""; +let stderr = ""; +child.stdout.on("data", (bytes) => { + stdout += bytes; +}); +child.stderr.on("data", (bytes) => { + stderr += bytes; +}); +const exitCode = await new Promise((resolveExit) => child.once("exit", resolveExit)); + +try { + const resultLine = stdout + .split("\n") + .find((line) => line.startsWith("PHASE05_TUNNEL ")); + if (!resultLine) throw new Error(`Electron probe did not emit a result: ${stderr || stdout}`); + const browserResult = JSON.parse(resultLine.slice("PHASE05_TUNNEL ".length)); + const redirectResponse = await fetch(`${previewAUrl}/absolute-redirect`, { redirect: "manual" }); + process.stdout.write( + `${JSON.stringify( + { + success: exitCode === 0, + browserResult, + untrustedPageCausedLoopbackRequest: observed.some((request) => request.url === "/secret"), + loopbackRequestOriginHeader: observed.find((request) => request.url === "/secret")?.origin ?? null, + observed, + originStickiness: { + sameAuthorityPreservedStorage: browserResult.originalPortValue === "sticky", + differentPortChangedOrigin: browserResult.otherPortValue === null, + }, + absoluteRedirect: { + status: redirectResponse.status, + location: redirectResponse.headers.get("location"), + escapedLoopbackAuthority: redirectResponse.headers.get("location")?.startsWith("https://") ?? false, + }, + }, + null, + 2, + )}\n`, + ); +} finally { + await Promise.all([close(previewA), close(previewB), close(malicious)]); +} diff --git a/.plans/browser-phase-0/spikes/tunnel-security-electron.cjs b/.plans/browser-phase-0/spikes/tunnel-security-electron.cjs new file mode 100644 index 00000000000..9b0565fa842 --- /dev/null +++ b/.plans/browser-phase-0/spikes/tunnel-security-electron.cjs @@ -0,0 +1,16 @@ +const { app, BrowserWindow } = require('electron'); +const [maliciousUrl, previewA, previewB] = process.argv.slice(2); +app.whenReady().then(async () => { + const window = new BrowserWindow({ show: false, webPreferences: { contextIsolation: true, nodeIntegration: false } }); + await window.loadURL(maliciousUrl); + const maliciousFetch = await window.webContents.executeJavaScript(`fetch(${JSON.stringify(previewA + '/secret')}, { mode: 'no-cors' }).then(() => 'sent').catch(error => String(error))`); + await new Promise(resolve => setTimeout(resolve, 150)); + await window.loadURL(previewA + '/storage'); + await window.webContents.executeJavaScript(`localStorage.setItem('phase05', 'sticky')`); + await window.loadURL(previewB + '/storage'); + const otherPortValue = await window.webContents.executeJavaScript(`localStorage.getItem('phase05')`); + await window.loadURL(previewA + '/storage'); + const originalPortValue = await window.webContents.executeJavaScript(`localStorage.getItem('phase05')`); + process.stdout.write(`PHASE05_TUNNEL ${JSON.stringify({ maliciousFetch, otherPortValue, originalPortValue })}\n`); + app.quit(); +}).catch(error => { console.error(error); app.exit(1); }); diff --git a/.plans/collaborative-browser-architecture.html b/.plans/collaborative-browser-architecture.html new file mode 100644 index 00000000000..870fc69b8e1 --- /dev/null +++ b/.plans/collaborative-browser-architecture.html @@ -0,0 +1,1981 @@ + + + + + + T3 Collaborative Browser Architecture + + + +
+ + +
T3 / Browser Architecture2026.06.12
+ +
+
+
+
One real browser / multiple actors / any environment
+

A browser that is shared state, not a preview.

+

+ T3 owns a route-durable Chromium page that users and agents manipulate together. The page + survives panel changes—not renderer or window death—accepts semantic programmatic control, records evidence, and + reaches development services whether they are local, SSH-hosted, private-networked, + or connected through T3 Connect. +

+
+
+
+
Core invariant
+
One session. One page state.
+
+ Human interaction, agent automation, recording, DevTools, console, and network + inspection all target the same guest WebContents. +
+
+
+
Selected browser host
+
Route-durable renderer-hosted <webview>
+
+ App-level lifetime; desktop-main control; explicit lost state after renderer reload, crash, or window close. +
+
+
+
Remote model
+
Environment stream gateway
+
+ A stable control endpoint carries authorized streams to arbitrary environment-local + ports. Preview ports do not become public endpoints. +
+
+
+
+ +
+
+
01 / SYSTEM
+
+

End-to-end ownership map

+

+ The internal browser API is session-oriented and transport-neutral. MCP, skills, + CLI tools, and future adapters are clients of the control service—not alternate + browser implementations. +

+
+
+
+
+
+ Authority flows toward the real page; state flows back through revisions and events. + The renderer may relay transport, but it does not decide whether a browser exists or whether automation is permitted. +
+
+
+
+
+
Clients
+

Agent + Human

+

MCP, skill client, CLI, direct UI input, DevTools, recordings.

+
+
+
Environment server
+

Browser Control Service

+

Authorization, sessions, routing, command queues, control leases, target resolution.

+
+
+
Desktop transport
+

Typed Relay + IPC

+

Environment connection enters renderer, then typed IPC reaches desktop main.

+
+
+
Desktop host
+

Electron Browser Host

+

Persistent CDP, webview registry, presentation bounds, recording, guest security.

+
+
+
Shared state
+

Guest WebContents

+

The actual Chromium page both human and agent observe and manipulate.

+
+
+
+
+ +
+
+
02 / LAYERS
+
+

Six boundaries, one browser product

+

+ Each layer has a narrow authority. This prevents React visibility, provider quirks, + network reachability, and artifact storage from leaking into the browser core. +

+
+
+
+
+
L1 / ENTRY
+

Agent Adapters

+

Provider-neutral ways to ask for browser work.

+
    +
  • MCP toolkit
  • +
  • Browser skill + Node helper
  • +
  • CLI / agent-browser compatibility
  • +
  • Internal TypeScript test client
  • +
+
+
+
L2 / CONTROL
+

Browser Control Service

+

The internal API and policy boundary.

+
    +
  • Invocation authorization
  • +
  • Per-tab command serialization
  • +
  • Cancellation + deadlines
  • +
  • Control leases + interruption
  • +
+
+
+
L3 / STATE
+

Session Registry

+

Authoritative logical lifecycle and recovery state.

+
    +
  • Stable session + tab IDs
  • +
  • Host assignment
  • +
  • Lifecycle revisions
  • +
  • Recording + artifact references
  • +
+
+
+
L4 / HOST
+

Electron Browser Host

+

Owns the relationship between durable DOM webviews and desktop main.

+
    +
  • App-level mounted webviews
  • +
  • Visible/offscreen/covered parking
  • +
  • Guest WebContents validation
  • +
  • Persistent debugger connections
  • +
+
+
+
L5 / REACH
+

Target + Tunnel Service

+

Resolves environment-relative targets into browser-reachable loopback URLs.

+
    +
  • Direct URL classification
  • +
  • Environment-port grants
  • +
  • Raw TCP stream tunnels
  • +
  • SSH, relay, LAN, Tailscale transport
  • +
+
+
+
L6 / EVIDENCE
+

Artifact Service

+

Stores durable evidence without blocking control/event traffic.

+
    +
  • H.264 MP4 recordings
  • +
  • Action timeline + screenshots
  • +
  • Console, exceptions, network
  • +
  • Downloads, HAR, traces
  • +
+
+
+
+ +
+
+
03 / MODEL
+
+

Session, tab, view, and controller are separate

+

+ The old preview model conflates “panel mounted” with “browser exists.” The new model + gives each concern its own state and lifecycle. +

+
+
+
+
+

BrowserSession

+

Logical browser workspace bound to an environment, thread, host, storage partition, and artifact history.

+
    +
  • Outlives panel and route mounts
  • +
  • Owns active tab selection
  • +
  • Tracks recovery + host availability
  • +
  • May be visible, hidden, detached, suspended, or closed
  • +
+
+
+

BrowserTab

+

One real Chromium page with origin, control, navigation, revision, and recording state.

+
    +
  • Created by human, agent, or system
  • +
  • Can be adopted and handed off
  • +
  • Has one host and one guest WebContents
  • +
  • Element references expire with document revision
  • +
+
+
+
// contracts remain schema-only +BrowserSession { + id, environmentId, threadId, hostId, activeTabId, + lifecycle: creating | ready | suspended | recovering | closed, + visibility: visible | hidden | detached, + controller: human | agent | none, + partitionId, recordingId, revision +} + +BrowserTab { + id, sessionId, webContentsId, origin, + state: opening | ready | closing | closed, + presentation: visible | parked-idle | parked-recording, + url, title, documentRevision, controller +}
+
+ +
+
+
04 / HOST
+
+

The tab manager owns durable webviews

+

+ Keep the existing real Electron webview, but lift it out of `PreviewPanel` and thread + route lifecycle. A window-level host maintains browser elements for every live tab. +

+
+
+
+
+
HOST / 01
+
Create tab
+
Session registry assigns tab ID and partition. App-level host mounts one webview and waits for `dom-ready`.
+
+
+
HOST / 02
+
Register guest
+
Renderer reports `webContentsId`; desktop main verifies type=`webview` and correct host window before accepting it.
+
+
+
HOST / 03
+
Attach control
+
Desktop main creates the persistent CDP connection, navigation listeners, console/network buffers, and recording controller.
+
+
+
HOST / 04
+
Present surface
+
Panel reports bounds. Host places the same webview at visible bounds or an internal parking surface without recreating the page.
+
+
+
HOST / 05
+
Explicit close
+
Only session/tab lifecycle commands close the guest. React unmounts, route changes, and panel closure never imply browser destruction.
+
+
+
+
+ Selected +

Renderer-hosted <webview>

+

Preserves the existing composited browser surface, overlays, clipping, capture behavior, and user interaction model.

+
+
+ Rejected for now +

WebContentsView

+

Playwright-visible and main-owned, but hidden/detached capture failed and native view stacking complicates collaboration UI.

+
+
+
+ +
+
+
05 / LIFE
+
+

Presentation state is not process state

+

+ Tabs remain live while hidden. Resource policy can explicitly suspend inactive tabs, + but never silently evicts a controlled or recording session. +

+
+
+
+
Creatingwebview mounting
+
Readyvisible or parked
+
Suspendedexplicit reload required
+
Recoveringhost reconnect
+
Closedterminal
+
+
+
+

Idle hidden tab

+

Park offscreen at the preserved viewport size. Timers, network, CDP, and semantic automation continue.

+
    +
  • Lowest practical compositor cost
  • +
  • No continuous recording frames
  • +
  • Snapshot/capture available when surface remains valid
  • +
+
+
+

Hidden recording tab

+

Move to a covered full-size parking surface inside the window. The user sees app chrome, not the browser, while Chromium keeps painting frames.

+
    +
  • CDP screencast remains active
  • +
  • Recording does not force panel open
  • +
  • Same page and viewport are preserved
  • +
+
+
+
+ +
+
+
06 / AUTO
+
+

Playwright semantics without Playwright ownership

+

+ Direct Playwright CDP attachment does not expose an Electron guest target as a + `Page`. The selected adapter uses persistent CDP plus a versioned injected utility + runtime to provide semantic, agent-friendly browser actions. +

+
+
+
+
+
+ Agent surface + `browser.open`, semantic locate, click, type, drag, wait, assert, record, console, network, tabs. +
+
+ Control service + Capability checks, control lease, command ID, deadline, cancellation, page revision, result evidence. +
+
+ Semantic adapter + Version-pinned Playwright injected runtime, locator re-resolution, actionability, shadow DOM, and per-frame routing. +
+
+ Persistent CDP + Accessibility, DOM, Runtime, Input, Page, Network, Log; one serialized connection per tab. +
+
+ Guest page + The same live WebContents displayed to the user. No synchronized second browser. +
+
+ +
+
+
!
+
+ Raw CDP is a privileged developer escape hatch. + Do not expose an unauthenticated process-wide Chromium debugging port in production. Normal agents use the Browser Control Service. +
+
+
+ +
+
+
07 / PROOF
+
+

Record the work, not the browser UX

+

+ Video and structured traces are review artifacts. The interactive experience remains + the real browser; frames are sampled only for evidence generation. +

+
+
+
+
+
+
Capture
+

CDP Screencast

+

Guest-only compositor frames. Sample to a bounded target frame rate.

+
+
+
Worker surface
+

Recording Canvas

+

Isolated renderer draws frames and optional agent/human cursor overlays.

+
+
+
Encode
+

MediaRecorder

+

H.264 MP4, initial 12 fps, no external ffmpeg requirement.

+
+
+
Persist
+

Artifact Service

+

Video, timeline, screenshots, logs, network, downloads, traces.

+
+
+
+
+
+

Structured action timeline

+
    +
  • Actor, action, input summary, status
  • +
  • Before/after document revisions
  • +
  • Recording timestamp and screenshot refs
  • +
  • Failure, interruption, or timeout reason
  • +
+
+
+

Diagnostic evidence

+
    +
  • Console and uncaught exceptions
  • +
  • Failed requests and response summaries
  • +
  • Optional HAR / deeper trace mode
  • +
  • Secret redaction before persistence
  • +
+
+
+
+ +
+
+
08 / SHARE
+
+

Human interruption is a feature

+

+ The browser is collaborative, not locked. Explicit control leases make conflicting + input predictable while allowing observation and immediate human takeover. +

+
+
+
+
+
LEASE / READ
+
Observe freely
+
Snapshots, URL, title, console, network, and action state can continue while another actor controls input.
+
+
+
LEASE / WRITE
+
Serialize mutations
+
Clicks, typing, navigation, drag, and uploads run through the per-tab command queue with one active controller.
+
+
+
LEASE / BREAK
+
Human interrupts
+
Pointer or keyboard input cancels the conflicting agent operation and returns a typed interruption result.
+
+
+
LEASE / HAND
+
Adopt or hand off
+
Tabs carry human/agent origin and can be intentionally adopted, finalized, retained, or returned to the user.
+
+
+
+ +
+
+
09 / REACH
+
+

Connection and reachability matrix

+

+ The browser should accept environment-relative targets. Resolution decides whether + the browser loads directly or through a private loopback tunnel. +

+
+
+
+ + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
EnvironmentTarget exampleBrowser pathTransportProduct behavior
Same machine`localhost:5173`Direct local URLDirectNo tunnel. Preserve normal localhost browser semantics.
LAN / Tailscale`mac-mini.local:5173`Direct private-network URL when policy allowsPrivate networkPrefer direct reachability; fall back to environment stream if unavailable.
SSH environmentremote `127.0.0.1:5173`desktop loopback listener → preview stream → SSH-forwarded T3 serverT3 stream over SSHDefault path. Native `ssh -L` may be a later optimization.
T3 Connect relayenvironment `127.0.0.1:3000`desktop loopback listener → preview stream → existing environment hostnameT3 stream over relayOne stable public control endpoint; preview ports remain private logical streams.
Docker / WSL / VMenvironment-local serviceenvironment-port target → environment stream gatewayT3 streamAgent never guesses host IPs. Environment resolves its own loopback target.
Public app sharingshare preview with external persondedicated published endpointSeparate productNot the browser-preview tunnel. Requires explicit publication and separate access policy.
+
+
+ +
+
+
10 / TUNNEL
+
+

One environment endpoint, many private streams

+

+ Do not provision a public Cloudflare route for every dev port. The existing T3 server + endpoint carries authorized preview streams to environment-local TCP targets. +

+
+
+
+
+
Browser side
+
Guest webview
`127.0.0.1:49101`
+
Desktop loopback
TCP listener
+
Authenticated
preview stream
+
Environment server
stream endpoint
+
+
+
Environment side
+
Grant validation
+
Target policy
loopback + port
+
TCP dial
`127.0.0.1:5173`
+
Vite / Next / app
HTTP + WebSocket
+
+
+
+
+
+ Raw TCP preserves browser behavior. + Root-relative assets, redirects, cookies, streaming responses, HMR upgrades, and application WebSockets pass without response rewriting. +
+
+
+
+

Initial stream model

+
    +
  • One WebSocket per accepted browser TCP connection
  • +
  • Dedicated from RPC/event traffic
  • +
  • Backpressure, timeout, cancellation, half-close
  • +
  • Easy to implement and diagnose
  • +
+
+
+

Scale-up model

+
    +
  • Multiplex streams per browser session
  • +
  • Per-stream flow-control windows
  • +
  • Bounded queues and concurrency
  • +
  • Only after profiling real asset-heavy apps
  • +
+
+
+
+ +
+
+
11 / ROUTES
+
+

SSH and T3 Connect carry the same preview protocol

+

+ Reachability transport changes; browser control does not. This keeps lifecycle, + grants, diagnostics, recording, and agent behavior consistent across environments. +

+
+
+
+
+
SSH
+
Browser loopback
+
Preview stream
+
Existing `ssh -L`
to T3 server
+
Remote target port
+
+
+
T3 Connect
+
Browser loopback
+
Preview stream
+
One managed
Cloudflare endpoint
+
Environment target port
+
+
+
LAN / Tailnet
+
Browser target
+
Direct URL or stream
+
Private network
+
Environment target port
+
+
+
+
+ Default +

Stream through T3 server

+

One protocol across SSH, relay, and direct environments. Reuses authentication, grants, diagnostics, and lifecycle.

+
+
+ Later optimization +

Native SSH port forward

+

Generalize the existing `ssh -L local:127.0.0.1:remote` manager only if direct forwarding materially improves performance or compatibility.

+
+
+
+
!
+
+ Public port publishing is not preview reachability. + “Share port 5173 with the internet” requires explicit publication, separate access control, and possibly additional managed ingress. It should not happen automatically when an agent opens a browser. +
+
+
+ +
+
+
12 / TRUST
+
+

Capability, site, target, and artifact boundaries

+

Browser access is powerful. Authorization should be explicit but not turn every click into a dialog.

+
+
+
+
+

Invocation scope

+

`observe`, `interact`, `navigate`, `evaluate`, `record`, `network-inspect`, and privileged `raw-cdp` capabilities.

+
+
+

Browser-attributed tunnel ingress

+

A loopback port is not authorization. Use a stable authority, identify the requesting guest, and scope the remote grant to one session and target.

+
+
+

Site policy

+

Origins, external navigation, auth pages, clipboard, uploads, downloads, permissions, evaluation, and raw CDP.

+
+
+

Artifact policy

+

Retention, size bounds, redaction of cookies/auth headers, encrypted storage where required, explicit sharing.

+
+
+
+
×
+
+ Normal tool input never supplies environment or thread identity. + Identity comes from the authenticated provider/session scope. Browser and tunnel commands cannot cross-control another thread by argument substitution. +
+
+
+
!
+
+ Phase 0.5 proved unrelated browser content can cause loopback requests. + Port secrecy and `Origin` checks are insufficient. Stable origins, browser attribution, bounded multiplexing, and explicit redirect/HTTPS policy gate the remote preview implementation. +
+
+
+ +
+
+
13 / BUILD
+
+

Implementation sequence

+

Build the durable host first; everything else becomes simpler once panel visibility no longer owns browser lifetime.

+
+
+
+
+
Phase 0 / complete
+

Prove decisions

+

Semantic CDP, hidden lifecycle, H.264 recording, raw TCP preview streams, routing latency, and host comparison.

+
+
+
Phase 0.5 / complete
+

Close production risks

+

Renderer-death blast radius, Playwright injected runtime, 1600×1200 recording endurance at about 11 fps, loopback threat model, origin stability, and input interruption.

+
+
+
Phase 1
+

Durable session core

+

Session/tab/window contracts, app-level webview host, host-loss UX, annotations, source attribution, discovered servers, guest hardening, and parking states.

+
+
+
Phase 2
+

Browser control service

+

Persistent CDP, Playwright injected runtime, OOPIF routing, locator re-resolution, console/network buffers, interruption, and tab APIs.

+
+
+
Phase 3
+

Remote reachability

+

Environment-port targets, stable authorities, browser-attributed ingress, multiplexed TCP streams, redirect/HTTPS policy, SSH, and relay integration.

+
+
+
Phase 4
+

Evidence pipeline

+

One-recording-per-window MediaRecorder worker, action timeline, artifact upload, thread viewer, retention, redaction, and soak gates.

+
+
+
Phase 5
+

Collaboration + unattended hosts

+

Tab adoption/handoff, agent cursor, pause/revoke, optional environment-hosted Chromium for CI as a separate session type.

+
+
+
+ +
+
+
14 / LOCKED
+
+

Pinned architecture decisions

+

These are the working defaults until implementation evidence requires an explicit ADR replacement.

+
+
+
+
+ ADR 001 + 006 +

Route-durable renderer-hosted webview

+

Keep the real guest webview in a window-level host. Renderer reload, crash, and window close are explicit live-page loss, not transparent recovery.

+
+
+ ADR 002 + 007 +

Playwright-injected semantic adapter

+

Use persistent CDP plus the version-pinned Playwright injected runtime behind T3 contracts. Locators re-resolve at action time.

+
+
+ ADR 003 + 008 +

Provisionally bounded recording

+

Capture guest frames and encode H.264 MP4 in Chromium. Start with one active recording per window and require soak/concurrency gates.

+
+
+ ADR 004 + 009 +

Attributed, stable preview tunnel

+

Keep raw TCP remotely, but require browser-attributed local ingress, stable authorities, bounded multiplexing, and redirect/HTTPS policy.

+
+
+ ADR 005 +

Keep renderer transport relay initially

+

IPC overhead is negligible. Remove renderer lifecycle authority before adding another desktop-main network connection.

+
+
+ ADR 010 +

Native human interruption

+

CDP keyboard dispatch did not emit `before-input-event` in the spike. Start with native user-input interruption and retain platform regression fixtures.

+
+
+
+
+
+ Feasibility proof is not production proof. + Open gates remain: browser-attributed tunnel ingress, OOPIF routing, long-duration and multi-tab recording, platform input behavior, and recovery fidelity after host loss. Explicit non-goals still include browser-process survival, live migration, a synchronized second browser, and image-stream browser UX. +
+
+
+ +
+ Source plans: `collaborative-browser-runtime-next-iteration.md`, `browser-phase-0/*`, and `browser-phase-0-5/*`. + This HTML document is the consolidated visual architecture reference. +
+
+
+ + + + diff --git a/.plans/collaborative-browser-runtime-next-iteration.md b/.plans/collaborative-browser-runtime-next-iteration.md new file mode 100644 index 00000000000..2649ac5d012 --- /dev/null +++ b/.plans/collaborative-browser-runtime-next-iteration.md @@ -0,0 +1,1015 @@ +# Collaborative Browser Runtime — Next Iteration + +## Purpose + +Turn the preview browser into a route-durable, programmable collaboration surface shared by users and agents. + +The browser is not an image preview and automation is not a secondary mode. The product should own a real browser session that: + +- remains alive independently of whether its panel is visible, while explicitly reporting host-renderer or window loss +- can be driven programmatically with reliable, semantic browser primitives +- can be inspected and manipulated by the user at any time +- records actions, video, console, network, and page state as reviewable evidence +- works when the development environment is remote +- can support multiple agent providers without provider-specific browser implementations + +The Codex in-app browser is a strong implementation reference for shared browser ownership, hidden tab retention, browser-client ergonomics, and human/agent handoff. It is not the target product boundary. T3 should preserve its provider-neutral and remote-capable architecture and improve on Codex where those requirements demand a different shape. + +This plan supersedes assumptions in: + +- `.plans/visible-preview-browser-automation-via-cdp-mcp.md` +- `.plans/shared-http-mcp-server-with-preview-automation-revised.md` + +Those plans remain useful implementation history, but their React-owned lifecycle, visibility-gated routing, fixed MCP command surface, and direct-only remote URL assumptions should not constrain this iteration. + +The Phase 0.5 review and follow-up spikes are authoritative amendments to Phase 0: + +- `.plans/browser-phase-0-5/findings.md` +- `.plans/browser-phase-0-5/006-renderer-failure-boundary.md` +- `.plans/browser-phase-0-5/007-playwright-injected-runtime.md` +- `.plans/browser-phase-0-5/008-recording-endurance.md` +- `.plans/browser-phase-0-5/009-loopback-threat-model.md` +- `.plans/browser-phase-0-5/010-human-input-interruption.md` + +## Product Principles + +### 1. The browser session is the product primitive + +The primary object is a durable logical `BrowserSession`, not a React panel, webview component, MCP request, or screenshot. The logical record can survive host loss; the live Chromium page state cannot. + +The panel is one view onto the session. Agent automation, recording, DevTools, snapshots, and user interaction all target the same session. + +### 2. Human and agent use the same page + +When an agent clicks, types, scrolls, navigates, or changes page state, the user must see that state when the browser is visible. When the user interacts, the agent's next observation must include the resulting state. + +Do not run a hidden Playwright browser beside an unrelated visible preview and attempt to synchronize them. + +### 3. Visibility is not lifecycle + +Closing or switching away from the browser panel must not destroy the browser session. Visibility, focus, control ownership, process lifetime, and recording state are separate concepts. + +The selected Electron `` is route-durable, not process-durable. A host-renderer reload, renderer crash, owning-window close, or application restart destroys the guest and must transition the logical tab to an explicit lost state. + +### 4. Prefer existing browser automation ecosystems + +Do not grow a large bespoke click/type/wait/selector framework when Playwright, CDP, and accessibility-based tooling already solve those problems. + +T3 should provide session discovery, authorization, routing, collaboration state, remote access, and artifact storage. Automation adapters should translate established browser APIs onto the T3-owned session. + +### 5. Remote environments are normal + +A browser attached to a remote environment must be able to open services bound to that environment's loopback interface without requiring the user to manually expose a LAN address. + +Remote access cannot be an afterthought or a warning-only UX. + +### 6. Evidence is first-class + +An agent must be able to start and stop a recording, capture screenshots, inspect console and network activity, and produce a machine-readable action trace. Evidence should be attached to the thread and usable by both the agent and user. + +Video recording is an artifact and debugging tool. It is not the mechanism used to render the interactive browser UI. + +## Current Architecture and Constraints + +The current browser implementation already has the most important property: the human-visible Electron `` and agent automation target the same guest `WebContents`. + +Current path: + +```text +agent + -> shared HTTP MCP tool + -> environment server PreviewAutomationBroker + -> WebSocket request to renderer owner + -> renderer PreviewAutomationOwner + -> Electron IPC + -> PreviewViewManager + -> guest webview WebContents through CDP +``` + +Current strengths: + +- real shared browser rather than a screenshot-driven duplicate +- provider-neutral MCP entry point +- environment and thread scoped authorization +- server-side routing compatible with remote T3 clients +- schema-validated contracts and tested broker behavior +- normal browser UX including navigation, history, zoom, DevTools, and annotations + +Current constraints to remove: + +- browser lifetime is anchored to React component mounting +- only three hidden preview threads are retained +- sheet and route behavior can unmount the browser +- most automation requires the owner to report `visible` +- ownership is inferred from a focused renderer rather than a durable browser host +- one global persistent preview partition is shared across projects and threads +- CDP attaches and detaches around individual operations +- the custom automation surface has limited selector and locator semantics +- requests take an avoidable renderer-mediated hop before reaching desktop browser control +- remote loopback URLs are not resolved to the remote environment +- recording, trace, console, network, and download artifacts are not modeled as one system +- a browser session cannot be intentionally handed between user and agent control modes + +The implementation may remove or replace these patterns rather than preserving compatibility internally. User-facing session state and existing thread behavior should be migrated deliberately. + +## Target Architecture + +Split the system into five boundaries: + +```text +Agent adapter / skill / MCP + | + v +Browser Control Service <----> Browser Artifact Service + | + v +Browser Session Registry + | + v +Browser Host Adapter <----> Environment Preview Gateway + | + v +Real Chromium page shown in T3 +``` + +### Browser Session Registry + +The environment server owns authoritative logical session metadata: + +```ts +interface BrowserSession { + id: BrowserSessionId; + environmentId: EnvironmentId; + threadId: ThreadId; + hostId: BrowserHostId | null; + activeTabId: BrowserTabId | null; + lifecycle: "creating" | "ready" | "suspended" | "recovering" | "closed"; + visibility: "visible" | "hidden" | "detached"; + controller: "human" | "agent" | "none"; + partitionId: BrowserPartitionId; + recordingId: BrowserRecordingId | null; + revision: number; + createdAt: string; + updatedAt: string; +} +``` + +The registry owns: + +- stable session and tab identities +- environment and thread association +- lifecycle and recovery state +- host assignment +- visibility and control state +- capabilities reported by the host +- recording and artifact references +- event revisions for reconnect and stale-command rejection + +The registry does not own Chromium directly. A browser host does. + +### Browser Host + +A `BrowserHost` is a process capable of owning and controlling real browser pages. + +Initial host adapter: + +- `ElectronBrowserHost` + - uses route-durable app-level renderer-hosted `` elements + - is mounted independently of thread routes, panel sheets, and preview components + - registers each guest `WebContents` with the desktop main process + - keeps desktop main authoritative for CDP, navigation, recording, and security + - moves tabs between visible panel bounds, offscreen idle parking, and covered recording parking + - binds each host to an owning window and reports renderer reload, renderer crash, or window close as host loss + +Future host adapter: + +- `EnvironmentChromiumHost` + - runs Chromium beside a remote/headless environment + - enables unattended testing and CI + - is not a second browser for the same active session + - creates a separate explicitly hosted session whose UI attachment strategy is a later phase + +Do not pretend a live browser process can migrate losslessly between hosts. A session is bound to one host. Migration requires an explicit restart/restore operation and must report what state cannot be preserved. + +### Browser Control Service + +The control service provides one internal session-oriented API regardless of the calling agent or host implementation. + +It owns: + +- authorization and invocation scope +- host discovery and routing +- command serialization per tab +- cancellation, deadlines, and stale revision checks +- automation adapter sessions +- control lease and user-interruption behavior +- structured event subscriptions +- artifact creation triggers + +It should expose a transport-neutral internal protocol. MCP is one adapter, not the core API. + +### Browser Artifact Service + +The artifact service stores and indexes: + +- screenshots +- video recordings +- action timelines +- console logs +- uncaught exceptions +- network request summaries +- HAR exports where supported +- DOM/accessibility snapshots +- downloads +- optional Playwright traces + +Artifacts are scoped to environment, thread, browser session, and provider session. Large payloads must not travel inline through the normal WebSocket event stream. + +### Environment Preview Gateway + +The gateway makes environment-local web services reachable by the browser host. + +Example: + +```text +remote environment localhost:5173 + -> environment TCP target + -> dedicated authenticated WebSocket tunnel + -> desktop loopback TCP listener + -> real webview navigation to desktop loopback +``` + +The browser may load a desktop-local authority so origin paths, root-relative assets, HMR, and application WebSockets retain normal semantics. Phase 0.5 proved that a bare loopback port is not an authorization boundary, changing ports loses origin storage, and absolute redirects can escape the authority. Browser-attributed ingress, stable authority assignment, and explicit redirect/HTTPS policy are required before the gateway is production-ready. The UI separately displays the environment-relative requested target. + +The gateway must support: + +- raw TCP forwarding with backpressure and half-close behavior +- HTTP, streaming responses, and WebSocket upgrades without application-level rewriting +- source maps, static assets, redirects, cookies, and arbitrary root-relative paths +- deterministic target identity +- connection loss and reconnect reporting +- explicit port authorization +- one dedicated tunnel connection per accepted TCP stream initially +- a separate priority class from normal RPC and event traffic + +The browser automation layer should receive both requested and resolved URLs. + +## Browser Ownership and UI Model + +### Move lifetime out of React + +Replace `usePreviewBridge` mount/unmount ownership with explicit commands: + +- `browserSession.create` +- `browserSession.attachView` +- `browserSession.detachView` +- `browserSession.setVisibility` +- `browserSession.close` + +The app-level browser host remains mounted for the desktop window lifetime. React panels report display bounds and visibility intent; they do not mount, reparent, or destroy browser elements. + +### Hidden browser behavior + +When hidden: + +- keep the webview and `WebContents` alive +- park idle tabs offscreen at their preserved viewport size to reduce compositor work +- move recording tabs to a full-size covered parking surface because offscreen tabs stop producing screencast frames +- preserve timers and network behavior by default +- allow automation and recording according to session policy +- expose an explicit low-resource suspension operation rather than silently evicting after an arbitrary count + +Resource pressure policy should be observable and deterministic: + +- warn before suspension +- store restorable metadata +- never silently destroy a session being controlled or recorded +- allow configurable limits by memory pressure and session activity + +### Tabs + +Make tabs first-class: + +```ts +interface BrowserTab { + id: BrowserTabId; + sessionId: BrowserSessionId; + origin: "human" | "agent" | "system"; + state: "opening" | "ready" | "closing" | "closed"; + url: string; + title: string; + active: boolean; + controller: "human" | "agent" | "none"; +} +``` + +Support: + +- agent-created tabs +- user-created tabs +- selecting and listing tabs +- adopting an existing user tab +- handing a tab back to the user +- closing only tabs owned by the current workflow when requested +- restoring tab metadata after client reconnect + +### Control and interruption + +Use an explicit control lease instead of assuming the most recently focused owner is always correct. + +Policy: + +- humans may always interrupt an agent action +- active user pointer or keyboard input cancels or pauses conflicting agent input +- automation receives `BrowserControlInterruptedError` +- read-only observations may continue during human control +- the UI shows when an agent controls a tab +- agent cursor and current action are visible but do not block normal browser input +- a user can pause, resume, or revoke automation for the session + +Avoid a heavyweight approval dialog for every action. Use session-level trust, site policy, and high-risk operation gates. + +## Programmatic Automation + +### Primary adapter decision + +Do not continue expanding a fixed set of hand-written CDP operations as the main agent interface. + +Implement an adapter boundary: + +```ts +interface BrowserAutomationAdapter { + connect(session: BrowserAutomationSession): Effect.Effect; +} +``` + +Phase 0.5 selected a **Playwright-injected semantic adapter behind a T3-owned browser-client boundary**: + +- persistent Electron debugger connection owned by desktop main +- the version-pinned Playwright injected runtime for selector parsing, semantic locators, shadow DOM, actionability, and hit-target behavior +- one injected runtime and execution context per frame/target, including explicit OOPIF target routing +- Playwright-like high-level semantics without Playwright owning the browser +- locator descriptions re-resolved at action time +- optional snapshot-scoped element references as ephemeral accelerators only + +Direct Playwright `connectOverCDP` does not expose Electron guest targets of type `webview` as Playwright pages. A `WebContentsView` does appear as a page, but it was rejected for this iteration because hidden/detached capture failed recording requirements and its native stacking model complicates collaboration UI. + +Playwright remains a behavior reference and test oracle. Its injected runtime is an internal, version-coupled dependency protected by compatibility fixtures and hidden behind T3 contracts. + +### Agent-facing API + +Offer multiple programmatic surfaces over the same control service: + +- MCP toolkit for providers that support MCP +- skill instructions and helper client for Codex-like runtimes +- CLI for debugging and providers that can execute shell commands +- internal TypeScript client for T3 features and tests +- optional raw CDP diagnostics only in explicitly trusted developer mode + +The default high-level surface should support: + +- create/open/list/close sessions and tabs +- navigate, reload, history, viewport, and zoom +- semantic locate, inspect, click, hover, drag, type, select, upload, and keyboard input +- frames, dialogs, popups, downloads, and new tabs +- wait for semantic conditions, requests, navigation, and page stability +- JavaScript evaluation with bounded results +- console and exception subscriptions +- request/response observation +- screenshots and recordings +- action groups and assertions + +Keep raw CDP as a privileged developer escape hatch, not the normal workflow. + +### Observation model + +Agents should not need a full screenshot after every action. + +Provide composable observations: + +- accessibility snapshot with stable element references +- semantic locator results +- bounded visible text +- viewport screenshot on demand +- current URL/title/loading state +- recent console and exception entries +- recent network failures +- DOM change summary since a known revision +- current control and recording state + +Locator descriptions are the primary durable handle and must re-resolve immediately before action. Snapshot references expire on navigation, document replacement, frame replacement, HMR, or node detachment; when the originating locator is available the adapter may retry through that locator. + +### Command execution + +Commands to a tab are serialized unless explicitly marked read-only and concurrent-safe. + +Each command includes: + +- command id +- browser session and tab id +- expected session/document revision where relevant +- provider and thread invocation scope +- deadline and cancellation token +- control requirement +- artifact/evidence policy + +Each result includes: + +- before and after revisions +- timing +- resulting URL and title +- structured error when applicable +- references to generated evidence + +## Recording, Tracing, and Debug Evidence + +### Recording requirements + +Agents and users can: + +- start recording the active tab or browser session +- stop recording and receive an artifact reference +- mark recording chapters around action groups +- capture a short rolling buffer after a failure +- attach recordings to thread messages or task completion evidence +- download or open recordings from the UI + +The browser remains a normal interactive webview while recording. + +### Selected capture pipeline + +Phase 0 selected: + +```text +guest webview Page.startScreencast + -> bounded frame sampler + -> isolated recording canvas + -> Chromium MediaRecorder + -> H.264 MP4 artifact +``` + +Initial defaults: + +- `video/mp4;codecs=avc1.42E01E` +- 12 frames per second +- current browser viewport with policy bounds +- dropped intermediate frames under encoder pressure instead of an unbounded queue + +When a recorded tab is hidden, the durable browser host places it on a covered full-size parking surface. Moving it offscreen stops continuous screencast frames. No external ffmpeg executable is required. + +CDP frame capture is internal to evidence generation. It does not replace the real browser as the interactive UX. + +### Action timeline + +Every recording can be paired with structured events: + +```ts +interface BrowserActionEvent { + id: BrowserActionEventId; + sessionId: BrowserSessionId; + tabId: BrowserTabId; + actor: "human" | "agent" | "system"; + action: string; + startedAt: string; + completedAt?: string; + status: "running" | "succeeded" | "failed" | "interrupted"; + inputSummary: unknown; + pageRevisionBefore: number; + pageRevisionAfter?: number; + screenshotBeforeId?: BrowserArtifactId; + screenshotAfterId?: BrowserArtifactId; + recordingOffsetMs?: number; + error?: BrowserErrorSummary; +} +``` + +This timeline is more useful to agents than video alone and allows the UI to jump from an action to the relevant recording timestamp. + +### Console and network capture + +Maintain bounded per-tab ring buffers for: + +- console entries +- uncaught exceptions +- failed requests +- response status summaries +- WebSocket connection failures +- navigation timing + +Allow explicit HAR/trace recording for deeper debugging. Redact authorization headers, cookies, and configured sensitive fields before persistence. + +## Remote-First Behavior + +### Required remote scenarios + +The design must support: + +1. Desktop UI and browser on a MacBook, environment and agent on a Mac mini. +2. Desktop UI and browser local, environment inside Docker, SSH, WSL, or a cloud VM. +3. Multiple desktop clients connected to one environment without ambiguous browser ownership. +4. Agent automation continuing when the browser panel is hidden. +5. Reconnecting a desktop client to an existing logical browser session after network interruption. +6. Unattended environment-side browser sessions for CI or automated verification in a later phase. + +### Preview target abstraction + +Agents should open an environment-relative target rather than constructing client-reachable URLs: + +```ts +type BrowserNavigationTarget = + | { kind: "url"; url: string } + | { kind: "environment-port"; port: number; protocol?: "http" | "https"; path?: string } + | { kind: "run-action"; actionId: string; path?: string }; +``` + +The environment resolves the target into: + +```ts +interface ResolvedBrowserTarget { + requested: BrowserNavigationTarget; + displayUrl: string; + browserUrl: string; + resolutionKind: "direct" | "environment-gateway" | "tunnel"; + environmentId: EnvironmentId; + expiresAt?: string; +} +``` + +This removes remote hostname guessing from prompts and agent logic. + +### Gateway security + +- do not treat loopback binding or port secrecy as authorization +- require browser-attributed ingress so unrelated pages and local processes cannot ride an authenticated environment tunnel +- keep the desktop authority stable for the granted environment/project/target tuple to preserve cookies, storage, service workers, and origin behavior +- scope gateway grants to environment, browser session, target host, and port +- use short-lived credentials inaccessible to page JavaScript where possible +- block cloud metadata and forbidden address ranges by policy +- require explicit configuration for non-loopback arbitrary upstream hosts +- validate redirects against the grant policy +- audit port openings and navigation resolutions +- do not place long-lived bearer tokens in visible URLs +- use a dedicated tunnel connection rather than the normal control/event WebSocket +- multiplex TCP streams over a bounded number of authenticated tunnel connections before T3 Connect rollout unless relay load tests prove per-stream connections safe +- define explicit compatibility behavior for absolute redirects, configured public origins, OAuth callbacks, and HTTPS upstreams + +### Reconnect behavior + +If the desktop connection drops: + +- mark the host unavailable without deleting the logical session +- fail or pause pending control commands deterministically +- retain artifact metadata and last-known tab state +- rebind when the same browser host reconnects and reports its session inventory +- report when the underlying `WebContents` was lost and recovery requires reload + +Do not claim browser process survival when the owning desktop application exited. + +## Security Model + +### Invocation scope + +Keep the existing session-scoped MCP authentication model, generalized to browser capabilities: + +- `browser:observe` +- `browser:interact` +- `browser:navigate` +- `browser:evaluate` +- `browser:record` +- `browser:network-inspect` +- `browser:raw-cdp` + +The agent cannot override environment, thread, or provider session identity in normal tool arguments. + +### Site policy + +Add environment/user policy for: + +- allowed origins and port ranges +- external internet navigation +- authentication pages +- file uploads and downloads +- clipboard access +- camera, microphone, geolocation, and notifications +- JavaScript evaluation +- raw CDP + +Local development origins may be trusted by default under a configurable policy. Sensitive external origins should require an explicit trust decision. + +### Browser partitions + +Replace the global `persist:t3code-preview` partition with deliberate isolation. + +Default proposal: + +- one persistent partition per environment and project +- optional isolated partition per browser session +- explicit user action to share authentication state between sessions +- clear partition lifecycle and storage deletion UX + +The final partition key must not contain raw secrets or unsafe filesystem characters. + +## Contract and Module Direction + +Likely contracts: + +```text +packages/contracts/src/browserSession.ts +packages/contracts/src/browserControl.ts +packages/contracts/src/browserArtifacts.ts +packages/contracts/src/browserGateway.ts +``` + +Keep contracts schema-only. + +Likely runtime modules: + +```text +apps/server/src/browser/ + Services/ + BrowserSessionRegistry.ts + BrowserControlService.ts + BrowserArtifactService.ts + BrowserTargetResolver.ts + Layers/ + Rpc/ + Mcp/ + +apps/desktop/src/browser/ + ElectronBrowserHost.ts + ElectronBrowserTab.ts + CdpConnection.ts + RecordingController.ts + ArtifactUploader.ts + +apps/web/src/browser/ + BrowserPanel.tsx + BrowserSessionProvider.tsx + BrowserTabs.tsx + BrowserControlIndicator.tsx + BrowserArtifactViewer.tsx + +packages/browser-client/ + session client + locator/action adapter + CLI or Node helper +``` + +Do not preserve `PreviewAutomationOwner` as the permanent routing boundary. Replace it with browser-host registration and session attachment protocols. + +## Migration Strategy + +### Phase 0: Technical Spikes and Decision Records + +**Status: completed.** + +Authoritative results and executable spikes: + +- `.plans/browser-phase-0/findings.md` +- `.plans/browser-phase-0/001-browser-host.md` +- `.plans/browser-phase-0/002-automation-adapter.md` +- `.plans/browser-phase-0/003-recording.md` +- `.plans/browser-phase-0/004-remote-preview-tunnel.md` +- `.plans/browser-phase-0/005-desktop-routing.md` +- `.plans/browser-phase-0/spikes/` + +Goals: + +- eliminate high-risk unknowns before reshaping contracts +- produce small executable prototypes rather than design-only conclusions + +Spikes: + +1. Attach Playwright or a Playwright-compatible locator runtime to the existing Electron guest webview. +2. Keep a guest `WebContents` alive and controllable while its panel is hidden and painting is reduced. +3. Record the guest content to a seekable video without replacing the interactive browser UX. +4. Proxy a remote environment's loopback HTTP and WebSocket dev server into the local desktop webview. +5. Measure CDP command latency through direct desktop routing versus the current renderer-mediated route. + +Deliverables: + +- decision record for automation adapter +- decision record for recording backend +- decision record for remote preview gateway transport +- measured CPU, memory, and latency results +- explicit unsupported cases + +Exit criteria: + +- completed: semantic role/name click and input worked against the real webview through CDP +- completed: Chromium MediaRecorder produced a seekable H.264 MP4 from covered webview capture +- completed: raw TCP tunneling carried HTTP and WebSocket traffic through a desktop loopback listener + +### Phase 0.5: Production-Risk Closure + +**Status: completed for the targeted desktop probes; browser-attributed tunnel ingress remains an implementation prerequisite.** + +Authoritative results: + +- `.plans/browser-phase-0-5/findings.md` +- `.plans/browser-phase-0-5/006-renderer-failure-boundary.md` +- `.plans/browser-phase-0-5/007-playwright-injected-runtime.md` +- `.plans/browser-phase-0-5/008-recording-endurance.md` +- `.plans/browser-phase-0-5/009-loopback-threat-model.md` +- `.plans/browser-phase-0-5/010-human-input-interruption.md` + +Confirmed: + +- host-renderer reload destroys the `` guest and live state, while a main-owned `WebContentsView` survives that specific reload +- Playwright's installed injected runtime can be evaluated in the real guest over CDP, resolves role/name locators through shadow DOM, and re-resolves after element replacement +- covered recording produced a seekable 1600×1200 H.264 MP4 for ten seconds at approximately 11.2 fps +- an unrelated browser page can cause a request to a loopback preview listener; loopback binding and port secrecy are not authorization +- changing the desktop port changes the browser origin and loses origin storage +- CDP keyboard dispatch did not emit Electron `before-input-event` in the tested guest + +Required carry-forward: + +- route-durable terminology and explicit host-loss UX +- version-pinned Playwright injected runtime with OOPIF routing and compatibility fixtures +- one-recording-per-window default until soak and concurrency budgets pass +- a browser-attributed desktop ingress design before remote preview implementation +- annotation/source attribution, port discovery, and guest isolation hardening in Phase 1 +- console and network ring buffers in Phase 2 + +### Phase 1: Durable Browser Session Core + +Goals: + +- separate browser lifetime from React +- introduce stable sessions, tabs, and host registration + +Tasks: + +1. Add browser session, tab, host, lifecycle, and capability schemas. +2. Add server `BrowserSessionRegistry` with revisioned events. +3. Add durable app-level `ElectronBrowserHost` plus desktop-main registration and heartbeat. +4. Move browser create/close ownership out of `usePreviewBridge` and panel components. +5. Add panel-bound reporting and visible/offscreen/covered parking transitions. +6. Remove the visibility requirement from control routing. +7. Replace hidden-thread count eviction with explicit resource policy. +8. Add per-environment/project partitioning. +9. Migrate existing preview open/close state to browser sessions. +10. Preserve the annotation/element-pick/source-attribution pipeline as a Browser Control Service capability. +11. Preserve discovered-server and run-action preview UX through environment-relative target contracts. +12. Harden guest isolation and document any narrowly required main-world injection. +13. Add owning-window identity, host-loss transitions, and explicit session-loss UX. + +Acceptance criteria: + +- hiding the panel does not destroy the page +- automation works while hidden +- reopening shows the exact current page state +- route changes do not accidentally close the session +- desktop reconnect reports and reconciles existing browser inventory +- browser closure is always an explicit lifecycle event + +### Phase 2: Control Service and Automation Adapter + +Goals: + +- replace fixed bespoke operations with a reusable automation connection +- preserve provider-neutral invocation + +Tasks: + +1. Add `BrowserControlService` with per-tab command queues and cancellation. +2. Add persistent CDP connection ownership in the desktop host. +3. Implement the selected Playwright or browser-client-style adapter. +4. Integrate the version-pinned Playwright injected runtime with locator re-resolution and compatibility fixtures. +5. Add target auto-attach, OOPIF routing, frames, popups, dialogs, downloads, drag, hover, and file upload support. +6. Add structured observation and incremental page revisions. +7. Add bounded console, exception, and network ring buffers. +8. Add a `packages/browser-client` helper and CLI. +9. Refactor MCP tools into a thin adapter over the control service. +10. Deprecate and remove redundant `PreviewAutomationOwner` operation dispatch. + +Acceptance criteria: + +- a provider can complete a multi-page workflow using semantic locators +- the same workflow can be driven through MCP and the browser client +- user interaction can interrupt an agent safely +- command failures identify stale page state, lost control, timeout, or host loss distinctly +- DevTools and automation coexist or fail with an explicit supported policy + +### Phase 3: Remote Preview Gateway + +Goals: + +- make remote loopback services first-class browser targets + +Tasks: + +1. Add environment-port and run-action target contracts. +2. Add target resolution, stable desktop authorities, and short-lived gateway grants. +3. Implement browser-attributed desktop ingress; reject a bare unauthenticated loopback listener. +4. Implement bounded multiplexed raw TCP streams over dedicated authenticated tunnel connections. +5. Add origin, cookie, service-worker, absolute-redirect, OAuth, HTTPS, and source-map compatibility tests. +6. Integrate run actions so agents can request the declared preview target directly. +7. Display requested and resolved target information in browser diagnostics. +8. Add reconnect and expired-grant recovery. + +Acceptance criteria: + +- a desktop browser can open `localhost` services from a remote environment without LAN exposure +- Vite HMR works through the gateway +- application WebSockets work through the gateway +- the agent never needs to guess the remote machine's IP address +- gateway permissions cannot be reused for unrelated ports or hosts + +### Phase 4: Recording and Evidence + +Goals: + +- make browser work inspectable, replayable, and attachable to tasks + +Tasks: + +1. Add browser artifact contracts and storage abstraction. +2. Add start/stop recording controls to agent and UI surfaces. +3. Add structured action timeline generation. +4. Add screenshot-before/after policies for action groups and failures. +5. Add artifact upload outside the normal WS payload path. +6. Add thread UI for video, screenshots, traces, and logs. +7. Add retention, cleanup, and size limits. +8. Add secret redaction for network and console artifacts. +9. Enforce the initial one-active-recording-per-window policy and expose encoder/drop health. + +Acceptance criteria: + +- an agent can record a test flow and return an artifact reference +- the user can watch the recording and jump to failed actions +- recordings work while the panel is hidden +- failed requests and console exceptions are included in the evidence bundle +- large artifacts do not block normal thread event delivery + +### Phase 5: Multi-Tab Collaboration and Unattended Hosts + +Goals: + +- mature collaboration UX +- add optional environment-side automation without compromising shared-session semantics + +Tasks: + +1. Add human/agent tab origin and ownership UX. +2. Add tab adoption, handoff, finalization, and workflow cleanup policies. +3. Add visible agent cursor and current-action overlays. +4. Add session pause/revoke controls. +5. Implement `EnvironmentChromiumHost` for CI and unattended verification. +6. Define how a remote-hosted session is viewed or attached without claiming it is the local Electron webview. +7. Add capability-based host selection. +8. Add test-report integrations using browser artifacts. + +Acceptance criteria: + +- agent-created tabs are identifiable and cleanly handed to the user +- user-created tabs can be intentionally adopted +- unattended browser workflows use a separately identified environment-hosted session +- no workflow silently operates a second browser while presenting another as the controlled browser + +## Testing Strategy + +### Contract tests + +- session, tab, host, control, artifact, and target schemas +- version and revision validation +- capability authorization +- invalid lifecycle transitions +- stale command rejection + +### Server tests + +- session registry lifecycle and event replay +- host assignment and reconnect +- per-tab command serialization +- cancellation and timeout behavior +- control lease interruption +- provider/thread isolation +- artifact metadata and cleanup +- gateway grant scoping + +### Desktop tests + +- webview registration cannot target unrelated `WebContents` +- hidden session remains navigable and controllable +- persistent CDP connection reconnects after navigation or target loss +- multiple tabs remain isolated +- recording starts, survives navigation, and finalizes +- resource policy never evicts active or recording sessions + +### Gateway integration tests + +- remote HTTP app +- Vite HMR WebSocket +- application WebSocket +- redirects +- cookies +- source maps +- upstream failure and reconnect +- forbidden host/port rejection + +### End-to-end tests + +1. Start a remote test environment and dev server bound only to loopback. +2. Open it through an environment-port target in the desktop browser. +3. Hide the panel. +4. Drive a semantic browser workflow through MCP or browser-client. +5. Record the workflow. +6. Reopen the panel and verify the same final page state. +7. Inspect the recording, action timeline, console, and network artifacts. + +Additional scenarios: + +- user interrupts agent typing +- desktop disconnects during navigation +- HMR reload occurs during locator interaction +- two desktop clients connect to the same environment +- two provider sessions cannot cross-control threads +- one provider loses authorization while another continues + +Do not mock core lifecycle, routing, or command-serialization logic. External Chromium, media encoding, filesystem artifact storage, and network transport boundaries may use test layers where necessary. + +## Performance and Reliability Budgets + +Measure and enforce budgets rather than treating performance as qualitative. + +Initial targets to validate during spikes: + +- control routing overhead excluding page work: p95 under 50 ms locally, under 150 ms over a typical remote T3 connection +- hidden idle session CPU: near-zero absent page activity +- hidden session memory: observable with configurable pressure policy +- screenshot capture: under 500 ms for a normal 1280px viewport +- semantic snapshot: under 300 ms for typical application pages +- recording overhead: under 15% CPU on supported desktop hardware at the selected resolution/frame rate +- browser host reconnect detection: under 5 seconds +- no unbounded console, network, screenshot, or action buffers + +Budgets may be adjusted after measurement, but the final values must be documented and covered by benchmarks or diagnostics. + +## Observability + +Add structured diagnostics for: + +- browser session and host lifecycle transitions +- tab creation, closure, and target changes +- command queue length and duration +- CDP connection state +- control lease changes and interruptions +- gateway resolution and proxy failures +- recording start, encoder health, and finalization +- artifact sizes and upload duration +- dropped or redacted evidence + +Provide a user-accessible browser diagnostics view or export so remote failures do not require reading opaque server logs. + +## Explicit Non-Goals + +- synchronizing two independent browsers and presenting them as one session +- replacing the interactive browser UI with JPEG or video streaming +- building a full custom Playwright clone +- preserving current internal APIs solely for compatibility +- guaranteeing browser process survival after the owning host exits +- transparent live migration of an active browser process between hosts +- unrestricted proxying to arbitrary private-network targets +- storing unlimited recordings or raw network bodies +- making Codex's browser architecture the permanent product boundary + +## Pinned Phase 0 + 0.5 Decisions + +1. Keep a route-durable renderer-hosted Electron ``; do not migrate to `WebContentsView` in this iteration. Explicitly model host-renderer reload, crash, and window close as live-page loss. +2. Use a T3-owned semantic CDP adapter backed initially by the version-pinned Playwright injected runtime. Do not depend on direct Playwright attachment and do not build locator/actionability semantics from scratch. +3. Record with CDP screencast plus Chromium MediaRecorder H.264 MP4 encoding. Default to one active recording per window until soak and concurrency budgets pass. +4. Keep raw TCP as the environment transport, but do not ship a bare loopback listener as the desktop authorization boundary. Require stable authorities, browser-attributed ingress, bounded multiplexing, and explicit redirect/HTTPS policy. +5. Keep the renderer as the environment transport relay initially because measured IPC overhead is negligible; remove its lifecycle authority. +6. Default browser partition scope is one persistent partition per environment and project, with optional isolated sessions. +7. Capability and site policy gates remain as defined in the Security Model section; guest isolation and the annotation/source-attribution pipeline are explicit Phase 1 requirements. +8. Artifact bytes use a dedicated storage/upload path; deployment-specific backends implement the shared artifact interface. +9. Locator descriptions re-resolve at action time. Snapshot node references are optional and ephemeral. +10. Initial controller modes are `human`, `agent`, and `none`; undefined shared control is removed. + +## Recommended Implementation Order + +1. Introduce session, tab, window, and host contracts from the completed Phase 0 and 0.5 ADRs. +2. Add the durable app-level webview host and desktop-main registry. +3. Move browser lifetime out of preview panels and `usePreviewBridge`. +4. Remove visibility-gated automation. +5. Add persistent CDP connections and command queues. +6. Integrate the Playwright injected runtime, per-frame routing, and semantic automation adapter. +7. Add console and network diagnostics. +8. Complete browser-attributed ingress and then add the multiplexed raw TCP environment preview tunnel. +9. Add the selected MediaRecorder evidence pipeline under the one-recording policy. +10. Add collaboration, tab handoff, and cursor UX. +11. Add optional environment-hosted Chromium sessions. +12. Remove superseded preview automation code while preserving annotations, source attribution, and discovered-server UX. + +## Completion Gates + +Each implementation phase must include tests for backend changes and must pass: + +- `vp check` +- `vp run typecheck` +- `vp test` + +Run `vp run lint:mobile` only when native mobile code changes. + +The next iteration is not complete merely when an agent can click the page. It is complete when the same durable browser session can be controlled by an agent, inspected and interrupted by a user, reached from remote environments, and reviewed through reliable evidence artifacts. diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index bfeff47dec4..82254b70970 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -161,6 +161,7 @@ import { type ElementContextDraft, formatElementContextLabel, } from "../lib/elementContext"; +import { appendPreviewAnnotationPrompt } from "../lib/previewAnnotation"; import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; @@ -1111,6 +1112,9 @@ export default function ChatView(props: ChatViewProps) { const setComposerDraftElementContexts = useComposerDraftStore( (store) => store.setElementContexts, ); + const setComposerDraftPreviewAnnotations = useComposerDraftStore( + (store) => store.setPreviewAnnotations, + ); const setComposerDraftModelSelection = useComposerDraftStore((store) => store.setModelSelection); const setComposerDraftRuntimeMode = useComposerDraftStore((store) => store.setRuntimeMode); const setComposerDraftInteractionMode = useComposerDraftStore( @@ -3555,6 +3559,7 @@ export default function ChatView(props: ChatViewProps) { images: composerImages, terminalContexts: composerTerminalContexts, elementContexts: composerElementContexts, + previewAnnotations: composerPreviewAnnotations, selectedProvider: ctxSelectedProvider, selectedModel: ctxSelectedModel, selectedProviderModels: ctxSelectedProviderModels, @@ -3571,7 +3576,7 @@ export default function ChatView(props: ChatViewProps) { prompt: promptForSend, imageCount: composerImages.length, terminalContexts: composerTerminalContexts, - elementContextCount: composerElementContexts.length, + elementContextCount: composerElementContexts.length + composerPreviewAnnotations.length, }); if (showPlanFollowUpPrompt && activeProposedPlan) { const followUp = resolvePlanFollowUpSubmission({ @@ -3590,7 +3595,8 @@ export default function ChatView(props: ChatViewProps) { const standaloneSlashCommand = composerImages.length === 0 && sendableComposerTerminalContexts.length === 0 && - composerElementContexts.length === 0 + composerElementContexts.length === 0 && + composerPreviewAnnotations.length === 0 ? parseStandaloneComposerSlashCommand(trimmed) : null; if (standaloneSlashCommand) { @@ -3639,10 +3645,15 @@ export default function ChatView(props: ChatViewProps) { const composerImagesSnapshot = [...composerImages]; const composerTerminalContextsSnapshot = [...sendableComposerTerminalContexts]; const composerElementContextsSnapshot = [...composerElementContexts]; - const messageTextForSend = appendElementContextsToPrompt( + const composerPreviewAnnotationsSnapshot = [...composerPreviewAnnotations]; + const messageTextWithContexts = appendElementContextsToPrompt( appendTerminalContextsToPrompt(promptForSend, composerTerminalContextsSnapshot), composerElementContextsSnapshot, ); + const messageTextForSend = composerPreviewAnnotationsSnapshot.reduce( + (text, annotation) => appendPreviewAnnotationPrompt(text, annotation), + messageTextWithContexts, + ); const messageIdForSend = newMessageId(); const messageCreatedAt = new Date().toISOString(); const outgoingMessageText = formatOutgoingPrompt({ @@ -3810,7 +3821,9 @@ export default function ChatView(props: ChatViewProps) { promptRef.current.length === 0 && composerImagesRef.current.length === 0 && composerTerminalContextsRef.current.length === 0 && - composerElementContextsRef.current.length === 0 + composerElementContextsRef.current.length === 0 && + (useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.previewAnnotations + .length ?? 0) === 0 ) { setOptimisticUserMessages((existing) => { const removed = existing.filter((message) => message.id === messageIdForSend); @@ -3829,6 +3842,7 @@ export default function ChatView(props: ChatViewProps) { addComposerDraftImages(composerDraftTarget, retryComposerImages); setComposerDraftTerminalContexts(composerDraftTarget, composerTerminalContextsSnapshot); setComposerDraftElementContexts(composerDraftTarget, composerElementContextsSnapshot); + setComposerDraftPreviewAnnotations(composerDraftTarget, composerPreviewAnnotationsSnapshot); composerRef.current?.resetCursorState({ cursor: collapseExpandedComposerCursor(promptForSend, promptForSend.length), prompt: promptForSend, diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 445c4eda2df..e6edbdc224a 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -2,6 +2,7 @@ import type { ApprovalRequestId, EnvironmentId, ModelSelection, + PreviewAnnotationPayload, ProviderApprovalDecision, ProviderInteractionMode, ResolvedKeybindingsConfig, @@ -55,6 +56,7 @@ import { import { useComposerPathSearch } from "../../lib/composerPathSearchState"; import { type ElementContextDraft } from "../../lib/elementContext"; import { ComposerPendingElementContexts } from "./ComposerPendingElementContexts"; +import { ComposerPreviewAnnotationCards } from "./ComposerPreviewAnnotationCards"; import { shouldUseCompactComposerPrimaryActions, shouldUseCompactComposerFooter, @@ -403,6 +405,7 @@ export interface ChatComposerHandle { images: ComposerImageAttachment[]; terminalContexts: TerminalContextDraft[]; elementContexts: ElementContextDraft[]; + previewAnnotations: PreviewAnnotationPayload[]; selectedPromptEffort: string | null; selectedModelOptionsForDispatch: unknown; selectedModelSelection: ModelSelection; @@ -611,6 +614,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const composerImages = composerDraft.images; const composerTerminalContexts = composerDraft.terminalContexts; const composerElementContexts = composerDraft.elementContexts; + const composerPreviewAnnotations = composerDraft.previewAnnotations; const nonPersistedComposerImageIds = composerDraft.nonPersistedImageIds; const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); @@ -629,6 +633,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const removeComposerDraftElementContext = useComposerDraftStore( (store) => store.removeElementContext, ); + const removeComposerDraftPreviewAnnotation = useComposerDraftStore( + (store) => store.removePreviewAnnotation, + ); const clearComposerDraftPersistedAttachments = useComposerDraftStore( (store) => store.clearPersistedAttachments, ); @@ -889,9 +896,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) prompt, imageCount: composerImages.length, terminalContexts: composerTerminalContexts, - elementContextCount: composerElementContexts.length, + elementContextCount: composerElementContexts.length + composerPreviewAnnotations.length, }), - [composerElementContexts.length, composerImages.length, composerTerminalContexts, prompt], + [ + composerElementContexts.length, + composerImages.length, + composerPreviewAnnotations.length, + composerTerminalContexts, + prompt, + ], ); // ------------------------------------------------------------------ @@ -1984,6 +1997,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) images: composerImagesRef.current, terminalContexts: composerTerminalContextsRef.current, elementContexts: composerElementContextsRef.current, + previewAnnotations: composerPreviewAnnotations, selectedPromptEffort, selectedModelOptionsForDispatch, selectedModelSelection, @@ -2002,6 +2016,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerImagesRef, composerTerminalContextsRef, composerElementContextsRef, + composerPreviewAnnotations, isConnecting, isComposerApprovalState, pendingUserInputs.length, @@ -2239,6 +2254,24 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
)} + {!isComposerCollapsedMobile && + !isComposerApprovalState && + pendingUserInputs.length === 0 && + composerPreviewAnnotations.length > 0 && ( + + removeComposerDraftPreviewAnnotation(composerDraftTarget, annotationId) + } + onExpandImage={(imageId) => { + const preview = buildExpandedImagePreview(composerImages, imageId); + if (preview) onExpandImage(preview); + }} + className="mb-3" + /> + )} + {!isComposerCollapsedMobile && !isComposerApprovalState && pendingUserInputs.length === 0 && @@ -2255,68 +2288,78 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) {!isComposerCollapsedMobile && !isComposerApprovalState && pendingUserInputs.length === 0 && - composerImages.length > 0 && ( + composerImages.some( + (image) => + !composerPreviewAnnotations.some((annotation) => annotation.id === image.id), + ) && (
- {composerImages.map((image) => ( -
- {image.previewUrl ? ( - - ) : ( -
- {image.name} -
- )} - {nonPersistedComposerImageIdSet.has(image.id) && ( - - - - - } - /> - - Draft attachment could not be saved locally and may be lost on - navigation. - - - )} - -
- ))} + {image.previewUrl ? ( + + ) : ( +
+ {image.name} +
+ )} + {nonPersistedComposerImageIdSet.has(image.id) && ( + + + + + } + /> + + Draft attachment could not be saved locally and may be lost on + navigation. + + + )} + +
+ ))}
)} diff --git a/apps/web/src/components/chat/ComposerPreviewAnnotationCards.test.tsx b/apps/web/src/components/chat/ComposerPreviewAnnotationCards.test.tsx new file mode 100644 index 00000000000..5bb28054e7d --- /dev/null +++ b/apps/web/src/components/chat/ComposerPreviewAnnotationCards.test.tsx @@ -0,0 +1,46 @@ +import type { PreviewAnnotationPayload } from "@t3tools/contracts"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { ComposerPreviewAnnotationCards } from "./ComposerPreviewAnnotationCards"; + +const annotation: PreviewAnnotationPayload = { + id: "annotation_1", + pageUrl: "http://localhost:3000/welcome", + pageTitle: "Welcome", + comment: "Make this headline feel intentional.", + elements: [], + regions: [{ id: "region_1", rect: { x: 1, y: 2, width: 30, height: 20 } }], + strokes: [], + styleChanges: [ + { + targetId: "element_1", + selector: "h1", + property: "font-size", + previousValue: "32px", + value: "40px", + }, + ], + screenshot: null, + createdAt: "2026-06-13T00:00:00.000Z", +}; + +describe("ComposerPreviewAnnotationCards", () => { + it("presents the annotation as one contextual attachment", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("Make this headline feel intentional."); + expect(markup).toContain('title="1 region"'); + expect(markup).toContain('title="1 style change"'); + expect(markup).not.toContain("Welcome"); + expect(markup).not.toContain("localhost:3000"); + expect(markup).not.toContain("Preview annotation"); + }); +}); diff --git a/apps/web/src/components/chat/ComposerPreviewAnnotationCards.tsx b/apps/web/src/components/chat/ComposerPreviewAnnotationCards.tsx new file mode 100644 index 00000000000..602ad114464 --- /dev/null +++ b/apps/web/src/components/chat/ComposerPreviewAnnotationCards.tsx @@ -0,0 +1,144 @@ +import type { PreviewAnnotationPayload } from "@t3tools/contracts"; +import { Frame, MousePointerClick, Paintbrush, PenLine, X } from "lucide-react"; +import type { ReactNode } from "react"; + +import type { ComposerImageAttachment } from "~/composerDraftStore"; +import { formatElementContextLabel, normalizeElementContextSelection } from "~/lib/elementContext"; +import { cn } from "~/lib/utils"; + +interface ComposerPreviewAnnotationCardsProps { + annotations: ReadonlyArray; + images: ReadonlyArray; + onRemove: (annotationId: string) => void; + onExpandImage: (imageId: string) => void; + className?: string; +} + +function TargetStat(props: { icon: ReactNode; count: number; label: string }) { + return ( + + {props.icon} + {props.count} + + ); +} + +export function ComposerPreviewAnnotationCards({ + annotations, + images, + onRemove, + onExpandImage, + className, +}: ComposerPreviewAnnotationCardsProps) { + if (annotations.length === 0) return null; + const imagesById = new Map(images.map((image) => [image.id, image])); + + return ( +
+ {annotations.map((annotation) => { + const image = imagesById.get(annotation.id); + const elementLabels = annotation.elements.flatMap((target) => { + const context = normalizeElementContextSelection(target.element); + return context ? [{ id: target.id, label: formatElementContextLabel(context) }] : []; + }); + return ( +
+ {image?.previewUrl ? ( + + ) : ( + + + + )} +
+ {annotation.comment.trim() ? ( +

+ {annotation.comment.trim()} +

+ ) : null} +
+ {elementLabels.length > 0 ? ( +
+ {elementLabels.slice(0, 2).map(({ id, label }) => ( + + {label} + + ))} + {elementLabels.length > 2 ? ( + + +{elementLabels.length - 2} + + ) : null} +
+ ) : null} +
+ {annotation.elements.length > 0 ? ( + } + count={annotation.elements.length} + label="element" + /> + ) : null} + {annotation.regions.length > 0 ? ( + } + count={annotation.regions.length} + label="region" + /> + ) : null} + {annotation.strokes.length > 0 ? ( + } + count={annotation.strokes.length} + label="drawing" + /> + ) : null} + {annotation.styleChanges.length > 0 ? ( + } + count={annotation.styleChanges.length} + label="style change" + /> + ) : null} +
+
+
+ +
+ ); + })} +
+ ); +} diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 216c7ca2a4d..250eee4d698 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -49,6 +49,7 @@ import { HammerIcon, MessageCircleIcon, MousePointerClickIcon, + PaintbrushIcon, MinusIcon, SquarePenIcon, TerminalIcon, @@ -83,6 +84,10 @@ import { extractTrailingElementContexts, type ParsedElementContextEntry, } from "~/lib/elementContext"; +import { + extractTrailingPreviewAnnotation, + type ParsedPreviewAnnotation, +} from "~/lib/previewAnnotation"; import { cn } from "~/lib/utils"; import { useUiStateStore } from "~/uiStateStore"; import { type TimestampFormat } from "@t3tools/contracts/settings"; @@ -433,15 +438,25 @@ function UserTimelineRow({ row }: { row: Extract image.name.startsWith("preview-annotation-")); + const regularImages = userImages.filter((image) => !image.name.startsWith("preview-annotation-")); const canRevertAgentWork = typeof row.revertTurnCount === "number"; return (
- {userImages.length > 0 && ( + {regularImages.length > 0 && (
- {userImages.map((image: NonNullable[number]) => ( + {regularImages.map((image: NonNullable[number]) => (
{ - const preview = buildExpandedImagePreview(userImages, image.id); + const preview = buildExpandedImagePreview(regularImages, image.id); if (!preview) return; ctx.onImageExpand(preview); }} @@ -472,6 +487,13 @@ function UserTimelineRow({ row }: { row: Extract )} + {previewAnnotations.map((annotation, index) => ( + + ))} {elementContextState.contexts.length > 0 ? (
{elementContextState.contexts.map((context) => ( @@ -944,6 +966,58 @@ const UserMessageElementContextChip = memo(function UserMessageElementContextChi ); }); +function UserMessagePreviewAnnotationCard(props: { + annotation: ParsedPreviewAnnotation; + image: NonNullable[number] | null; +}) { + const ctx = use(TimelineRowCtx); + return ( +
+ {props.image?.previewUrl ? ( + + ) : null} +
+ {props.annotation.comment ? ( +
+ {props.annotation.comment} +
+ ) : null} +
+ {props.annotation.targetSummary ? ( + {props.annotation.targetSummary} + ) : null} + {props.annotation.styleChanges.length > 0 ? ( + + + {props.annotation.styleChanges.length} + + ) : null} +
+
+
+ ); +} + const MAX_COLLAPSED_USER_MESSAGE_LINES = 8; const MAX_COLLAPSED_USER_MESSAGE_LENGTH = 600; const COLLAPSED_USER_MESSAGE_FADE_HEIGHT_REM = 1.75; diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index c70f2d8d28f..e3d09a31961 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -6,11 +6,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useComposerDraftStore } from "~/composerDraftStore"; import { ensureEnvironmentApi } from "~/environmentApi"; -import { normalizeElementContextSelection } from "~/lib/elementContext"; -import { - appendPreviewAnnotationPrompt, - previewAnnotationScreenshotFile, -} from "~/lib/previewAnnotation"; +import { previewAnnotationScreenshotFile } from "~/lib/previewAnnotation"; import { ensureLocalApi } from "~/localApi"; import { selectThreadPreviewState, usePreviewStateStore } from "~/previewStateStore"; import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver"; @@ -62,10 +58,8 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, ); const applyServerSnapshot = usePreviewStateStore((state) => state.applyServerSnapshot); const rememberUrl = usePreviewStateStore((state) => state.rememberUrl); - const addElementContext = useComposerDraftStore((store) => store.addElementContext); + const addPreviewAnnotation = useComposerDraftStore((store) => store.addPreviewAnnotation); const addImage = useComposerDraftStore((store) => store.addImage); - const getComposerDraft = useComposerDraftStore((store) => store.getComposerDraft); - const setPrompt = useComposerDraftStore((store) => store.setPrompt); usePreviewSession(threadRef); @@ -421,12 +415,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, try { const annotation = await previewBridge.pickElement(tabId); if (!annotation) return; - for (const target of annotation.elements) { - const selection = normalizeElementContextSelection(target.element); - if (selection) addElementContext(threadRef, selection); - } - const currentPrompt = getComposerDraft(threadRef)?.prompt ?? ""; - setPrompt(threadRef, appendPreviewAnnotationPrompt(currentPrompt, annotation)); + addPreviewAnnotation(threadRef, annotation); const screenshotFile = await previewAnnotationScreenshotFile(annotation); if (screenshotFile && annotation.screenshot) { addImage(threadRef, { @@ -462,7 +451,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, } } })(); - }, [addElementContext, addImage, getComposerDraft, setPrompt, tabId, threadRef]); + }, [addImage, addPreviewAnnotation, tabId, threadRef]); // If the active tab changes mid-pick (close, thread switch, hot restart), // tell main to tear down the in-flight session AND reset our local toggle diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index d7f4e2d2049..a71c1690c23 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -9,6 +9,8 @@ import { ProviderInteractionMode, ProviderDriverKind, ProviderOptionSelection, + PreviewAnnotationPayloadSchema, + type PreviewAnnotationPayload, RuntimeMode, type ServerProvider, type ScopedProjectRef, @@ -52,7 +54,7 @@ const isRuntimeMode = Schema.is(RuntimeMode); const isProviderDriverKind = Schema.is(ProviderDriverKind); export const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1"; -const COMPOSER_DRAFT_STORAGE_VERSION = 6; +const COMPOSER_DRAFT_STORAGE_VERSION = 7; const DraftThreadEnvModeSchema = Schema.Literals(["local", "worktree"]); export type DraftThreadEnvMode = typeof DraftThreadEnvModeSchema.Type; @@ -125,6 +127,7 @@ const PersistedComposerThreadDraftState = Schema.Struct({ attachments: Schema.Array(PersistedComposerImageAttachment), terminalContexts: Schema.optionalKey(Schema.Array(PersistedTerminalContextDraft)), elementContexts: Schema.optionalKey(Schema.Array(PersistedElementContextDraft)), + previewAnnotations: Schema.optionalKey(Schema.Array(PreviewAnnotationPayloadSchema)), // Keyed by `ProviderInstanceId` (open branded slug) so custom provider // instances (e.g. `codex_personal`) round-trip alongside the built-in // `codex` / `claudeAgent` / ... entries. Every prior `ProviderDriverKind` @@ -252,6 +255,7 @@ export interface ComposerThreadDraftState { * re-derive the snapshot from on reload. */ elementContexts: ElementContextDraft[]; + previewAnnotations: PreviewAnnotationPayload[]; /** * Per-instance model selection. Keyed by `ProviderInstanceId` (open * branded slug) so a default `codex` instance and a user-authored @@ -451,6 +455,15 @@ interface ComposerDraftStoreState { ) => void; removeElementContext: (threadRef: ComposerThreadTarget, contextId: string) => void; clearElementContexts: (threadRef: ComposerThreadTarget) => void; + addPreviewAnnotation: ( + threadRef: ComposerThreadTarget, + annotation: PreviewAnnotationPayload, + ) => void; + setPreviewAnnotations: ( + threadRef: ComposerThreadTarget, + annotations: ReadonlyArray, + ) => void; + removePreviewAnnotation: (threadRef: ComposerThreadTarget, annotationId: string) => void; clearPersistedAttachments: (threadRef: ComposerThreadTarget) => void; syncPersistedAttachments: ( threadRef: ComposerThreadTarget, @@ -527,10 +540,12 @@ const EMPTY_IDS: string[] = []; const EMPTY_PERSISTED_ATTACHMENTS: PersistedComposerImageAttachment[] = []; const EMPTY_TERMINAL_CONTEXTS: TerminalContextDraft[] = []; const EMPTY_ELEMENT_CONTEXTS: ElementContextDraft[] = []; +const EMPTY_PREVIEW_ANNOTATIONS: PreviewAnnotationPayload[] = []; Object.freeze(EMPTY_IMAGES); Object.freeze(EMPTY_IDS); Object.freeze(EMPTY_PERSISTED_ATTACHMENTS); Object.freeze(EMPTY_ELEMENT_CONTEXTS); +Object.freeze(EMPTY_PREVIEW_ANNOTATIONS); const EMPTY_MODEL_SELECTION_BY_PROVIDER: Partial> = Object.freeze({}); const EMPTY_COMPOSER_DRAFT_MODEL_STATE = Object.freeze({ @@ -545,6 +560,7 @@ const EMPTY_THREAD_DRAFT = Object.freeze({ persistedAttachments: EMPTY_PERSISTED_ATTACHMENTS, terminalContexts: EMPTY_TERMINAL_CONTEXTS, elementContexts: EMPTY_ELEMENT_CONTEXTS, + previewAnnotations: EMPTY_PREVIEW_ANNOTATIONS, modelSelectionByProvider: EMPTY_MODEL_SELECTION_BY_PROVIDER, activeProvider: null, runtimeMode: null, @@ -565,6 +581,7 @@ export function createEmptyThreadDraft(): ComposerThreadDraftState { persistedAttachments: [], terminalContexts: [], elementContexts: [], + previewAnnotations: [], modelSelectionByProvider: {}, activeProvider: null, runtimeMode: null, @@ -636,6 +653,7 @@ function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean { draft.persistedAttachments.length === 0 && draft.terminalContexts.length === 0 && draft.elementContexts.length === 0 && + draft.previewAnnotations.length === 0 && Object.keys(draft.modelSelectionByProvider).length === 0 && draft.activeProvider === null && draft.runtimeMode === null && @@ -1776,6 +1794,7 @@ function partializeComposerDraftStoreState( draft.persistedAttachments.length === 0 && draft.terminalContexts.length === 0 && draft.elementContexts.length === 0 && + draft.previewAnnotations.length === 0 && !hasModelData && draft.runtimeMode === null && draft.interactionMode === null @@ -1815,6 +1834,13 @@ function partializeComposerDraftStoreState( })), } : {}), + ...(draft.previewAnnotations.length > 0 + ? { + previewAnnotations: draft.previewAnnotations.map( + (annotation) => ({ ...annotation }) as DeepMutable, + ), + } + : {}), ...(hasModelData ? { modelSelectionByProvider: compactModelSelectionByProvider( @@ -2055,6 +2081,8 @@ function toHydratedThreadDraft( persistedDraft.elementContexts?.map((context) => ({ ...context, })) ?? [], + previewAnnotations: + persistedDraft.previewAnnotations?.map((annotation) => ({ ...annotation })) ?? [], modelSelectionByProvider, activeProvider, runtimeMode: persistedDraft.runtimeMode ?? null, @@ -3057,6 +3085,69 @@ const composerDraftStore = create()( return { draftsByThreadKey: nextDraftsByThreadKey }; }); }, + addPreviewAnnotation: (threadRef, annotation) => { + const threadKey = resolveComposerDraftKey(get(), threadRef); + if (!threadKey) return; + set((state) => { + const existing = state.draftsByThreadKey[threadKey] ?? createEmptyThreadDraft(); + const nextAnnotations = existing.previewAnnotations.filter( + (entry) => entry.id !== annotation.id, + ); + const compactAnnotation: PreviewAnnotationPayload = { + ...annotation, + screenshot: annotation.screenshot ? { ...annotation.screenshot, dataUrl: "" } : null, + }; + return { + draftsByThreadKey: { + ...state.draftsByThreadKey, + [threadKey]: { + ...existing, + previewAnnotations: [...nextAnnotations, compactAnnotation], + }, + }, + }; + }); + }, + setPreviewAnnotations: (threadRef, annotations) => { + const threadKey = resolveComposerDraftKey(get(), threadRef); + if (!threadKey) return; + set((state) => { + const existing = state.draftsByThreadKey[threadKey] ?? createEmptyThreadDraft(); + return { + draftsByThreadKey: { + ...state.draftsByThreadKey, + [threadKey]: { ...existing, previewAnnotations: [...annotations] }, + }, + }; + }); + }, + removePreviewAnnotation: (threadRef, annotationId) => { + const threadKey = resolveComposerDraftKey(get(), threadRef); + if (!threadKey || !annotationId) return; + set((state) => { + const current = state.draftsByThreadKey[threadKey]; + if (!current) return state; + const previewAnnotations = current.previewAnnotations.filter( + (entry) => entry.id !== annotationId, + ); + if (previewAnnotations.length === current.previewAnnotations.length) return state; + const nextDraft = { + ...current, + previewAnnotations, + images: current.images.filter((image) => image.id !== annotationId), + persistedAttachments: current.persistedAttachments.filter( + (image) => image.id !== annotationId, + ), + nonPersistedImageIds: current.nonPersistedImageIds.filter( + (imageId) => imageId !== annotationId, + ), + }; + const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; + if (shouldRemoveDraft(nextDraft)) delete nextDraftsByThreadKey[threadKey]; + else nextDraftsByThreadKey[threadKey] = nextDraft; + return { draftsByThreadKey: nextDraftsByThreadKey }; + }); + }, clearPersistedAttachments: (threadRef) => { const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; if (threadKey.length === 0) { @@ -3130,6 +3221,7 @@ const composerDraftStore = create()( persistedAttachments: [], terminalContexts: [], elementContexts: [], + previewAnnotations: [], }; const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; if (shouldRemoveDraft(nextDraft)) { diff --git a/apps/web/src/lib/previewAnnotation.test.ts b/apps/web/src/lib/previewAnnotation.test.ts index 5dc5c0b6601..05f4b8d6273 100644 --- a/apps/web/src/lib/previewAnnotation.test.ts +++ b/apps/web/src/lib/previewAnnotation.test.ts @@ -1,7 +1,11 @@ import type { PreviewAnnotationPayload } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { appendPreviewAnnotationPrompt, buildPreviewAnnotationPrompt } from "./previewAnnotation"; +import { + appendPreviewAnnotationPrompt, + buildPreviewAnnotationPrompt, + extractTrailingPreviewAnnotation, +} from "./previewAnnotation"; const annotation: PreviewAnnotationPayload = { id: "annotation_1", @@ -53,8 +57,31 @@ describe("preview annotations", () => { it("appends to an existing composer prompt", () => { expect( appendPreviewAnnotationPrompt("Fix this", annotation).startsWith( - "Fix this\n\nPreview annotation:", + "Fix this\n\n", ), ).toBe(true); }); + + it("extracts annotation presentation from a sent prompt", () => { + const result = extractTrailingPreviewAnnotation( + appendPreviewAnnotationPrompt("Fix this", annotation), + ); + expect(result.promptText).toBe("Fix this"); + expect(result.annotation).toMatchObject({ + title: "Example", + targetSummary: "1 marked region, 1 drawing.", + hasScreenshot: true, + }); + }); + + it("extracts multiple trailing annotations one at a time", () => { + const first = appendPreviewAnnotationPrompt("Fix this", annotation); + const secondAnnotation = { ...annotation, id: "annotation_2", pageTitle: "Details" }; + const second = appendPreviewAnnotationPrompt(first, secondAnnotation); + const extractedSecond = extractTrailingPreviewAnnotation(second); + const extractedFirst = extractTrailingPreviewAnnotation(extractedSecond.promptText); + expect(extractedSecond.annotation?.id).toBe("annotation_2"); + expect(extractedFirst.annotation?.id).toBe("annotation_1"); + expect(extractedFirst.promptText).toBe("Fix this"); + }); }); diff --git a/apps/web/src/lib/previewAnnotation.ts b/apps/web/src/lib/previewAnnotation.ts index c5d3747967d..f1723dd93aa 100644 --- a/apps/web/src/lib/previewAnnotation.ts +++ b/apps/web/src/lib/previewAnnotation.ts @@ -1,8 +1,29 @@ import type { PreviewAnnotationPayload } from "@t3tools/contracts"; +import { buildElementContextBlock, normalizeElementContextSelection } from "./elementContext"; + +const TRAILING_PREVIEW_ANNOTATION_BLOCK_PATTERN = + /\n*\n((?:(?!)[\s\S])*)\n<\/preview_annotation>\s*$/; + +export interface ParsedPreviewAnnotation { + id: string; + title: string; + comment: string; + targetSummary: string; + styleChanges: string[]; + hasScreenshot: boolean; +} + +export interface ExtractedPreviewAnnotation { + promptText: string; + annotation: ParsedPreviewAnnotation | null; +} export function buildPreviewAnnotationPrompt(annotation: PreviewAnnotationPayload): string { const lines = ["Preview annotation:"]; - if (annotation.comment.trim()) lines.push(annotation.comment.trim()); + lines.push(`Id: ${annotation.id}`); + const title = annotation.pageTitle?.trim() || annotation.pageUrl.trim() || "Preview"; + lines.push(`Page: ${title}`); + if (annotation.comment.trim()) lines.push(`Comment: ${annotation.comment.trim()}`); const targets: string[] = []; if (annotation.elements.length > 0) { targets.push( @@ -29,7 +50,12 @@ export function buildPreviewAnnotationPrompt(annotation: PreviewAnnotationPayloa if (annotation.screenshot) { lines.push("The attached screenshot is the annotated preview crop."); } - return lines.join("\n"); + const elementContexts = annotation.elements + .map((target) => normalizeElementContextSelection(target.element)) + .filter((context) => context !== null); + const elementBlock = buildElementContextBlock(elementContexts); + if (elementBlock) lines.push(elementBlock); + return ["", ...lines, ""].join("\n"); } export function appendPreviewAnnotationPrompt( @@ -41,6 +67,38 @@ export function appendPreviewAnnotationPrompt( return trimmed ? `${trimmed}\n\n${annotationText}` : annotationText; } +export function extractTrailingPreviewAnnotation(prompt: string): ExtractedPreviewAnnotation { + const match = TRAILING_PREVIEW_ANNOTATION_BLOCK_PATTERN.exec(prompt); + if (!match) return { promptText: prompt, annotation: null }; + const body = match[1] ?? ""; + const lines = body.split("\n"); + const pageLine = lines.find((line) => line.startsWith("Page: ")); + const idLine = lines.find((line) => line.startsWith("Id: ")); + const commentLine = lines.find((line) => line.startsWith("Comment: ")); + const targetsLine = lines.find((line) => line.startsWith("Targets: ")); + const styleHeadingIndex = lines.indexOf("Requested visual changes:"); + const linesAfterStyleHeading = lines.slice(styleHeadingIndex + 1); + const elementContextIndex = linesAfterStyleHeading.indexOf(""); + const styleChanges = + styleHeadingIndex < 0 + ? [] + : linesAfterStyleHeading + .slice(0, elementContextIndex < 0 ? undefined : elementContextIndex) + .filter((line) => line.startsWith("- ")) + .map((line) => line.slice(2)); + return { + promptText: prompt.slice(0, match.index).replace(/\n+$/, ""), + annotation: { + id: idLine?.slice("Id: ".length).trim() || `${match.index}`, + title: pageLine?.slice("Page: ".length).trim() || "Preview annotation", + comment: commentLine?.slice("Comment: ".length).trim() || "", + targetSummary: targetsLine?.slice("Targets: ".length).trim() || "", + styleChanges, + hasScreenshot: body.includes("The attached screenshot is the annotated preview crop."), + }, + }; +} + export async function previewAnnotationScreenshotFile( annotation: PreviewAnnotationPayload, ): Promise { From adff23234454e04664f3bbaacaf8cce5c45236d4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 13 Jun 2026 21:56:54 -0700 Subject: [PATCH 24/25] Remove outdated plans for shared HTTP MCP server and visible preview browser automation via CDP. Consolidate and refine the architecture and process models for the new preview automation framework, including updated contracts, server-side broker, and desktop integration. Introduce new WS methods for client communication and enhance security measures for token management. Ensure comprehensive testing strategies are in place for all components. --- .../006-renderer-failure-boundary.md | 35 - .../007-playwright-injected-runtime.md | 40 - .../008-recording-endurance.md | 32 - .../009-loopback-threat-model.md | 33 - .../010-human-input-interruption.md | 23 - .plans/browser-phase-0-5/README.md | 23 - .plans/browser-phase-0-5/findings.md | 27 - .plans/browser-phase-0/001-browser-host.md | 24 - .../browser-phase-0/002-automation-adapter.md | 33 - .plans/browser-phase-0/003-recording.md | 33 - .../004-remote-preview-tunnel.md | 38 - .plans/browser-phase-0/005-desktop-routing.md | 24 - .plans/browser-phase-0/README.md | 33 - .plans/browser-phase-0/findings.md | 156 -- .plans/browser-phase-0/results/.gitignore | 3 - .../spikes/electron-webview-bootstrap.cjs | 16 - .../spikes/electron-webview-host.mjs | 736 ------ .plans/browser-phase-0/spikes/guest.html | 41 - .../browser-phase-0/spikes/host-preload.cjs | 51 - .plans/browser-phase-0/spikes/host.html | 12 - .../spikes/run-electron-spikes.mjs | 191 -- .../spikes/run-gateway-spike.mjs | 243 -- .../spikes/run-tunnel-security-spike.mjs | 98 - .../spikes/tunnel-security-electron.cjs | 16 - .../collaborative-browser-architecture.html | 1981 ----------------- ...borative-browser-runtime-next-iteration.md | 1015 --------- ...-server-with-preview-automation-revised.md | 520 ----- ...-preview-browser-automation-via-cdp-mcp.md | 670 ------ 28 files changed, 6147 deletions(-) delete mode 100644 .plans/browser-phase-0-5/006-renderer-failure-boundary.md delete mode 100644 .plans/browser-phase-0-5/007-playwright-injected-runtime.md delete mode 100644 .plans/browser-phase-0-5/008-recording-endurance.md delete mode 100644 .plans/browser-phase-0-5/009-loopback-threat-model.md delete mode 100644 .plans/browser-phase-0-5/010-human-input-interruption.md delete mode 100644 .plans/browser-phase-0-5/README.md delete mode 100644 .plans/browser-phase-0-5/findings.md delete mode 100644 .plans/browser-phase-0/001-browser-host.md delete mode 100644 .plans/browser-phase-0/002-automation-adapter.md delete mode 100644 .plans/browser-phase-0/003-recording.md delete mode 100644 .plans/browser-phase-0/004-remote-preview-tunnel.md delete mode 100644 .plans/browser-phase-0/005-desktop-routing.md delete mode 100644 .plans/browser-phase-0/README.md delete mode 100644 .plans/browser-phase-0/findings.md delete mode 100644 .plans/browser-phase-0/results/.gitignore delete mode 100644 .plans/browser-phase-0/spikes/electron-webview-bootstrap.cjs delete mode 100644 .plans/browser-phase-0/spikes/electron-webview-host.mjs delete mode 100644 .plans/browser-phase-0/spikes/guest.html delete mode 100644 .plans/browser-phase-0/spikes/host-preload.cjs delete mode 100644 .plans/browser-phase-0/spikes/host.html delete mode 100644 .plans/browser-phase-0/spikes/run-electron-spikes.mjs delete mode 100644 .plans/browser-phase-0/spikes/run-gateway-spike.mjs delete mode 100644 .plans/browser-phase-0/spikes/run-tunnel-security-spike.mjs delete mode 100644 .plans/browser-phase-0/spikes/tunnel-security-electron.cjs delete mode 100644 .plans/collaborative-browser-architecture.html delete mode 100644 .plans/collaborative-browser-runtime-next-iteration.md delete mode 100644 .plans/shared-http-mcp-server-with-preview-automation-revised.md delete mode 100644 .plans/visible-preview-browser-automation-via-cdp-mcp.md diff --git a/.plans/browser-phase-0-5/006-renderer-failure-boundary.md b/.plans/browser-phase-0-5/006-renderer-failure-boundary.md deleted file mode 100644 index b689927e2d1..00000000000 --- a/.plans/browser-phase-0-5/006-renderer-failure-boundary.md +++ /dev/null @@ -1,35 +0,0 @@ -# ADR 006: Renderer Failure Boundary - -## Status - -Accepted, revising ADR 001 terminology and failure handling. - -## Decision - -Keep the renderer-hosted Electron `` for the next implementation iteration because it supports normal renderer composition, annotations, and the selected covered-surface recording strategy. - -Do not describe this as process-durable. It is: - -- durable across React unmounts, route changes, panel closure, and tab switching; -- not durable across host-renderer reload, host-renderer crash, window destruction, or application restart. - -The logical `BrowserSession` and `BrowserTab` records survive host loss. The live page process state does not. When the host disappears, the environment server marks the host unavailable and tabs as lost rather than pretending the DOM, JavaScript heap, sockets, and history can be recovered. - -## Evidence - -The `renderer-reload` spike created both a renderer `` and a main-owned `WebContentsView`, modified state in each, and reloaded the host renderer: - -- `` guest id changed from `2` to `4`, the first guest was destroyed, and count reset from `1` to `0`; -- `WebContentsView` retained id `3`, remained alive, and retained count `1`. - -## Product Requirements - -- Show an explicit "browser page was lost" state with last known URL, title, screenshot, and artifacts. -- Offer restart-and-restore as a new browser process, clearly reporting which state cannot be restored. -- Never silently bind a replacement guest to the old live-process identity. -- Bind every desktop browser host to one window id and define window-close as host loss. -- A future multi-window implementation must explicitly move or restart sessions; `` elements cannot be reparented across renderer documents. - -## Rejected for This Iteration - -Migrating the collaborative surface to `WebContentsView` solely for renderer-reload survival remains rejected because Phase 0 found hidden/detached recording and overlay composition unsuitable. The trade is now explicit: collaborative composition and recording are prioritized over host-renderer reload survival. diff --git a/.plans/browser-phase-0-5/007-playwright-injected-runtime.md b/.plans/browser-phase-0-5/007-playwright-injected-runtime.md deleted file mode 100644 index 5a2c3a262b7..00000000000 --- a/.plans/browser-phase-0-5/007-playwright-injected-runtime.md +++ /dev/null @@ -1,40 +0,0 @@ -# ADR 007: Playwright Injected Runtime - -## Status - -Accepted as the initial implementation direction; supersedes the bespoke interpretation of ADR 002. - -## Decision - -Load a version-pinned Playwright injected runtime into each guest frame through the persistent CDP connection. Use it for selector parsing, role/name/text/test-id resolution, shadow DOM traversal, element-state checks, hit-target checks, and locator re-resolution. - -T3 continues to own: - -- browser sessions, hosts, tabs, command queues, authorization, and cancellation; -- CDP target discovery and per-frame execution contexts; -- native input dispatch, navigation, dialogs, downloads, artifacts, and remote reachability; -- the stable T3 automation contract exposed to agents. - -Do not expose Playwright internal objects directly as the product API. - -## Evidence - -The `injected-runtime` spike extracted the runtime bundled with installed Playwright 1.60.0 and evaluated it inside the real Electron guest over CDP: - -- injected source size: `307,101` bytes; -- install time: approximately `6.5–8.5 ms` across the recorded runs; -- role/name locator matched the button; -- role/name locator pierced an open shadow root; -- the same locator re-resolved after the input element was replaced; -- same-origin frames still require a separate injected runtime and routing context. - -## Constraints - -- Playwright's injected runtime is internal and version-coupled. Pin its version and run compatibility fixtures on upgrades. -- OOPIFs remain separate CDP targets. The Browser Control Service must enable target auto-attach and maintain frame-to-session routing. -- Locators are durable query descriptions resolved at action time. Snapshot node references are optional, short-lived accelerators only. -- The T3 adapter must compare key behavior against real Playwright tests for actionability, shadow DOM, frames, rerenders, and strictness. - -## Fallback - -If a future Playwright release makes the injected runtime impractical to consume, retain the T3 contract and replace only the internal locator provider. Do not leak the vendored runtime across architecture boundaries. diff --git a/.plans/browser-phase-0-5/008-recording-endurance.md b/.plans/browser-phase-0-5/008-recording-endurance.md deleted file mode 100644 index 9367eb7fb1d..00000000000 --- a/.plans/browser-phase-0-5/008-recording-endurance.md +++ /dev/null @@ -1,32 +0,0 @@ -# ADR 008: Covered Recording Is Provisional - -## Status - -Accepted with concurrency and platform gates. - -## Decision - -Keep CDP screencast plus Chromium MediaRecorder and the covered full-size parking surface for the first recording implementation. - -Default to one actively recorded tab per desktop window until multi-tab budgets are measured. Hidden idle tabs remain offscreen; hidden recording tabs remain composited and covered. - -## Evidence - -The `recording-endurance` spike recorded a `1600×1200` covered webview for ten seconds at a requested 12 fps: - -- 111–112 sampled frames, approximately 11.1–11.2 fps across the recorded runs; -- 9.98-second seekable H.264 MP4; -- main-process CPU sample approximately 3.5–3.8%; -- observed renderer working sets approximately 146 MiB and 99 MiB, plus GPU working set approximately 149 MiB. - -This is stronger than the original 800×600 proof, but it is not a production concurrency budget. - -## Required Before Raising Concurrency - -- 30-minute soak with active timers, animation, HMR, and navigation; -- two and four simultaneous recording tabs; -- Retina device-scale-factor coverage; -- window resize, minimization, macOS Spaces, sleep/wake, and display changes; -- explicit `backgroundThrottling` policy and regression tests; -- proof that the cover cannot receive or leak pointer input to the parked guest; -- bounded frame queues with drop metrics and encoder-failure finalization. diff --git a/.plans/browser-phase-0-5/009-loopback-threat-model.md b/.plans/browser-phase-0-5/009-loopback-threat-model.md deleted file mode 100644 index 91c5fbdb2b5..00000000000 --- a/.plans/browser-phase-0-5/009-loopback-threat-model.md +++ /dev/null @@ -1,33 +0,0 @@ -# ADR 009: Loopback Is Not an Authorization Boundary - -## Status - -Accepted, revising ADR 004. - -## Threat - -A listener bound to `127.0.0.1` can be reached by any local process. Browser content can also cause cross-origin requests to it even when CORS prevents reading the response. Possession of a loopback port therefore cannot authorize access to a remote environment. - -## Evidence - -The `run-tunnel-security-spike.mjs` probe loaded an unrelated page in Chromium and issued a `no-cors` request to another loopback port. The request reached the preview listener. The request did not include an `Origin` header, so origin-header validation alone would not reject it. - -The same probe confirmed: - -- local storage survives when the exact loopback authority remains stable; -- changing only the port creates a new origin and loses that storage; -- an absolute HTTPS redirect passes through unchanged and escapes the loopback authority. - -## Decision - -- Allocate one stable local authority per environment/project/target tuple for the lifetime of its origin grant. -- Treat the authority as a browser capability, not a random open port. -- The desktop browser host must restrict navigation to authorities granted to that browser session and prevent unrelated tabs from using preview authorities. -- Environment tunnel grants remain scoped to environment, session, target host, and target port, but server-side grants do not protect the desktop listener from local callers. -- Add a desktop-side connection broker that verifies the connecting browser guest belongs to the authorized session. A bare TCP listener without browser attribution is insufficient for the final design. -- Absolute redirects, OAuth callbacks, HTTPS upstreams, service workers, and configured public origins require explicit compatibility policy rather than a "no rewriting" guarantee. -- Replace one-WebSocket-per-TCP-stream before relay rollout unless load testing proves the relay limits acceptable. The target design is bounded multiplexing over dedicated authenticated tunnel connections. - -## Open Implementation Choice - -The browser-attribution mechanism needs a dedicated spike. Candidates include an Electron protocol/proxy integration, per-session proxy configuration, or another browser-mediated connection path that preserves ordinary application semantics while identifying the requesting guest. Port secrecy is explicitly rejected. diff --git a/.plans/browser-phase-0-5/010-human-input-interruption.md b/.plans/browser-phase-0-5/010-human-input-interruption.md deleted file mode 100644 index 4ad3fe5d13f..00000000000 --- a/.plans/browser-phase-0-5/010-human-input-interruption.md +++ /dev/null @@ -1,23 +0,0 @@ -# ADR 010: Human Input Interruption - -## Status - -Accepted for the initial lease implementation, with regression coverage. - -## Decision - -Observe native user keyboard and pointer activity at the Electron guest/window boundary and revoke or pause the conflicting automation lease. Do not implement a timing-based suppression window unless a supported platform demonstrates that CDP-dispatched input is reported through the same native event path. - -## Evidence - -The `input-origin` spike attached Electron's `before-input-event` listener to the guest and dispatched keyboard input through CDP. No `before-input-event` was emitted. - -This contradicts the assumption that CDP keyboard input is necessarily indistinguishable from human input at this boundary. It does not prove every Electron version, platform, input method, or pointer path behaves identically. - -## Requirements - -- Cover keyboard, pointer, wheel, touch, IME, accessibility input, and DevTools interaction where available. -- Record the interruption reason and the command that was cancelled. -- Serialize lease transition and command cancellation so a late automation event cannot land after the user takes control. -- Keep a regression fixture for CDP keyboard and mouse dispatch on every supported desktop platform. -- Remove the undefined `shared` controller mode. Initial modes are `human`, `agent`, and `none`. diff --git a/.plans/browser-phase-0-5/README.md b/.plans/browser-phase-0-5/README.md deleted file mode 100644 index e0e183d23f3..00000000000 --- a/.plans/browser-phase-0-5/README.md +++ /dev/null @@ -1,23 +0,0 @@ -# Browser Phase 0.5 - -Phase 0.5 closes the production-risk questions raised after the original browser spikes. It does not add application behavior. It expands the executable harness and revises the architecture decisions before session implementation begins. - -## Commands - -```bash -node .plans/browser-phase-0/spikes/run-electron-spikes.mjs renderer-reload -node .plans/browser-phase-0/spikes/run-electron-spikes.mjs injected-runtime -node .plans/browser-phase-0/spikes/run-electron-spikes.mjs recording-endurance -node .plans/browser-phase-0/spikes/run-electron-spikes.mjs input-origin -node .plans/browser-phase-0/spikes/run-tunnel-security-spike.mjs -``` - -## Decisions - -- `006-renderer-failure-boundary.md`: keep `` for this iteration, but describe it as route-durable rather than process-durable and specify session-loss behavior. -- `007-playwright-injected-runtime.md`: use Playwright's injected runtime as the initial semantic locator/actionability substrate, behind a T3-owned adapter and compatibility tests. -- `008-recording-endurance.md`: retain covered parking provisionally, with a one-recording default and further soak/platform tests required before widening concurrency. -- `009-loopback-threat-model.md`: a plain loopback listener is not an authorization boundary; stable origins and a browser-enforced capability authority are required. -- `010-human-input-interruption.md`: Electron `before-input-event` did not observe CDP keyboard dispatch in the tested guest, so human interruption can start with native input observation without a dispatch suppression window, while retaining regression coverage. - -See `findings.md` for measured results and the resulting roadmap changes. diff --git a/.plans/browser-phase-0-5/findings.md b/.plans/browser-phase-0-5/findings.md deleted file mode 100644 index 27f75501aba..00000000000 --- a/.plans/browser-phase-0-5/findings.md +++ /dev/null @@ -1,27 +0,0 @@ -# Phase 0.5 Findings - -## Executive Decisions - -1. The selected `` is route-durable, not process-durable. Renderer reload destroys its guest and state; `WebContentsView` survives that specific failure boundary. -2. Use Playwright's injected runtime behind the T3 Browser Control Service instead of implementing locator and actionability semantics from scratch. -3. Use locator descriptions that re-resolve at action time. Treat snapshot element references as ephemeral. -4. Covered recording remains viable at 1600×1200 for one tab, but concurrency and long-duration operation remain gated. -5. A loopback port is not an authorization capability. Stable origin, browser attribution, and bounded tunnel multiplexing are required before remote preview ships. -6. CDP keyboard dispatch did not trigger Electron `before-input-event` in the tested guest. Start with native input interruption and no timing suppression hack. - -## Roadmap Changes - -- Add host loss, window identity, session-loss UX, annotation preservation, port discovery, and guest hardening to the session foundation phase. -- Put persistent CDP, Playwright injected-runtime integration, console buffers, and network buffers in the automation phase. -- Add OOPIF target routing and per-frame injected runtimes to the automation acceptance criteria. -- Move remote preview behind a second browser-attribution spike; raw TCP transport feasibility alone is insufficient. -- Keep one active recording per window as the initial policy. -- Remove `shared` controller mode until concurrency semantics exist. - -## Remaining Open Questions - -- Browser-attributed local tunnel ingress that preserves application origin behavior. -- Multi-tab recording budgets and 30-minute platform soak results. -- Pointer/touch interruption behavior across macOS, Windows, and Linux. -- Recovery fidelity after renderer/window loss. -- Web-app viewing UX for separately hosted environment Chromium sessions. diff --git a/.plans/browser-phase-0/001-browser-host.md b/.plans/browser-phase-0/001-browser-host.md deleted file mode 100644 index ab10e518893..00000000000 --- a/.plans/browser-phase-0/001-browser-host.md +++ /dev/null @@ -1,24 +0,0 @@ -# ADR 001: Durable Renderer-Hosted Webview - -## Status - -Accepted with the failure-boundary amendment in `.plans/browser-phase-0-5/006-renderer-failure-boundary.md`. - -## Decision - -Keep Electron `` as the collaborative browser surface, but move it out of thread panel lifecycle into a route-durable app-level browser host. This does not survive host-renderer reload, renderer crash, owning-window close, or application restart. - -The host owns one mounted webview per live browser tab. The visible browser panel supplies bounds and visibility intent. Hiding, switching routes, closing a sheet, or unmounting a panel must not remove the webview element or close its guest `WebContents`. - -The desktop main process remains authoritative for navigation, CDP, recording, security policy, and guest `WebContents` validation. The renderer host is responsible only for maintaining the required DOM attachment and reporting presentation bounds. - -## Hidden Presentation Policy - -- Visible tab: place the webview at the panel bounds. -- Hidden idle tab: park it offscreen at its preserved viewport size; automation remains available but continuous screencast is not expected. -- Hidden recording tab: place it on a full-size in-window parking surface covered by an opaque application layer so Chromium continues producing compositor frames. -- Suspended tab: explicit lifecycle transition that may destroy and later reload the page; never perform silently while controlled or recording. - -## Rejected Alternative - -`WebContentsView` is not selected for this iteration. It is durable and Playwright-visible, but hidden/detached capture semantics failed the recording requirements and its native stacking model complicates overlays and clipping. diff --git a/.plans/browser-phase-0/002-automation-adapter.md b/.plans/browser-phase-0/002-automation-adapter.md deleted file mode 100644 index a0bd889fbad..00000000000 --- a/.plans/browser-phase-0/002-automation-adapter.md +++ /dev/null @@ -1,33 +0,0 @@ -# ADR 002: Browser-Client-Style Semantic CDP Adapter - -## Status - -Superseded in implementation detail by `.plans/browser-phase-0-5/007-playwright-injected-runtime.md`. - -## Decision - -Build the primary automation adapter over a persistent Electron debugger connection using CDP accessibility, DOM, runtime, page, network, and input domains. - -The adapter should offer Playwright-like concepts—role/name locators, text locators, frames, auto-waiting, stale references, dialogs, downloads, and actionability checks—but it must not depend on Playwright owning the browser or recognizing the guest as a `Page`. - -Use two layers: - -1. main-process CDP session and command/event transport -2. the version-pinned Playwright injected runtime behind a T3-owned compatibility adapter - -Locator descriptions re-resolve at action time. Snapshot-scoped backend-node references are optional and ephemeral. - -## Playwright Role - -Use Playwright as: - -- a behavior reference -- a test oracle against standalone test pages -- the initial injected selector/actionability runtime -- an optional adapter for future environment-hosted Chromium sessions - -Do not expose a production Electron remote-debugging port merely to attach Playwright. - -## agent-browser Role - -agent-browser may be supported through a compatible CLI or command adapter backed by the same Browser Control Service. It must not launch an unrelated browser for collaborative preview sessions. diff --git a/.plans/browser-phase-0/003-recording.md b/.plans/browser-phase-0/003-recording.md deleted file mode 100644 index 7eff27070f1..00000000000 --- a/.plans/browser-phase-0/003-recording.md +++ /dev/null @@ -1,33 +0,0 @@ -# ADR 003: CDP Capture with Chromium MediaRecorder - -## Status - -Accepted with the concurrency and soak gates in `.plans/browser-phase-0-5/008-recording-endurance.md`. - -## Decision - -Capture the collaborative guest page with `Page.startScreencast`, sample frames at the configured recording frame rate, draw them into an isolated recording canvas, and encode through Chromium MediaRecorder. - -Initial artifact format: - -- H.264 MP4 -- `video/mp4;codecs=avc1.42E01E` -- default 12 fps -- default viewport resolution, bounded by recording policy - -Encoding runs in a dedicated hidden renderer or utility surface, not in the primary React render path. Frame transport must be bounded and may drop intermediate frames under pressure rather than accumulating memory. - -## Hidden Recording - -An offscreen webview does not continuously produce screencast frames. While recording a hidden tab, the browser host temporarily places the full-size webview on a covered parking surface. The user continues seeing the normal application UI, not the frame stream. - -## Artifact Semantics - -Video is paired with structured action events and timestamps. Video is evidence, not browser state and not the interactive UX. - -## Rejected Alternatives - -- external ffmpeg as a required encoder -- `desktopCapturer` capture of the whole application window -- JPEG/video streaming as the browser UI -- recording only when the preview panel is open diff --git a/.plans/browser-phase-0/004-remote-preview-tunnel.md b/.plans/browser-phase-0/004-remote-preview-tunnel.md deleted file mode 100644 index 7dc5c321743..00000000000 --- a/.plans/browser-phase-0/004-remote-preview-tunnel.md +++ /dev/null @@ -1,38 +0,0 @@ -# ADR 004: Raw TCP Preview Tunnel - -## Status - -Transport accepted; desktop ingress and authorization amended by `.plans/browser-phase-0-5/009-loopback-threat-model.md`. - -## Decision - -Remote environment ports are exposed to the desktop browser through: - -```text -browser webview - -> browser-attributed stable desktop authority - -> dedicated authenticated WebSocket tunnel - -> environment server tunnel endpoint - -> environment loopback TCP target -``` - -Multiplex TCP streams over a bounded number of dedicated authenticated tunnel connections before relay rollout. The protocol must preserve backpressure, half-close, cancellation, and connection-specific errors. - -The browser navigates to a stable desktop authority, preserving normal origin and root-relative URL behavior. A bare loopback listener is not an authorization boundary, and absolute redirects, OAuth callbacks, and HTTPS upstreams require explicit policy. - -## Isolation - -Tunnel data uses a dedicated connection and priority class. It must not share the normal RPC/event WebSocket because large responses or stalled browser streams must not delay thread control traffic. - -## Authorization - -Each listener is backed by a short-lived grant scoped to: - -- environment -- browser session -- target loopback host -- target port -- protocol policy -- provider/thread authorization where agent-created - -The environment server validates the grant before opening any TCP target. diff --git a/.plans/browser-phase-0/005-desktop-routing.md b/.plans/browser-phase-0/005-desktop-routing.md deleted file mode 100644 index 7508b327cfe..00000000000 --- a/.plans/browser-phase-0/005-desktop-routing.md +++ /dev/null @@ -1,24 +0,0 @@ -# ADR 005: Retain Renderer Transport Relay Initially - -## Status - -Accepted for Phase 1; revisit after lifecycle migration. - -## Decision - -Do not add a second environment-server connection from desktop main solely to remove renderer IPC. - -The current renderer-mediated transport can remain temporarily: - -```text -environment server -> renderer connection -> typed IPC -> desktop Browser Control Service -``` - -The renderer must no longer decide browser existence, visible-only eligibility, or ownership. It relays authenticated commands and browser-host events between transports. - -## Rationale - -Measured renderer IPC overhead was approximately 0.04 ms at the median and approximately 0.20 ms at p95 above direct CDP for a no-op command. That is negligible compared with page work and remote network latency. - -The architectural problem is lifecycle coupling and failure ownership, not local IPC performance. A direct desktop-main network connection may still be introduced later for reliability or background operation, but it is not required to begin the durable session migration. - diff --git a/.plans/browser-phase-0/README.md b/.plans/browser-phase-0/README.md deleted file mode 100644 index 5802f1a6040..00000000000 --- a/.plans/browser-phase-0/README.md +++ /dev/null @@ -1,33 +0,0 @@ -# Browser Phase 0 Spikes - -These spikes validate the architectural decisions recorded beside this file. They exercise Electron 41 against real browser surfaces rather than mocking browser lifecycle or CDP behavior. - -## Environment Used for Recorded Results - -- macOS 26.5.1, arm64 -- Apple M4 Max, 64 GiB RAM -- Electron 41.5.0 -- installed Playwright 1.60.0 -- Node.js 25.8.2 orchestration process - -Results are documented in `findings.md`. Generated frames, videos, screenshots, logs, and transient JSON files under `results/` are intentionally ignored. - -## Commands - -```bash -node .plans/browser-phase-0/spikes/run-electron-spikes.mjs automation -node .plans/browser-phase-0/spikes/run-electron-spikes.mjs view-automation -node .plans/browser-phase-0/spikes/run-electron-spikes.mjs hidden -node .plans/browser-phase-0/spikes/run-electron-spikes.mjs offscreen-recording -node .plans/browser-phase-0/spikes/run-electron-spikes.mjs covered-recording -node .plans/browser-phase-0/spikes/run-electron-spikes.mjs media-recorder -node .plans/browser-phase-0/spikes/run-electron-spikes.mjs latency -node .plans/browser-phase-0/spikes/run-gateway-spike.mjs -``` - -The Electron harness uses the repository's installed Electron and Playwright packages. The gateway harness uses the locked `ws` package already present in the pnpm installation. - -## Scope - -These are architectural probes, not production modules. They deliberately avoid changing application behavior. Production work must reimplement the selected patterns with typed contracts, Effect services, cancellation, security policy, tests, and resource cleanup. - diff --git a/.plans/browser-phase-0/findings.md b/.plans/browser-phase-0/findings.md deleted file mode 100644 index 54de397bbc1..00000000000 --- a/.plans/browser-phase-0/findings.md +++ /dev/null @@ -1,156 +0,0 @@ -# Browser Phase 0 Findings - -## Executive Result - -Phase 0 resolves the major architectural unknowns: - -1. Keep a real Electron `` as the collaborative browser surface for the next iteration. -2. Move that webview into a durable app-level host independent of panel and route mounting. -3. Build a browser-client-style semantic automation adapter over persistent CDP, not direct Playwright attachment. -4. Use CDP screencast for capture and Chromium MediaRecorder for seekable H.264 MP4 encoding. -5. Use a desktop loopback listener plus a dedicated authenticated raw-TCP-over-WebSocket tunnel for remote environment ports. -6. Keep the renderer as the desktop/server transport relay initially; remove its browser lifecycle authority, not the relay itself. - -## Automation Attachment - -### Existing `` - -Electron remote debugging exposed two targets: - -- host renderer: target type `page` -- guest browser: target type `webview` - -Playwright `chromium.connectOverCDP()` returned only the host renderer as a `Page`. Connecting directly to the guest target's debugger WebSocket succeeded at the transport level but produced zero Playwright pages. - -Conclusion: direct Playwright locators cannot drive the current guest webview through the supported `connectOverCDP` API. - -### Semantic CDP adapter - -The spike used: - -- `Accessibility.getFullAXTree` -- role and accessible-name matching -- `DOM.getBoxModel` -- `Input.dispatchMouseEvent` -- `DOM.resolveNode` -- `Runtime.callFunctionOn` - -It located and clicked a button by role/name and filled a textbox by role/name against the exact guest page. The resulting page state was correct. - -Conclusion: a browser-client-style adapter can provide semantic locators without Playwright owning or directly attaching to the guest page. - -### `WebContentsView` comparison - -A main-process-owned `WebContentsView` appears as a normal Playwright `Page`; Playwright role and textbox locators succeeded. - -However: - -- hidden or detached view capture was inconsistent or unavailable -- detached `capturePage()` reported no current display surface -- detached CDP screenshot could hang without an explicit timeout -- hidden/detached CDP screencast emitted zero frames -- native child views add clipping, stacking, and renderer-overlay integration costs - -Conclusion: do not migrate the collaborative browser to `WebContentsView` in this iteration merely to obtain direct Playwright attachment. - -## Hidden Lifecycle - -For the existing ``: - -- the window was hidden for 1.5 seconds -- the page timer advanced 15 ticks -- semantic automation incremented page state -- `capturePage()` returned a 1600×1200 Retina capture -- the page remained alive and controllable - -Observed process snapshot for the minimal two-renderer harness: - -- main-process private memory: approximately 43 MiB -- Electron Browser working set: approximately 172 MiB -- GPU process working set: approximately 100 MiB -- host renderer working set: approximately 88 MiB -- guest renderer working set: approximately 93 MiB -- measured main-process CPU during the 1.5-second hidden interval: approximately 0.04% - -These are development-machine snapshots, not production budgets. They prove lifecycle behavior and establish that each retained page has meaningful renderer memory cost. - -## Recording - -### CDP screencast behavior - -`Page.startScreencast` against the guest webview produced frames while: - -- the whole BrowserWindow was hidden -- the webview remained full-size and was covered by an opaque renderer element - -Moving the webview offscreen reduced the stream to one frame. Therefore hidden idle parking and hidden recording require different presentation policies. - -### External ffmpeg proof - -The initial proof sampled CDP frames at 12 fps and encoded H.264 MP4 with ffmpeg: - -- 28 frames -- 800×600 -- 12 fps -- 2.33-second duration -- valid seekable MP4 - -This proved the capture pipeline but is not the selected production encoder because shipping an external ffmpeg executable is unnecessary. - -### Selected Chromium-native encoder - -The final proof sent sampled CDP frames into an isolated canvas and used Chromium MediaRecorder: - -- MIME: `video/mp4;codecs=avc1.42E01E` -- codec: H.264 -- 800×600 -- approximately 11 fps from a requested 12 fps -- 2.4617-second duration -- 12,823-byte seekable MP4 artifact -- no external ffmpeg process used for encoding - -Conclusion: select CDP screencast plus Chromium MediaRecorder H.264 MP4 for the initial recording backend. - -## Remote Preview Tunnel - -Two proxy shapes were tested: - -1. HTTP-aware reverse proxy with WebSocket upgrade handling. -2. Desktop loopback TCP listener forwarding raw streams over a dedicated WebSocket to an environment-side TCP dialer. - -Both loaded the HTTP page and passed 100 WebSocket echo round trips. The raw TCP tunnel is selected because it preserves origin paths, root-relative assets, arbitrary HTTP semantics, HMR upgrades, and application WebSockets without rewriting response bodies. - -Five local runs of the raw TCP tunnel produced: - -- added HTTP median latency: 0.036–0.112 ms; median run 0.049 ms -- WebSocket median round-trip: 0.075–0.138 ms; median run 0.102 ms -- WebSocket p95 round-trip: 0.133–0.287 ms; median run 0.151 ms - -Network latency will dominate in real remote environments. The local result shows the framing/proxy architecture itself is not a meaningful latency source. - -## Desktop Routing Latency - -The spike compared 100 no-op CDP evaluations: - -- direct main-process CDP call -- renderer `ipcRenderer.invoke` relay to the same main-process CDP call - -Across five runs: - -- direct median: 0.058–0.066 ms; median run 0.060 ms -- renderer-relay median: 0.100 ms -- direct p95: 0.101–0.115 ms; median run 0.105 ms -- renderer-relay p95: approximately 0.300 ms - -Conclusion: renderer IPC overhead is negligible compared with browser work and remote networking. The renderer should lose lifecycle and ownership authority, but replacing the existing server-to-renderer transport relay is not a Phase 1 performance requirement. - -## Explicit Unsupported or Rejected Paths - -- Direct Playwright `connectOverCDP` to an Electron `` guest. -- Enabling an unauthenticated process-wide Chromium remote-debugging port in production. -- Switching to `WebContentsView` solely for Playwright compatibility. -- Recording from an offscreen or detached surface without temporarily restoring a composited parking surface. -- Using screenshots, JPEG frames, or video as the interactive browser presentation. -- HTTP path-prefix rewriting as the general remote preview solution. -- Sending preview tunnel data over the normal control/event WebSocket. - diff --git a/.plans/browser-phase-0/results/.gitignore b/.plans/browser-phase-0/results/.gitignore deleted file mode 100644 index a5baada18fd..00000000000 --- a/.plans/browser-phase-0/results/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -* -!.gitignore - diff --git a/.plans/browser-phase-0/spikes/electron-webview-bootstrap.cjs b/.plans/browser-phase-0/spikes/electron-webview-bootstrap.cjs deleted file mode 100644 index fd6d69adfce..00000000000 --- a/.plans/browser-phase-0/spikes/electron-webview-bootstrap.cjs +++ /dev/null @@ -1,16 +0,0 @@ -const { app } = require("electron"); - -const portArgument = process.argv.find((value) => value.startsWith("--port=")); -if (portArgument) { - app.commandLine.appendSwitch("remote-debugging-address", "127.0.0.1"); - app.commandLine.appendSwitch("remote-debugging-port", portArgument.slice("--port=".length)); -} -app.commandLine.appendSwitch("disable-background-timer-throttling"); - -app - .whenReady() - .then(() => import("./electron-webview-host.mjs")) - .catch((error) => { - console.error(error); - app.exit(1); - }); diff --git a/.plans/browser-phase-0/spikes/electron-webview-host.mjs b/.plans/browser-phase-0/spikes/electron-webview-host.mjs deleted file mode 100644 index 83f22322153..00000000000 --- a/.plans/browser-phase-0/spikes/electron-webview-host.mjs +++ /dev/null @@ -1,736 +0,0 @@ -import { spawnSync } from "node:child_process"; -import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; - -import electron from "electron"; - -const { app, BrowserWindow, ipcMain, WebContentsView } = electron; - -const args = Object.fromEntries( - process.argv - .slice(2) - .filter((value) => value.startsWith("--")) - .map((value) => { - const [key, ...rest] = value.slice(2).split("="); - return [key, rest.join("=") || "true"]; - }), -); - -const mode = args.mode ?? "automation"; -const debugPort = Number(args.port ?? 0); -const outputDirectory = args.output; -const playwrightCoreBundle = args["playwright-core-bundle"]; - -function debug(message) { - if (!outputDirectory) return; - mkdirSync(outputDirectory, { recursive: true }); - appendFileSync(join(outputDirectory, "debug.log"), `${new Date().toISOString()} ${message}\n`); -} - -debug(`startup electron=${process.versions.electron ?? "missing"}`); - -const here = dirname(fileURLToPath(import.meta.url)); -const guestUrl = new URL("./guest.html", import.meta.url).href; - -function emitResult(result) { - if (outputDirectory) { - mkdirSync(outputDirectory, { recursive: true }); - writeFileSync(join(outputDirectory, "result.json"), JSON.stringify(result, null, 2)); - } - process.stdout.write(`PHASE0_RESULT ${JSON.stringify(result)}\n`); -} - -function attachDebugger(contents) { - if (!contents.debugger.isAttached()) contents.debugger.attach("1.3"); - return (method, params) => contents.debugger.sendCommand(method, params); -} - -async function evaluate(send, expression) { - const result = await send("Runtime.evaluate", { - expression, - awaitPromise: true, - returnByValue: true, - }); - if (result.exceptionDetails) { - const description = result.exceptionDetails.exception?.description; - throw new Error(description ?? result.exceptionDetails.text ?? "Evaluation failed"); - } - return result.result?.value; -} - -function axValue(node, key) { - return node[key]?.value ?? null; -} - -async function runSemanticProbe(contents) { - const send = attachDebugger(contents); - await Promise.all([send("Runtime.enable"), send("DOM.enable"), send("Accessibility.enable")]); - const tree = await send("Accessibility.getFullAXTree"); - const buttonNode = tree.nodes.find( - (node) => axValue(node, "role") === "button" && axValue(node, "name") === "Increment count", - ); - const inputNode = tree.nodes.find( - (node) => axValue(node, "role") === "textbox" && axValue(node, "name") === "Message", - ); - if (!buttonNode?.backendDOMNodeId || !inputNode?.backendDOMNodeId) { - throw new Error("Semantic nodes were not found in the accessibility tree"); - } - const box = await send("DOM.getBoxModel", { backendNodeId: buttonNode.backendDOMNodeId }); - const quad = box.model.content; - const x = (quad[0] + quad[2] + quad[4] + quad[6]) / 4; - const y = (quad[1] + quad[3] + quad[5] + quad[7]) / 4; - await send("Input.dispatchMouseEvent", { type: "mousePressed", x, y, button: "left", clickCount: 1 }); - await send("Input.dispatchMouseEvent", { type: "mouseReleased", x, y, button: "left", clickCount: 1 }); - const resolvedInput = await send("DOM.resolveNode", { backendNodeId: inputNode.backendDOMNodeId }); - await send("Runtime.callFunctionOn", { - objectId: resolvedInput.object.objectId, - functionDeclaration: `function(value) { - this.focus(); - const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set; - setter.call(this, value); - this.dispatchEvent(new Event('input', { bubbles: true })); - this.dispatchEvent(new Event('change', { bubbles: true })); - }`, - arguments: [{ value: "semantic CDP attached" }], - }); - const state = await evaluate( - send, - `({ count: document.querySelector('#count').textContent, value: document.querySelector('#message').value })`, - ); - return { - success: state.count === "1" && state.value === "semantic CDP attached", - button: { role: axValue(buttonNode, "role"), name: axValue(buttonNode, "name") }, - input: { role: axValue(inputNode, "role"), name: axValue(inputNode, "name") }, - state, - }; -} - -function readPlaywrightInjectedSource() { - if (!playwrightCoreBundle) throw new Error("Playwright core bundle path is required"); - const bundle = readFileSync(playwrightCoreBundle, "utf8"); - const marker = "source3 = "; - const start = bundle.indexOf(marker); - const suffix = ";\n }\n});\n\n// packages/playwright-core/src/server/dom.ts"; - const end = bundle.indexOf(suffix, start); - if (start === -1 || end === -1) throw new Error("Could not locate Playwright injected source"); - const literal = bundle.slice(start + marker.length, end); - return Function(`"use strict"; return (${literal});`)(); -} - -async function runInjectedRuntimeSpike(contents) { - const send = attachDebugger(contents); - await send("Runtime.enable"); - const injectedSource = readPlaywrightInjectedSource(); - const installStartedAt = performance.now(); - const install = await evaluate( - send, - `(() => { - const module = {}; - ${injectedSource} - const injected = new (module.exports.InjectedScript())(globalThis, { - isUnderTest: false, - sdkLanguage: "javascript", - testIdAttributeName: "data-testid", - stableRafCount: 2, - browserName: "chromium", - shouldPrependErrorPrefix: false, - isUtilityWorld: true, - customEngines: [], - }); - globalThis.__phase05Injected = injected; - return { - methods: Object.getOwnPropertyNames(Object.getPrototypeOf(injected)).sort(), - bytes: ${Buffer.byteLength(injectedSource)}, - }; - })()`, - ); - const installMs = performance.now() - installStartedAt; - const probe = await evaluate( - send, - `(async () => { - const injected = globalThis.__phase05Injected; - const resolve = (selector) => { - const parsed = injected.parseSelector(selector); - return injected.querySelectorAll(parsed, document.documentElement); - }; - const roleSelector = 'internal:role=button[name="Increment count"i]'; - const buttonBefore = resolve(roleSelector); - const shadow = resolve('internal:role=button[name="Shadow action"i]'); - const inputLocator = 'internal:role=textbox[name="Message"i]'; - const inputBefore = resolve(inputLocator)[0]; - inputBefore.value = "before replacement"; - document.querySelector("#replace").click(); - const inputAfter = resolve(inputLocator)[0]; - inputAfter.value = "after replacement"; - const frame = document.querySelector("#same-origin-frame"); - await new Promise((resolveReady) => { - if (frame.contentDocument?.readyState === "complete") resolveReady(); - else frame.addEventListener("load", resolveReady, { once: true }); - }); - return { - roleMatches: buttonBefore.length, - shadowMatches: shadow.length, - locatorReresolvedAfterReplacement: inputBefore !== inputAfter && inputAfter.value === "after replacement", - sameOriginFrameRequiresSeparateRuntime: frame.contentDocument !== document, - }; - })()`, - ); - emitResult({ mode, install, installMs, probe }); - app.quit(); -} - -let rendererReloadStarted = false; -let rendererReloadNextGuestResolve; - -async function runRendererReloadSpike(window, firstGuest) { - rendererReloadStarted = true; - const firstSend = attachDebugger(firstGuest); - await firstSend("Runtime.enable"); - await evaluate(firstSend, `document.querySelector("#increment").click()`); - const firstState = await evaluate(firstSend, `document.querySelector("#count").textContent`); - const firstGuestId = firstGuest.id; - - const nativeView = new WebContentsView({ - webPreferences: { contextIsolation: true, nodeIntegration: false, partition: "persist:t3-phase05-view" }, - }); - nativeView.setBounds({ x: 0, y: 0, width: 1, height: 1 }); - window.contentView.addChildView(nativeView); - await nativeView.webContents.loadFile(join(here, "guest.html")); - await nativeView.webContents.executeJavaScript(`document.querySelector("#increment").click()`); - const nativeId = nativeView.webContents.id; - - const nextGuest = new Promise((resolveGuest) => { - rendererReloadNextGuestResolve = resolveGuest; - }); - await window.webContents.reload(); - const secondGuest = await Promise.race([ - nextGuest, - new Promise((_, reject) => setTimeout(() => reject(new Error("Replacement webview timed out")), 5_000)), - ]); - const secondSend = attachDebugger(secondGuest); - await secondSend("Runtime.enable"); - const secondState = await evaluate(secondSend, `document.querySelector("#count").textContent`); - const nativeState = await nativeView.webContents.executeJavaScript( - `document.querySelector("#count").textContent`, - ); - emitResult({ - mode, - webview: { - firstGuestId, - secondGuestId: secondGuest.id, - firstState, - secondState, - firstDestroyed: firstGuest.isDestroyed(), - survivedRendererReload: firstGuestId === secondGuest.id, - }, - webContentsView: { - idBefore: nativeId, - idAfter: nativeView.webContents.id, - stateAfter: nativeState, - destroyed: nativeView.webContents.isDestroyed(), - }, - }); - app.quit(); -} - -async function runInputOriginSpike(contents) { - const send = attachDebugger(contents); - await send("Input.enable").catch(() => undefined); - const beforeInputEvents = []; - contents.on("before-input-event", (_event, input) => beforeInputEvents.push(input)); - await send("Input.dispatchKeyEvent", { type: "keyDown", key: "a", code: "KeyA", text: "a" }); - await send("Input.dispatchKeyEvent", { type: "keyUp", key: "a", code: "KeyA" }); - await new Promise((resolveDelay) => setTimeout(resolveDelay, 100)); - emitResult({ - mode, - beforeInputEvents: beforeInputEvents.map(({ type, key, code, isAutoRepeat }) => ({ - type, - key, - code, - isAutoRepeat, - })), - cdpDispatchVisibleAsBeforeInput: beforeInputEvents.length > 0, - }); - app.quit(); -} - -async function runHiddenSpike(window, contents) { - const send = attachDebugger(contents); - await send("Runtime.enable"); - contents.setBackgroundThrottling(false); - const before = await evaluate(send, `({ ticks: Number(document.querySelector('#timer').textContent), count: Number(document.querySelector('#count').textContent) })`); - const cpuStart = process.getCPUUsage(); - window.hide(); - await new Promise((resolve) => setTimeout(resolve, 1_500)); - await evaluate(send, `document.querySelector('#increment').click()`); - const after = await evaluate(send, `({ ticks: Number(document.querySelector('#timer').textContent), count: Number(document.querySelector('#count').textContent), hidden: document.hidden })`); - const image = await contents.capturePage(); - const processMemory = await process.getProcessMemoryInfo(); - const cpuUsage = process.getCPUUsage(cpuStart); - const metrics = app.getAppMetrics().map((metric) => ({ - type: metric.type, - cpuPercent: metric.cpu.percentCPUUsage, - memoryKb: metric.memory.workingSetSize, - })); - if (outputDirectory) { - mkdirSync(outputDirectory, { recursive: true }); - writeFileSync(join(outputDirectory, "hidden-capture.png"), image.toPNG()); - } - emitResult({ - mode, - before, - after, - captureSize: image.getSize(), - processPrivateKb: processMemory.private, - mainProcessCpuPercent: cpuUsage.percentCPUUsage, - metrics, - }); - app.quit(); -} - -async function runRecordingSpike(window, contents, hideWindow = true) { - if (!outputDirectory) throw new Error("recording mode requires --output"); - mkdirSync(outputDirectory, { recursive: true }); - const send = attachDebugger(contents); - await Promise.all([send("Runtime.enable"), send("Page.enable")]); - let frameIndex = 0; - const timestamps = []; - let lastWrittenTimestamp = Number.NEGATIVE_INFINITY; - const onMessage = async (_event, method, params) => { - if (method !== "Page.screencastFrame") return; - const timestamp = params.metadata?.timestamp ?? 0; - if (timestamp - lastWrittenTimestamp < 1 / 12) { - await send("Page.screencastFrameAck", { sessionId: params.sessionId }); - return; - } - lastWrittenTimestamp = timestamp; - frameIndex += 1; - timestamps.push(timestamp); - writeFileSync( - join(outputDirectory, `frame-${String(frameIndex).padStart(4, "0")}.jpg`), - Buffer.from(params.data, "base64"), - ); - await send("Page.screencastFrameAck", { sessionId: params.sessionId }); - }; - contents.debugger.on("message", onMessage); - await send("Page.startScreencast", { - format: "jpeg", - quality: 80, - maxWidth: 800, - maxHeight: 600, - everyNthFrame: 1, - }); - if (hideWindow) window.hide(); - await new Promise((resolve) => setTimeout(resolve, 2_500)); - await send("Page.stopScreencast"); - contents.debugger.off("message", onMessage); - - const videoPath = join(outputDirectory, "webview-recording.mp4"); - const ffmpeg = spawnSync( - "ffmpeg", - [ - "-y", - "-loglevel", - "error", - "-framerate", - "12", - "-i", - join(outputDirectory, "frame-%04d.jpg"), - "-c:v", - "libx264", - "-pix_fmt", - "yuv420p", - "-movflags", - "+faststart", - videoPath, - ], - { encoding: "utf8" }, - ); - const probe = spawnSync( - "ffprobe", - ["-v", "error", "-show_entries", "format=duration,size", "-of", "json", videoPath], - { encoding: "utf8" }, - ); - emitResult({ - mode, - frames: frameIndex, - firstTimestamp: timestamps.at(0), - lastTimestamp: timestamps.at(-1), - ffmpegStatus: ffmpeg.status, - ffmpegError: ffmpeg.stderr.trim() || null, - probe: probe.status === 0 ? JSON.parse(probe.stdout) : { error: probe.stderr.trim() }, - videoPath, - }); - app.quit(); -} - -async function runMediaRecorderSpike(window, contents) { - if (!outputDirectory) throw new Error("media-recorder mode requires --output"); - mkdirSync(outputDirectory, { recursive: true }); - await window.webContents.executeJavaScript(`(() => { - const guest = document.querySelector('webview'); - guest.style.position = 'fixed'; - guest.style.left = '0'; - guest.style.top = '0'; - guest.style.width = '800px'; - guest.style.height = '600px'; - guest.style.zIndex = '1'; - const cover = document.createElement('div'); - cover.style.position = 'fixed'; - cover.style.inset = '0'; - cover.style.background = '#111827'; - cover.style.zIndex = '2'; - document.body.appendChild(cover); - })()`); - const recordingInfo = await window.webContents.executeJavaScript( - `window.phase0.startRecording({ width: 800, height: 600, fps: 12 })`, - ); - const send = attachDebugger(contents); - await send("Page.enable"); - let frameCount = 0; - let lastSentTimestamp = Number.NEGATIVE_INFINITY; - const onMessage = async (_event, method, params) => { - if (method !== "Page.screencastFrame") return; - const timestamp = params.metadata?.timestamp ?? 0; - if (timestamp - lastSentTimestamp >= 1 / 12) { - lastSentTimestamp = timestamp; - frameCount += 1; - window.webContents.send("phase0:recording-frame", `data:image/jpeg;base64,${params.data}`); - } - await send("Page.screencastFrameAck", { sessionId: params.sessionId }); - }; - contents.debugger.on("message", onMessage); - await send("Page.startScreencast", { - format: "jpeg", - quality: 80, - maxWidth: 800, - maxHeight: 600, - everyNthFrame: 1, - }); - await new Promise((resolve) => setTimeout(resolve, 2_500)); - await send("Page.stopScreencast"); - contents.debugger.off("message", onMessage); - await new Promise((resolve) => setTimeout(resolve, 250)); - const recordingResult = await window.webContents.executeJavaScript("window.phase0.stopRecording()"); - const videoPath = join( - outputDirectory, - recordingResult.mimeType.startsWith("video/mp4") - ? "webview-recording.mp4" - : "webview-recording.webm", - ); - writeFileSync(videoPath, Buffer.from(recordingResult.bytes)); - const probe = spawnSync( - "ffprobe", - ["-v", "error", "-show_entries", "format=duration,size", "-show_entries", "stream=codec_name,width,height,avg_frame_rate", "-of", "json", videoPath], - { encoding: "utf8" }, - ); - emitResult({ - mode, - frameCount, - recordingInfo, - videoPath, - bytes: recordingResult.bytes.length, - probe: probe.status === 0 ? JSON.parse(probe.stdout) : { error: probe.stderr.trim() }, - }); - app.quit(); -} - -async function runRecordingEnduranceSpike(window, contents) { - if (!outputDirectory) throw new Error("recording-endurance mode requires --output"); - const width = 1600; - const height = 1200; - const fps = 12; - const durationMs = 10_000; - window.setSize(1700, 1300); - contents.setBackgroundThrottling(false); - await window.webContents.executeJavaScript(`(() => { - const guest = document.querySelector('webview'); - guest.style.position = 'fixed'; - guest.style.left = '0'; - guest.style.top = '0'; - guest.style.width = '${width}px'; - guest.style.height = '${height}px'; - guest.style.zIndex = '1'; - const cover = document.createElement('div'); - cover.style.position = 'fixed'; - cover.style.inset = '0'; - cover.style.background = '#111827'; - cover.style.zIndex = '2'; - document.body.appendChild(cover); - })()`); - const recordingInfo = await window.webContents.executeJavaScript( - `window.phase0.startRecording({ width: ${width}, height: ${height}, fps: ${fps} })`, - ); - const send = attachDebugger(contents); - await send("Page.enable"); - let frameCount = 0; - let lastSentTimestamp = Number.NEGATIVE_INFINITY; - const cpuStart = process.getCPUUsage(); - const onMessage = async (_event, method, params) => { - if (method !== "Page.screencastFrame") return; - const timestamp = params.metadata?.timestamp ?? 0; - if (timestamp - lastSentTimestamp >= 1 / fps) { - lastSentTimestamp = timestamp; - frameCount += 1; - window.webContents.send("phase0:recording-frame", `data:image/jpeg;base64,${params.data}`); - } - await send("Page.screencastFrameAck", { sessionId: params.sessionId }); - }; - contents.debugger.on("message", onMessage); - await send("Page.startScreencast", { - format: "jpeg", - quality: 75, - maxWidth: width, - maxHeight: height, - everyNthFrame: 1, - }); - await new Promise((resolveDelay) => setTimeout(resolveDelay, durationMs)); - await send("Page.stopScreencast"); - contents.debugger.off("message", onMessage); - await new Promise((resolveDelay) => setTimeout(resolveDelay, 250)); - const recordingResult = await window.webContents.executeJavaScript("window.phase0.stopRecording()"); - const videoPath = join(outputDirectory, "recording-endurance.mp4"); - writeFileSync(videoPath, Buffer.from(recordingResult.bytes)); - const probe = spawnSync( - "ffprobe", - ["-v", "error", "-show_entries", "format=duration,size", "-show_entries", "stream=codec_name,width,height,avg_frame_rate", "-of", "json", videoPath], - { encoding: "utf8" }, - ); - emitResult({ - mode, - width, - height, - fps, - durationMs, - frameCount, - achievedFps: frameCount / (durationMs / 1_000), - recordingInfo, - bytes: recordingResult.bytes.length, - mainProcessCpu: process.getCPUUsage(cpuStart), - metrics: app.getAppMetrics().map((metric) => ({ - type: metric.type, - cpuPercent: metric.cpu.percentCPUUsage, - memoryKb: metric.memory.workingSetSize, - })), - probe: probe.status === 0 ? JSON.parse(probe.stdout) : { error: probe.stderr.trim() }, - }); - app.quit(); -} - -function summarizeDurations(values) { - const sorted = [...values].sort((left, right) => left - right); - const percentile = (fraction) => sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * fraction))]; - return { - count: sorted.length, - minMs: sorted[0], - medianMs: percentile(0.5), - p95Ms: percentile(0.95), - maxMs: sorted.at(-1), - meanMs: sorted.reduce((sum, value) => sum + value, 0) / sorted.length, - }; -} - -async function runLatencySpike(window, contents) { - const send = attachDebugger(contents); - await send("Runtime.enable"); - const evaluateNoop = () => send("Runtime.evaluate", { expression: "1 + 1", returnByValue: true }); - for (let index = 0; index < 10; index += 1) await evaluateNoop(); - const direct = []; - for (let index = 0; index < 100; index += 1) { - const startedAt = performance.now(); - await evaluateNoop(); - direct.push(performance.now() - startedAt); - } - ipcMain.removeHandler("phase0:cdp-evaluate"); - ipcMain.handle("phase0:cdp-evaluate", evaluateNoop); - const rendererRelay = await window.webContents.executeJavaScript(`(async () => { - const durations = []; - for (let index = 0; index < 100; index += 1) { - const startedAt = performance.now(); - await window.phase0.invokeCdp(); - durations.push(performance.now() - startedAt); - } - return durations; - })()`); - emitResult({ - mode, - direct: summarizeDurations(direct), - rendererRelay: summarizeDurations(rendererRelay), - }); - app.quit(); -} - -async function runDetachedViewSpike(window, view, detach) { - const contents = view.webContents; - const send = attachDebugger(contents); - await send("Runtime.enable"); - contents.setBackgroundThrottling(false); - const before = await evaluate( - send, - `({ ticks: Number(document.querySelector('#timer').textContent), count: Number(document.querySelector('#count').textContent) })`, - ); - if (detach) window.contentView.removeChildView(view); - else view.setVisible(false); - await new Promise((resolve) => setTimeout(resolve, 1_500)); - const semanticProbe = await runSemanticProbe(contents); - const after = await evaluate( - send, - `({ ticks: Number(document.querySelector('#timer').textContent), count: Number(document.querySelector('#count').textContent), hidden: document.hidden })`, - ); - let capture; - try { - const image = await contents.capturePage(); - if (outputDirectory) writeFileSync(join(outputDirectory, "detached-view-capture.png"), image.toPNG()); - capture = { success: true, size: image.getSize() }; - } catch (error) { - capture = { success: false, error: error instanceof Error ? error.message : String(error) }; - } - let cdpScreenshot; - try { - await send("Page.enable"); - const screenshot = await Promise.race([ - send("Page.captureScreenshot", { - format: "png", - captureBeyondViewport: false, - fromSurface: false, - }), - new Promise((_, reject) => setTimeout(() => reject(new Error("CDP screenshot timed out")), 3_000)), - ]); - const bytes = Buffer.from(screenshot.data, "base64"); - if (outputDirectory) writeFileSync(join(outputDirectory, "hidden-cdp-screenshot.png"), bytes); - cdpScreenshot = { success: true, bytes: bytes.length }; - } catch (error) { - cdpScreenshot = { success: false, error: error instanceof Error ? error.message : String(error) }; - } - emitResult({ - mode, - detach, - before, - after, - semanticProbe, - capture, - cdpScreenshot, - destroyed: contents.isDestroyed(), - }); - contents.close(); - app.quit(); -} - -await app.whenReady(); -debug("app-ready"); - -const window = new BrowserWindow({ - width: 900, - height: 700, - show: true, - webPreferences: { - contextIsolation: true, - nodeIntegration: false, - preload: join(here, "host-preload.cjs"), - webviewTag: true, - }, -}); - -app.on("web-contents-created", (_event, contents) => { - debug(`web-contents-created type=${contents.getType()} id=${contents.id}`); - if (contents.getType() !== "webview") return; - contents.once("dom-ready", () => { - if (mode === "renderer-reload" && rendererReloadStarted) { - rendererReloadNextGuestResolve?.(contents); - rendererReloadNextGuestResolve = undefined; - return; - } - if (mode === "automation") { - runSemanticProbe(contents) - .then((semanticProbe) => - emitResult({ mode, debugPort, webContentsId: contents.id, guestUrl, semanticProbe }), - ) - .catch((error) => { - emitResult({ mode, error: error instanceof Error ? error.stack : String(error) }); - app.exit(1); - }); - return; - } - const operation = - mode === "hidden" - ? runHiddenSpike(window, contents) - : mode === "injected-runtime" - ? runInjectedRuntimeSpike(contents) - : mode === "renderer-reload" - ? runRendererReloadSpike(window, contents) - : mode === "input-origin" - ? runInputOriginSpike(contents) - : mode === "recording-endurance" - ? runRecordingEnduranceSpike(window, contents) - : mode === "recording" - ? runRecordingSpike(window, contents) - : mode === "offscreen-recording" - ? window.webContents - .executeJavaScript(`(() => { - const guest = document.querySelector('webview'); - guest.style.position = 'fixed'; - guest.style.left = '-10000px'; - guest.style.top = '0'; - guest.style.width = '800px'; - guest.style.height = '600px'; - })()`) - .then(() => runRecordingSpike(window, contents, false)) - : mode === "covered-recording" - ? window.webContents - .executeJavaScript(`(() => { - const guest = document.querySelector('webview'); - guest.style.position = 'fixed'; - guest.style.left = '0'; - guest.style.top = '0'; - guest.style.width = '800px'; - guest.style.height = '600px'; - guest.style.zIndex = '1'; - const cover = document.createElement('div'); - cover.style.position = 'fixed'; - cover.style.inset = '0'; - cover.style.background = '#111827'; - cover.style.zIndex = '2'; - document.body.appendChild(cover); - })()`) - .then(() => runRecordingSpike(window, contents, false)) - : mode === "media-recorder" - ? runMediaRecorderSpike(window, contents) - : runLatencySpike(window, contents); - operation.catch((error) => { - emitResult({ mode, error: error instanceof Error ? error.stack : String(error) }); - app.exit(1); - }); - }); -}); - -if (mode.startsWith("view-")) { - await window.loadURL("data:text/html,Phase0 View Host"); - const view = new WebContentsView({ - webPreferences: { - contextIsolation: true, - nodeIntegration: false, - partition: "persist:t3-phase0-view", - }, - }); - view.setBounds({ x: 0, y: 0, width: 800, height: 600 }); - window.contentView.addChildView(view); - await view.webContents.loadFile(join(here, "guest.html")); - if (mode === "view-hidden" || mode === "view-detached") { - await runDetachedViewSpike(window, view, mode === "view-detached"); - } else if (mode === "view-recording") { - await runRecordingSpike(window, view.webContents); - } else { - const semanticProbe = await runSemanticProbe(view.webContents); - emitResult({ - mode, - debugPort, - webContentsId: view.webContents.id, - guestUrl, - semanticProbe, - }); - } -} else { - await window.loadFile(join(here, "host.html")); - debug(`host-loaded url=${window.webContents.getURL()}`); -} diff --git a/.plans/browser-phase-0/spikes/guest.html b/.plans/browser-phase-0/spikes/guest.html deleted file mode 100644 index 9a76c0522ca..00000000000 --- a/.plans/browser-phase-0/spikes/guest.html +++ /dev/null @@ -1,41 +0,0 @@ - - - - - Phase0 Guest - - - - - - -
- -
0
-
- - - diff --git a/.plans/browser-phase-0/spikes/host-preload.cjs b/.plans/browser-phase-0/spikes/host-preload.cjs deleted file mode 100644 index b47cc89c9c5..00000000000 --- a/.plans/browser-phase-0/spikes/host-preload.cjs +++ /dev/null @@ -1,51 +0,0 @@ -const { contextBridge, ipcRenderer } = require("electron"); - -let recording = null; - -ipcRenderer.on("phase0:recording-frame", async (_event, dataUrl) => { - if (!recording) return; - const response = await fetch(dataUrl); - const bitmap = await createImageBitmap(await response.blob()); - recording.context.drawImage(bitmap, 0, 0, recording.canvas.width, recording.canvas.height); - bitmap.close(); -}); - -contextBridge.exposeInMainWorld("phase0", { - invokeCdp: () => ipcRenderer.invoke("phase0:cdp-evaluate"), - startRecording: ({ width, height, fps }) => { - const canvas = document.createElement("canvas"); - canvas.width = width; - canvas.height = height; - const context = canvas.getContext("2d"); - const stream = canvas.captureStream(fps); - const mimeTypes = [ - "video/mp4;codecs=avc1.42E01E", - "video/mp4;codecs=h264", - "video/mp4", - "video/webm;codecs=vp9", - "video/webm;codecs=vp8", - "video/webm", - ]; - const mimeType = mimeTypes.find((candidate) => MediaRecorder.isTypeSupported(candidate)); - if (!mimeType) throw new Error("No supported WebM MediaRecorder codec"); - const recorder = new MediaRecorder(stream, { mimeType, videoBitsPerSecond: 2_000_000 }); - const chunks = []; - recorder.addEventListener("dataavailable", (event) => { - if (event.data.size > 0) chunks.push(event.data); - }); - recorder.start(250); - recording = { canvas, context, recorder, chunks, mimeType }; - return { mimeType }; - }, - stopRecording: async () => { - if (!recording) throw new Error("Recording was not started"); - const activeRecording = recording; - recording = null; - await new Promise((resolve) => { - activeRecording.recorder.addEventListener("stop", resolve, { once: true }); - activeRecording.recorder.stop(); - }); - const bytes = new Uint8Array(await new Blob(activeRecording.chunks).arrayBuffer()); - return { mimeType: activeRecording.mimeType, bytes }; - }, -}); diff --git a/.plans/browser-phase-0/spikes/host.html b/.plans/browser-phase-0/spikes/host.html deleted file mode 100644 index 6b22de49262..00000000000 --- a/.plans/browser-phase-0/spikes/host.html +++ /dev/null @@ -1,12 +0,0 @@ - - - Phase0 Host - - - - diff --git a/.plans/browser-phase-0/spikes/run-electron-spikes.mjs b/.plans/browser-phase-0/spikes/run-electron-spikes.mjs deleted file mode 100644 index 4f3d9cbade4..00000000000 --- a/.plans/browser-phase-0/spikes/run-electron-spikes.mjs +++ /dev/null @@ -1,191 +0,0 @@ -import { spawn } from "node:child_process"; -import { createRequire } from "node:module"; -import { createServer } from "node:net"; -import { dirname, join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; -import { readFile, rm } from "node:fs/promises"; - -const here = dirname(fileURLToPath(import.meta.url)); -const repositoryRoot = resolve(here, "../../.."); -const requireDesktop = createRequire(join(repositoryRoot, "apps/desktop/package.json")); -const requireWeb = createRequire(join(repositoryRoot, "apps/web/package.json")); -const electronBinary = requireDesktop("electron"); -const { chromium } = requireWeb("playwright"); -const playwrightPackage = requireWeb.resolve("playwright/package.json"); -const playwrightVersion = JSON.parse(await readFile(playwrightPackage, "utf8")).version; -const playwrightCoreBundle = resolve( - dirname(playwrightPackage), - `../../../playwright-core@${playwrightVersion}/node_modules/playwright-core/lib/coreBundle.js`, -); - -async function reservePort() { - return new Promise((resolvePort, reject) => { - const server = createServer(); - server.once("error", reject); - server.listen(0, "127.0.0.1", () => { - const address = server.address(); - const port = typeof address === "object" && address ? address.port : 0; - server.close(() => resolvePort(port)); - }); - }); -} - -function launchHost({ mode, port, output }) { - const childEnvironment = { ...process.env }; - delete childEnvironment.ELECTRON_RUN_AS_NODE; - const command = { - electronPath: electronBinary, - args: [ - join(here, "electron-webview-bootstrap.cjs"), - `--mode=${mode}`, - ...(port ? [`--port=${port}`] : []), - ...(output ? [`--output=${output}`] : []), - `--playwright-core-bundle=${playwrightCoreBundle}`, - ], - }; - const child = spawn( - command.electronPath, - command.args, - { cwd: repositoryRoot, env: childEnvironment, stdio: ["ignore", "pipe", "pipe"] }, - ); - child.stderr.on("data", (chunk) => process.stderr.write(chunk)); - return child; -} - -async function waitForResult(child, output, timeoutMs = 20_000) { - const resultPath = join(output, "result.json"); - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - try { - return JSON.parse(await readFile(resultPath, "utf8")); - } catch { - await new Promise((resolveDelay) => setTimeout(resolveDelay, 100)); - } - } - child.kill("SIGTERM"); - throw new Error(`Timed out waiting for ${resultPath}`); -} - -async function runAutomation(hostMode = "automation") { - const port = await reservePort(); - const output = join(repositoryRoot, ".plans/browser-phase-0/results", hostMode); - await rm(output, { recursive: true, force: true }); - const child = launchHost({ mode: hostMode, port, output }); - const hostResult = await waitForResult(child, output); - let browser; - try { - const endpoint = `http://127.0.0.1:${port}`; - let lastError; - for (let attempt = 0; attempt < 30; attempt += 1) { - try { - browser = await chromium.connectOverCDP(endpoint); - break; - } catch (error) { - lastError = error; - await new Promise((resolveDelay) => setTimeout(resolveDelay, 100)); - } - } - if (!browser) throw lastError; - const targets = await fetch(`${endpoint}/json/list`).then((response) => response.json()); - const pages = browser.contexts().flatMap((context) => context.pages()); - const pageMetadata = await Promise.all( - pages.map(async (page) => ({ url: page.url(), title: await page.title().catch(() => "") })), - ); - const guestPage = pages.find((page, index) => pageMetadata[index]?.title === "Phase0 Guest"); - if (!guestPage) { - const guestTarget = targets.find((target) => target.title === "Phase0 Guest"); - let directTargetAttachment; - if (guestTarget?.webSocketDebuggerUrl) { - try { - const directBrowser = await chromium.connectOverCDP(guestTarget.webSocketDebuggerUrl); - const directPages = directBrowser.contexts().flatMap((context) => context.pages()); - directTargetAttachment = { - success: true, - pages: await Promise.all( - directPages.map(async (page) => ({ url: page.url(), title: await page.title().catch(() => "") })), - ), - }; - await directBrowser.close(); - } catch (error) { - directTargetAttachment = { - success: false, - error: error instanceof Error ? error.message : String(error), - }; - } - } - return { - hostResult, - targets: targets.map(({ id, title, type, url }) => ({ id, title, type, url })), - pageMetadata, - directTargetAttachment, - success: false, - reason: "Guest webview was not exposed as a Playwright Page", - }; - } - const button = guestPage.getByRole("button", { name: "Increment count" }); - await button.click(); - const countAfterClick = await guestPage.locator("#count").textContent(); - const input = guestPage.getByRole("textbox", { name: "Message" }); - await input.fill("semantic locator attached"); - const inputValue = await input.inputValue(); - const expectedCount = String(Number(hostResult.semanticProbe?.state?.count ?? "0") + 1); - return { - hostResult, - pageMetadata, - success: countAfterClick === expectedCount && inputValue === "semantic locator attached", - countAfterClick, - inputValue, - }; - } finally { - await browser?.close().catch(() => undefined); - child.kill("SIGTERM"); - } -} - -async function runManagedMode(mode) { - const output = join(repositoryRoot, ".plans/browser-phase-0/results", mode); - await rm(output, { recursive: true, force: true }); - const child = launchHost({ mode, output }); - return waitForResult(child, output, mode === "recording" ? 30_000 : 20_000); -} - -const requestedMode = process.argv[2] ?? "all"; -const result = {}; -if (requestedMode === "all" || requestedMode === "automation") result.automation = await runAutomation(); -if (requestedMode === "all" || requestedMode === "view-automation") { - result.viewAutomation = await runAutomation("view-automation"); -} -if (requestedMode === "all" || requestedMode === "hidden") result.hidden = await runManagedMode("hidden"); -if (requestedMode === "all" || requestedMode === "recording") result.recording = await runManagedMode("recording"); -if (requestedMode === "all" || requestedMode === "offscreen-recording") { - result.offscreenRecording = await runManagedMode("offscreen-recording"); -} -if (requestedMode === "all" || requestedMode === "covered-recording") { - result.coveredRecording = await runManagedMode("covered-recording"); -} -if (requestedMode === "all" || requestedMode === "media-recorder") { - result.mediaRecorder = await runManagedMode("media-recorder"); -} -if (requestedMode === "all" || requestedMode === "latency") result.latency = await runManagedMode("latency"); -if (requestedMode === "all" || requestedMode === "injected-runtime") { - result.injectedRuntime = await runManagedMode("injected-runtime"); -} -if (requestedMode === "all" || requestedMode === "renderer-reload") { - result.rendererReload = await runManagedMode("renderer-reload"); -} -if (requestedMode === "all" || requestedMode === "input-origin") { - result.inputOrigin = await runManagedMode("input-origin"); -} -if (requestedMode === "all" || requestedMode === "recording-endurance") { - result.recordingEndurance = await runManagedMode("recording-endurance"); -} -if (requestedMode === "all" || requestedMode === "view-hidden") { - result.viewHidden = await runManagedMode("view-hidden"); -} -if (requestedMode === "all" || requestedMode === "view-detached") { - result.viewDetached = await runManagedMode("view-detached"); -} -if (requestedMode === "all" || requestedMode === "view-recording") { - result.viewRecording = await runManagedMode("view-recording"); -} -process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); diff --git a/.plans/browser-phase-0/spikes/run-gateway-spike.mjs b/.plans/browser-phase-0/spikes/run-gateway-spike.mjs deleted file mode 100644 index ba3157cfece..00000000000 --- a/.plans/browser-phase-0/spikes/run-gateway-spike.mjs +++ /dev/null @@ -1,243 +0,0 @@ -import { createServer, request } from "node:http"; -import { connect as connectTcp, createServer as createTcpServer } from "node:net"; -import { createRequire } from "node:module"; -import { readdirSync } from "node:fs"; -import { join, resolve } from "node:path"; - -const repositoryRoot = resolve(import.meta.dirname, "../../.."); -const pnpmDirectory = join(repositoryRoot, "node_modules/.pnpm"); -const wsPackageDirectory = readdirSync(pnpmDirectory) - .filter((name) => name.startsWith("ws@8.")) - .sort() - .at(-1); -if (!wsPackageDirectory) throw new Error("The locked ws 8 package is not installed"); -const requireWs = createRequire(join(pnpmDirectory, wsPackageDirectory, "node_modules/ws/package.json")); -const { WebSocket, WebSocketServer } = requireWs("ws"); - -function listen(server) { - return new Promise((resolvePort, reject) => { - server.once("error", reject); - server.listen(0, "127.0.0.1", () => { - const address = server.address(); - resolvePort(address.port); - }); - }); -} - -function listenTcp(server) { - return new Promise((resolvePort, reject) => { - server.once("error", reject); - server.listen(0, "127.0.0.1", () => resolvePort(server.address().port)); - }); -} - -function close(server) { - return new Promise((resolveClose, reject) => server.close((error) => (error ? reject(error) : resolveClose()))); -} - -function summarize(values) { - const sorted = [...values].sort((left, right) => left - right); - const percentile = (fraction) => sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * fraction))]; - return { - count: sorted.length, - medianMs: percentile(0.5), - p95Ms: percentile(0.95), - maxMs: sorted.at(-1), - meanMs: sorted.reduce((sum, value) => sum + value, 0) / sorted.length, - }; -} - -const upstreamWebSockets = new WebSocketServer({ noServer: true }); -upstreamWebSockets.on("connection", (socket) => { - socket.on("message", (message) => socket.send(`upstream:${message}`)); -}); -const upstream = createServer((incoming, response) => { - response.writeHead(200, { - "content-type": incoming.url === "/app" ? "text/html" : "application/json", - "x-upstream-host": incoming.headers.host ?? "", - }); - response.end( - incoming.url === "/app" - ? "Remote Preview

remote-loopback-ok

" - : JSON.stringify({ ok: true, url: incoming.url }), - ); -}); -upstream.on("upgrade", (incoming, socket, head) => { - upstreamWebSockets.handleUpgrade(incoming, socket, head, (webSocket) => { - upstreamWebSockets.emit("connection", webSocket, incoming); - }); -}); -const upstreamPort = await listen(upstream); - -const gatewayWebSockets = new WebSocketServer({ noServer: true }); -gatewayWebSockets.on("connection", (clientSocket, incoming) => { - const upstreamSocket = new WebSocket(`ws://127.0.0.1:${upstreamPort}${incoming.url}`); - const pending = []; - clientSocket.on("message", (message) => { - if (upstreamSocket.readyState === WebSocket.OPEN) upstreamSocket.send(message); - else pending.push(message); - }); - upstreamSocket.on("open", () => pending.splice(0).forEach((message) => upstreamSocket.send(message))); - upstreamSocket.on("message", (message) => clientSocket.send(message)); - const closeBoth = () => { - if (clientSocket.readyState < WebSocket.CLOSING) clientSocket.close(); - if (upstreamSocket.readyState < WebSocket.CLOSING) upstreamSocket.close(); - }; - clientSocket.on("close", closeBoth); - upstreamSocket.on("close", closeBoth); - upstreamSocket.on("error", closeBoth); -}); - -const gateway = createServer((incoming, response) => { - const upstreamRequest = request( - { - hostname: "127.0.0.1", - port: upstreamPort, - path: incoming.url, - method: incoming.method, - headers: { ...incoming.headers, host: `127.0.0.1:${upstreamPort}` }, - }, - (upstreamResponse) => { - response.writeHead(upstreamResponse.statusCode ?? 502, upstreamResponse.headers); - upstreamResponse.pipe(response); - }, - ); - upstreamRequest.on("error", (error) => response.destroy(error)); - incoming.pipe(upstreamRequest); -}); -gateway.on("upgrade", (incoming, socket, head) => { - gatewayWebSockets.handleUpgrade(incoming, socket, head, (webSocket) => { - gatewayWebSockets.emit("connection", webSocket, incoming); - }); -}); -const gatewayPort = await listen(gateway); - -const tunnelWebSockets = new WebSocketServer({ noServer: true }); -tunnelWebSockets.on("connection", (tunnelSocket) => { - const upstreamSocket = connectTcp({ host: "127.0.0.1", port: upstreamPort }); - const pending = []; - tunnelSocket.on("message", (message) => { - const bytes = Buffer.from(message); - if (upstreamSocket.readyState === "open") upstreamSocket.write(bytes); - else pending.push(bytes); - }); - upstreamSocket.on("connect", () => pending.splice(0).forEach((bytes) => upstreamSocket.write(bytes))); - upstreamSocket.on("data", (bytes) => { - if (tunnelSocket.readyState === WebSocket.OPEN) tunnelSocket.send(bytes, { binary: true }); - }); - const closeBoth = () => { - if (!upstreamSocket.destroyed) upstreamSocket.destroy(); - if (tunnelSocket.readyState < WebSocket.CLOSING) tunnelSocket.close(); - }; - upstreamSocket.on("close", closeBoth); - upstreamSocket.on("error", closeBoth); - tunnelSocket.on("close", closeBoth); -}); -const tunnelServer = createServer(); -tunnelServer.on("upgrade", (incoming, socket, head) => { - tunnelWebSockets.handleUpgrade(incoming, socket, head, (webSocket) => { - tunnelWebSockets.emit("connection", webSocket, incoming); - }); -}); -const tunnelPort = await listen(tunnelServer); - -const desktopLoopback = createTcpServer((browserSocket) => { - const tunnelSocket = new WebSocket(`ws://127.0.0.1:${tunnelPort}/tcp`); - const pending = []; - browserSocket.on("data", (bytes) => { - if (tunnelSocket.readyState === WebSocket.OPEN) tunnelSocket.send(bytes, { binary: true }); - else pending.push(bytes); - }); - tunnelSocket.on("open", () => pending.splice(0).forEach((bytes) => tunnelSocket.send(bytes, { binary: true }))); - tunnelSocket.on("message", (message) => browserSocket.write(Buffer.from(message))); - const closeBoth = () => { - if (!browserSocket.destroyed) browserSocket.destroy(); - if (tunnelSocket.readyState < WebSocket.CLOSING) tunnelSocket.close(); - }; - browserSocket.on("close", closeBoth); - browserSocket.on("error", closeBoth); - tunnelSocket.on("close", closeBoth); - tunnelSocket.on("error", closeBoth); -}); -const desktopLoopbackPort = await listenTcp(desktopLoopback); - -async function measureFetch(url, count) { - const durations = []; - for (let index = 0; index < count; index += 1) { - const startedAt = performance.now(); - const response = await fetch(url); - await response.arrayBuffer(); - durations.push(performance.now() - startedAt); - } - return summarize(durations); -} - -function websocketRoundTrips(url, count) { - return new Promise((resolveResult, reject) => { - const socket = new WebSocket(url); - const durations = []; - let startedAt = 0; - let completed = 0; - socket.on("open", () => { - startedAt = performance.now(); - socket.send(String(completed)); - }); - socket.on("message", (message) => { - durations.push(performance.now() - startedAt); - if (String(message) !== `upstream:${completed}`) { - reject(new Error(`Unexpected WebSocket response: ${message}`)); - return; - } - completed += 1; - if (completed === count) { - socket.close(); - resolveResult(summarize(durations)); - return; - } - startedAt = performance.now(); - socket.send(String(completed)); - }); - socket.on("error", reject); - }); -} - -try { - const appResponse = await fetch(`http://127.0.0.1:${gatewayPort}/app`); - const appBody = await appResponse.text(); - const directHttp = await measureFetch(`http://127.0.0.1:${upstreamPort}/bench`, 100); - const gatewayHttp = await measureFetch(`http://127.0.0.1:${gatewayPort}/bench`, 100); - const gatewayWebSocket = await websocketRoundTrips(`ws://127.0.0.1:${gatewayPort}/hmr`, 100); - const tunnelAppResponse = await fetch(`http://127.0.0.1:${desktopLoopbackPort}/app`); - const tunnelAppBody = await tunnelAppResponse.text(); - const rawTunnelHttp = await measureFetch(`http://127.0.0.1:${desktopLoopbackPort}/bench`, 100); - const rawTunnelWebSocket = await websocketRoundTrips( - `ws://127.0.0.1:${desktopLoopbackPort}/hmr`, - 100, - ); - process.stdout.write( - `${JSON.stringify( - { - success: appBody.includes("remote-loopback-ok"), - upstreamPort, - gatewayPort, - responseHeaders: Object.fromEntries(appResponse.headers), - directHttp, - gatewayHttp, - addedHttpMedianMs: gatewayHttp.medianMs - directHttp.medianMs, - gatewayWebSocket, - rawTcpTunnel: { - success: tunnelAppBody.includes("remote-loopback-ok"), - environmentTunnelPort: tunnelPort, - desktopLoopbackPort, - http: rawTunnelHttp, - addedHttpMedianMs: rawTunnelHttp.medianMs - directHttp.medianMs, - webSocket: rawTunnelWebSocket, - }, - }, - null, - 2, - )}\n`, - ); -} finally { - await Promise.all([close(desktopLoopback), close(tunnelServer), close(gateway), close(upstream)]); -} diff --git a/.plans/browser-phase-0/spikes/run-tunnel-security-spike.mjs b/.plans/browser-phase-0/spikes/run-tunnel-security-spike.mjs deleted file mode 100644 index 2ed963fc13a..00000000000 --- a/.plans/browser-phase-0/spikes/run-tunnel-security-spike.mjs +++ /dev/null @@ -1,98 +0,0 @@ -import { spawn } from "node:child_process"; -import { createServer } from "node:http"; -import { createRequire } from "node:module"; -import { join, resolve } from "node:path"; - -const repositoryRoot = resolve(import.meta.dirname, "../../.."); -const requireDesktop = createRequire(join(repositoryRoot, "apps/desktop/package.json")); -const electronBinary = requireDesktop("electron"); - -function listen(server) { - return new Promise((resolvePort, reject) => { - server.once("error", reject); - server.listen(0, "127.0.0.1", () => resolvePort(server.address().port)); - }); -} - -function close(server) { - return new Promise((resolveClose, reject) => - server.close((error) => (error ? reject(error) : resolveClose())), - ); -} - -const observed = []; -const previewHandler = (request, response) => { - observed.push({ url: request.url, origin: request.headers.origin ?? null }); - if (request.url === "/absolute-redirect") { - response.writeHead(302, { location: "https://remote.example.test/callback" }); - response.end(); - return; - } - response.writeHead(200, { "content-type": "text/html" }); - response.end("Preview target

preview target

"); -}; -const previewA = createServer(previewHandler); -const previewB = createServer(previewHandler); -const malicious = createServer((_request, response) => { - response.writeHead(200, { "content-type": "text/html" }); - response.end("Untrusted page

untrusted page

"); -}); - -const [previewAPort, previewBPort, maliciousPort] = await Promise.all([ - listen(previewA), - listen(previewB), - listen(malicious), -]); -const previewAUrl = `http://127.0.0.1:${previewAPort}`; -const previewBUrl = `http://127.0.0.1:${previewBPort}`; -const maliciousUrl = `http://127.0.0.1:${maliciousPort}`; - -const childEnvironment = { ...process.env }; -delete childEnvironment.ELECTRON_RUN_AS_NODE; -const child = spawn( - electronBinary, - [join(import.meta.dirname, "tunnel-security-electron.cjs"), maliciousUrl, previewAUrl, previewBUrl], - { cwd: repositoryRoot, env: childEnvironment, stdio: ["ignore", "pipe", "pipe"] }, -); -let stdout = ""; -let stderr = ""; -child.stdout.on("data", (bytes) => { - stdout += bytes; -}); -child.stderr.on("data", (bytes) => { - stderr += bytes; -}); -const exitCode = await new Promise((resolveExit) => child.once("exit", resolveExit)); - -try { - const resultLine = stdout - .split("\n") - .find((line) => line.startsWith("PHASE05_TUNNEL ")); - if (!resultLine) throw new Error(`Electron probe did not emit a result: ${stderr || stdout}`); - const browserResult = JSON.parse(resultLine.slice("PHASE05_TUNNEL ".length)); - const redirectResponse = await fetch(`${previewAUrl}/absolute-redirect`, { redirect: "manual" }); - process.stdout.write( - `${JSON.stringify( - { - success: exitCode === 0, - browserResult, - untrustedPageCausedLoopbackRequest: observed.some((request) => request.url === "/secret"), - loopbackRequestOriginHeader: observed.find((request) => request.url === "/secret")?.origin ?? null, - observed, - originStickiness: { - sameAuthorityPreservedStorage: browserResult.originalPortValue === "sticky", - differentPortChangedOrigin: browserResult.otherPortValue === null, - }, - absoluteRedirect: { - status: redirectResponse.status, - location: redirectResponse.headers.get("location"), - escapedLoopbackAuthority: redirectResponse.headers.get("location")?.startsWith("https://") ?? false, - }, - }, - null, - 2, - )}\n`, - ); -} finally { - await Promise.all([close(previewA), close(previewB), close(malicious)]); -} diff --git a/.plans/browser-phase-0/spikes/tunnel-security-electron.cjs b/.plans/browser-phase-0/spikes/tunnel-security-electron.cjs deleted file mode 100644 index 9b0565fa842..00000000000 --- a/.plans/browser-phase-0/spikes/tunnel-security-electron.cjs +++ /dev/null @@ -1,16 +0,0 @@ -const { app, BrowserWindow } = require('electron'); -const [maliciousUrl, previewA, previewB] = process.argv.slice(2); -app.whenReady().then(async () => { - const window = new BrowserWindow({ show: false, webPreferences: { contextIsolation: true, nodeIntegration: false } }); - await window.loadURL(maliciousUrl); - const maliciousFetch = await window.webContents.executeJavaScript(`fetch(${JSON.stringify(previewA + '/secret')}, { mode: 'no-cors' }).then(() => 'sent').catch(error => String(error))`); - await new Promise(resolve => setTimeout(resolve, 150)); - await window.loadURL(previewA + '/storage'); - await window.webContents.executeJavaScript(`localStorage.setItem('phase05', 'sticky')`); - await window.loadURL(previewB + '/storage'); - const otherPortValue = await window.webContents.executeJavaScript(`localStorage.getItem('phase05')`); - await window.loadURL(previewA + '/storage'); - const originalPortValue = await window.webContents.executeJavaScript(`localStorage.getItem('phase05')`); - process.stdout.write(`PHASE05_TUNNEL ${JSON.stringify({ maliciousFetch, otherPortValue, originalPortValue })}\n`); - app.quit(); -}).catch(error => { console.error(error); app.exit(1); }); diff --git a/.plans/collaborative-browser-architecture.html b/.plans/collaborative-browser-architecture.html deleted file mode 100644 index 870fc69b8e1..00000000000 --- a/.plans/collaborative-browser-architecture.html +++ /dev/null @@ -1,1981 +0,0 @@ - - - - - - T3 Collaborative Browser Architecture - - - -
- - -
T3 / Browser Architecture2026.06.12
- -
-
-
-
One real browser / multiple actors / any environment
-

A browser that is shared state, not a preview.

-

- T3 owns a route-durable Chromium page that users and agents manipulate together. The page - survives panel changes—not renderer or window death—accepts semantic programmatic control, records evidence, and - reaches development services whether they are local, SSH-hosted, private-networked, - or connected through T3 Connect. -

-
-
-
-
Core invariant
-
One session. One page state.
-
- Human interaction, agent automation, recording, DevTools, console, and network - inspection all target the same guest WebContents. -
-
-
-
Selected browser host
-
Route-durable renderer-hosted <webview>
-
- App-level lifetime; desktop-main control; explicit lost state after renderer reload, crash, or window close. -
-
-
-
Remote model
-
Environment stream gateway
-
- A stable control endpoint carries authorized streams to arbitrary environment-local - ports. Preview ports do not become public endpoints. -
-
-
-
- -
-
-
01 / SYSTEM
-
-

End-to-end ownership map

-

- The internal browser API is session-oriented and transport-neutral. MCP, skills, - CLI tools, and future adapters are clients of the control service—not alternate - browser implementations. -

-
-
-
-
-
- Authority flows toward the real page; state flows back through revisions and events. - The renderer may relay transport, but it does not decide whether a browser exists or whether automation is permitted. -
-
-
-
-
-
Clients
-

Agent + Human

-

MCP, skill client, CLI, direct UI input, DevTools, recordings.

-
-
-
Environment server
-

Browser Control Service

-

Authorization, sessions, routing, command queues, control leases, target resolution.

-
-
-
Desktop transport
-

Typed Relay + IPC

-

Environment connection enters renderer, then typed IPC reaches desktop main.

-
-
-
Desktop host
-

Electron Browser Host

-

Persistent CDP, webview registry, presentation bounds, recording, guest security.

-
-
-
Shared state
-

Guest WebContents

-

The actual Chromium page both human and agent observe and manipulate.

-
-
-
-
- -
-
-
02 / LAYERS
-
-

Six boundaries, one browser product

-

- Each layer has a narrow authority. This prevents React visibility, provider quirks, - network reachability, and artifact storage from leaking into the browser core. -

-
-
-
-
-
L1 / ENTRY
-

Agent Adapters

-

Provider-neutral ways to ask for browser work.

-
    -
  • MCP toolkit
  • -
  • Browser skill + Node helper
  • -
  • CLI / agent-browser compatibility
  • -
  • Internal TypeScript test client
  • -
-
-
-
L2 / CONTROL
-

Browser Control Service

-

The internal API and policy boundary.

-
    -
  • Invocation authorization
  • -
  • Per-tab command serialization
  • -
  • Cancellation + deadlines
  • -
  • Control leases + interruption
  • -
-
-
-
L3 / STATE
-

Session Registry

-

Authoritative logical lifecycle and recovery state.

-
    -
  • Stable session + tab IDs
  • -
  • Host assignment
  • -
  • Lifecycle revisions
  • -
  • Recording + artifact references
  • -
-
-
-
L4 / HOST
-

Electron Browser Host

-

Owns the relationship between durable DOM webviews and desktop main.

-
    -
  • App-level mounted webviews
  • -
  • Visible/offscreen/covered parking
  • -
  • Guest WebContents validation
  • -
  • Persistent debugger connections
  • -
-
-
-
L5 / REACH
-

Target + Tunnel Service

-

Resolves environment-relative targets into browser-reachable loopback URLs.

-
    -
  • Direct URL classification
  • -
  • Environment-port grants
  • -
  • Raw TCP stream tunnels
  • -
  • SSH, relay, LAN, Tailscale transport
  • -
-
-
-
L6 / EVIDENCE
-

Artifact Service

-

Stores durable evidence without blocking control/event traffic.

-
    -
  • H.264 MP4 recordings
  • -
  • Action timeline + screenshots
  • -
  • Console, exceptions, network
  • -
  • Downloads, HAR, traces
  • -
-
-
-
- -
-
-
03 / MODEL
-
-

Session, tab, view, and controller are separate

-

- The old preview model conflates “panel mounted” with “browser exists.” The new model - gives each concern its own state and lifecycle. -

-
-
-
-
-

BrowserSession

-

Logical browser workspace bound to an environment, thread, host, storage partition, and artifact history.

-
    -
  • Outlives panel and route mounts
  • -
  • Owns active tab selection
  • -
  • Tracks recovery + host availability
  • -
  • May be visible, hidden, detached, suspended, or closed
  • -
-
-
-

BrowserTab

-

One real Chromium page with origin, control, navigation, revision, and recording state.

-
    -
  • Created by human, agent, or system
  • -
  • Can be adopted and handed off
  • -
  • Has one host and one guest WebContents
  • -
  • Element references expire with document revision
  • -
-
-
-
// contracts remain schema-only -BrowserSession { - id, environmentId, threadId, hostId, activeTabId, - lifecycle: creating | ready | suspended | recovering | closed, - visibility: visible | hidden | detached, - controller: human | agent | none, - partitionId, recordingId, revision -} - -BrowserTab { - id, sessionId, webContentsId, origin, - state: opening | ready | closing | closed, - presentation: visible | parked-idle | parked-recording, - url, title, documentRevision, controller -}
-
- -
-
-
04 / HOST
-
-

The tab manager owns durable webviews

-

- Keep the existing real Electron webview, but lift it out of `PreviewPanel` and thread - route lifecycle. A window-level host maintains browser elements for every live tab. -

-
-
-
-
-
HOST / 01
-
Create tab
-
Session registry assigns tab ID and partition. App-level host mounts one webview and waits for `dom-ready`.
-
-
-
HOST / 02
-
Register guest
-
Renderer reports `webContentsId`; desktop main verifies type=`webview` and correct host window before accepting it.
-
-
-
HOST / 03
-
Attach control
-
Desktop main creates the persistent CDP connection, navigation listeners, console/network buffers, and recording controller.
-
-
-
HOST / 04
-
Present surface
-
Panel reports bounds. Host places the same webview at visible bounds or an internal parking surface without recreating the page.
-
-
-
HOST / 05
-
Explicit close
-
Only session/tab lifecycle commands close the guest. React unmounts, route changes, and panel closure never imply browser destruction.
-
-
-
-
- Selected -

Renderer-hosted <webview>

-

Preserves the existing composited browser surface, overlays, clipping, capture behavior, and user interaction model.

-
-
- Rejected for now -

WebContentsView

-

Playwright-visible and main-owned, but hidden/detached capture failed and native view stacking complicates collaboration UI.

-
-
-
- -
-
-
05 / LIFE
-
-

Presentation state is not process state

-

- Tabs remain live while hidden. Resource policy can explicitly suspend inactive tabs, - but never silently evicts a controlled or recording session. -

-
-
-
-
Creatingwebview mounting
-
Readyvisible or parked
-
Suspendedexplicit reload required
-
Recoveringhost reconnect
-
Closedterminal
-
-
-
-

Idle hidden tab

-

Park offscreen at the preserved viewport size. Timers, network, CDP, and semantic automation continue.

-
    -
  • Lowest practical compositor cost
  • -
  • No continuous recording frames
  • -
  • Snapshot/capture available when surface remains valid
  • -
-
-
-

Hidden recording tab

-

Move to a covered full-size parking surface inside the window. The user sees app chrome, not the browser, while Chromium keeps painting frames.

-
    -
  • CDP screencast remains active
  • -
  • Recording does not force panel open
  • -
  • Same page and viewport are preserved
  • -
-
-
-
- -
-
-
06 / AUTO
-
-

Playwright semantics without Playwright ownership

-

- Direct Playwright CDP attachment does not expose an Electron guest target as a - `Page`. The selected adapter uses persistent CDP plus a versioned injected utility - runtime to provide semantic, agent-friendly browser actions. -

-
-
-
-
-
- Agent surface - `browser.open`, semantic locate, click, type, drag, wait, assert, record, console, network, tabs. -
-
- Control service - Capability checks, control lease, command ID, deadline, cancellation, page revision, result evidence. -
-
- Semantic adapter - Version-pinned Playwright injected runtime, locator re-resolution, actionability, shadow DOM, and per-frame routing. -
-
- Persistent CDP - Accessibility, DOM, Runtime, Input, Page, Network, Log; one serialized connection per tab. -
-
- Guest page - The same live WebContents displayed to the user. No synchronized second browser. -
-
- -
-
-
!
-
- Raw CDP is a privileged developer escape hatch. - Do not expose an unauthenticated process-wide Chromium debugging port in production. Normal agents use the Browser Control Service. -
-
-
- -
-
-
07 / PROOF
-
-

Record the work, not the browser UX

-

- Video and structured traces are review artifacts. The interactive experience remains - the real browser; frames are sampled only for evidence generation. -

-
-
-
-
-
-
Capture
-

CDP Screencast

-

Guest-only compositor frames. Sample to a bounded target frame rate.

-
-
-
Worker surface
-

Recording Canvas

-

Isolated renderer draws frames and optional agent/human cursor overlays.

-
-
-
Encode
-

MediaRecorder

-

H.264 MP4, initial 12 fps, no external ffmpeg requirement.

-
-
-
Persist
-

Artifact Service

-

Video, timeline, screenshots, logs, network, downloads, traces.

-
-
-
-
-
-

Structured action timeline

-
    -
  • Actor, action, input summary, status
  • -
  • Before/after document revisions
  • -
  • Recording timestamp and screenshot refs
  • -
  • Failure, interruption, or timeout reason
  • -
-
-
-

Diagnostic evidence

-
    -
  • Console and uncaught exceptions
  • -
  • Failed requests and response summaries
  • -
  • Optional HAR / deeper trace mode
  • -
  • Secret redaction before persistence
  • -
-
-
-
- -
-
-
08 / SHARE
-
-

Human interruption is a feature

-

- The browser is collaborative, not locked. Explicit control leases make conflicting - input predictable while allowing observation and immediate human takeover. -

-
-
-
-
-
LEASE / READ
-
Observe freely
-
Snapshots, URL, title, console, network, and action state can continue while another actor controls input.
-
-
-
LEASE / WRITE
-
Serialize mutations
-
Clicks, typing, navigation, drag, and uploads run through the per-tab command queue with one active controller.
-
-
-
LEASE / BREAK
-
Human interrupts
-
Pointer or keyboard input cancels the conflicting agent operation and returns a typed interruption result.
-
-
-
LEASE / HAND
-
Adopt or hand off
-
Tabs carry human/agent origin and can be intentionally adopted, finalized, retained, or returned to the user.
-
-
-
- -
-
-
09 / REACH
-
-

Connection and reachability matrix

-

- The browser should accept environment-relative targets. Resolution decides whether - the browser loads directly or through a private loopback tunnel. -

-
-
-
- - - - -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
EnvironmentTarget exampleBrowser pathTransportProduct behavior
Same machine`localhost:5173`Direct local URLDirectNo tunnel. Preserve normal localhost browser semantics.
LAN / Tailscale`mac-mini.local:5173`Direct private-network URL when policy allowsPrivate networkPrefer direct reachability; fall back to environment stream if unavailable.
SSH environmentremote `127.0.0.1:5173`desktop loopback listener → preview stream → SSH-forwarded T3 serverT3 stream over SSHDefault path. Native `ssh -L` may be a later optimization.
T3 Connect relayenvironment `127.0.0.1:3000`desktop loopback listener → preview stream → existing environment hostnameT3 stream over relayOne stable public control endpoint; preview ports remain private logical streams.
Docker / WSL / VMenvironment-local serviceenvironment-port target → environment stream gatewayT3 streamAgent never guesses host IPs. Environment resolves its own loopback target.
Public app sharingshare preview with external persondedicated published endpointSeparate productNot the browser-preview tunnel. Requires explicit publication and separate access policy.
-
-
- -
-
-
10 / TUNNEL
-
-

One environment endpoint, many private streams

-

- Do not provision a public Cloudflare route for every dev port. The existing T3 server - endpoint carries authorized preview streams to environment-local TCP targets. -

-
-
-
-
-
Browser side
-
Guest webview
`127.0.0.1:49101`
-
Desktop loopback
TCP listener
-
Authenticated
preview stream
-
Environment server
stream endpoint
-
-
-
Environment side
-
Grant validation
-
Target policy
loopback + port
-
TCP dial
`127.0.0.1:5173`
-
Vite / Next / app
HTTP + WebSocket
-
-
-
-
-
- Raw TCP preserves browser behavior. - Root-relative assets, redirects, cookies, streaming responses, HMR upgrades, and application WebSockets pass without response rewriting. -
-
-
-
-

Initial stream model

-
    -
  • One WebSocket per accepted browser TCP connection
  • -
  • Dedicated from RPC/event traffic
  • -
  • Backpressure, timeout, cancellation, half-close
  • -
  • Easy to implement and diagnose
  • -
-
-
-

Scale-up model

-
    -
  • Multiplex streams per browser session
  • -
  • Per-stream flow-control windows
  • -
  • Bounded queues and concurrency
  • -
  • Only after profiling real asset-heavy apps
  • -
-
-
-
- -
-
-
11 / ROUTES
-
-

SSH and T3 Connect carry the same preview protocol

-

- Reachability transport changes; browser control does not. This keeps lifecycle, - grants, diagnostics, recording, and agent behavior consistent across environments. -

-
-
-
-
-
SSH
-
Browser loopback
-
Preview stream
-
Existing `ssh -L`
to T3 server
-
Remote target port
-
-
-
T3 Connect
-
Browser loopback
-
Preview stream
-
One managed
Cloudflare endpoint
-
Environment target port
-
-
-
LAN / Tailnet
-
Browser target
-
Direct URL or stream
-
Private network
-
Environment target port
-
-
-
-
- Default -

Stream through T3 server

-

One protocol across SSH, relay, and direct environments. Reuses authentication, grants, diagnostics, and lifecycle.

-
-
- Later optimization -

Native SSH port forward

-

Generalize the existing `ssh -L local:127.0.0.1:remote` manager only if direct forwarding materially improves performance or compatibility.

-
-
-
-
!
-
- Public port publishing is not preview reachability. - “Share port 5173 with the internet” requires explicit publication, separate access control, and possibly additional managed ingress. It should not happen automatically when an agent opens a browser. -
-
-
- -
-
-
12 / TRUST
-
-

Capability, site, target, and artifact boundaries

-

Browser access is powerful. Authorization should be explicit but not turn every click into a dialog.

-
-
-
-
-

Invocation scope

-

`observe`, `interact`, `navigate`, `evaluate`, `record`, `network-inspect`, and privileged `raw-cdp` capabilities.

-
-
-

Browser-attributed tunnel ingress

-

A loopback port is not authorization. Use a stable authority, identify the requesting guest, and scope the remote grant to one session and target.

-
-
-

Site policy

-

Origins, external navigation, auth pages, clipboard, uploads, downloads, permissions, evaluation, and raw CDP.

-
-
-

Artifact policy

-

Retention, size bounds, redaction of cookies/auth headers, encrypted storage where required, explicit sharing.

-
-
-
-
×
-
- Normal tool input never supplies environment or thread identity. - Identity comes from the authenticated provider/session scope. Browser and tunnel commands cannot cross-control another thread by argument substitution. -
-
-
-
!
-
- Phase 0.5 proved unrelated browser content can cause loopback requests. - Port secrecy and `Origin` checks are insufficient. Stable origins, browser attribution, bounded multiplexing, and explicit redirect/HTTPS policy gate the remote preview implementation. -
-
-
- -
-
-
13 / BUILD
-
-

Implementation sequence

-

Build the durable host first; everything else becomes simpler once panel visibility no longer owns browser lifetime.

-
-
-
-
-
Phase 0 / complete
-

Prove decisions

-

Semantic CDP, hidden lifecycle, H.264 recording, raw TCP preview streams, routing latency, and host comparison.

-
-
-
Phase 0.5 / complete
-

Close production risks

-

Renderer-death blast radius, Playwright injected runtime, 1600×1200 recording endurance at about 11 fps, loopback threat model, origin stability, and input interruption.

-
-
-
Phase 1
-

Durable session core

-

Session/tab/window contracts, app-level webview host, host-loss UX, annotations, source attribution, discovered servers, guest hardening, and parking states.

-
-
-
Phase 2
-

Browser control service

-

Persistent CDP, Playwright injected runtime, OOPIF routing, locator re-resolution, console/network buffers, interruption, and tab APIs.

-
-
-
Phase 3
-

Remote reachability

-

Environment-port targets, stable authorities, browser-attributed ingress, multiplexed TCP streams, redirect/HTTPS policy, SSH, and relay integration.

-
-
-
Phase 4
-

Evidence pipeline

-

One-recording-per-window MediaRecorder worker, action timeline, artifact upload, thread viewer, retention, redaction, and soak gates.

-
-
-
Phase 5
-

Collaboration + unattended hosts

-

Tab adoption/handoff, agent cursor, pause/revoke, optional environment-hosted Chromium for CI as a separate session type.

-
-
-
- -
-
-
14 / LOCKED
-
-

Pinned architecture decisions

-

These are the working defaults until implementation evidence requires an explicit ADR replacement.

-
-
-
-
- ADR 001 + 006 -

Route-durable renderer-hosted webview

-

Keep the real guest webview in a window-level host. Renderer reload, crash, and window close are explicit live-page loss, not transparent recovery.

-
-
- ADR 002 + 007 -

Playwright-injected semantic adapter

-

Use persistent CDP plus the version-pinned Playwright injected runtime behind T3 contracts. Locators re-resolve at action time.

-
-
- ADR 003 + 008 -

Provisionally bounded recording

-

Capture guest frames and encode H.264 MP4 in Chromium. Start with one active recording per window and require soak/concurrency gates.

-
-
- ADR 004 + 009 -

Attributed, stable preview tunnel

-

Keep raw TCP remotely, but require browser-attributed local ingress, stable authorities, bounded multiplexing, and redirect/HTTPS policy.

-
-
- ADR 005 -

Keep renderer transport relay initially

-

IPC overhead is negligible. Remove renderer lifecycle authority before adding another desktop-main network connection.

-
-
- ADR 010 -

Native human interruption

-

CDP keyboard dispatch did not emit `before-input-event` in the spike. Start with native user-input interruption and retain platform regression fixtures.

-
-
-
-
-
- Feasibility proof is not production proof. - Open gates remain: browser-attributed tunnel ingress, OOPIF routing, long-duration and multi-tab recording, platform input behavior, and recovery fidelity after host loss. Explicit non-goals still include browser-process survival, live migration, a synchronized second browser, and image-stream browser UX. -
-
-
- -
- Source plans: `collaborative-browser-runtime-next-iteration.md`, `browser-phase-0/*`, and `browser-phase-0-5/*`. - This HTML document is the consolidated visual architecture reference. -
-
-
- - - - diff --git a/.plans/collaborative-browser-runtime-next-iteration.md b/.plans/collaborative-browser-runtime-next-iteration.md deleted file mode 100644 index 2649ac5d012..00000000000 --- a/.plans/collaborative-browser-runtime-next-iteration.md +++ /dev/null @@ -1,1015 +0,0 @@ -# Collaborative Browser Runtime — Next Iteration - -## Purpose - -Turn the preview browser into a route-durable, programmable collaboration surface shared by users and agents. - -The browser is not an image preview and automation is not a secondary mode. The product should own a real browser session that: - -- remains alive independently of whether its panel is visible, while explicitly reporting host-renderer or window loss -- can be driven programmatically with reliable, semantic browser primitives -- can be inspected and manipulated by the user at any time -- records actions, video, console, network, and page state as reviewable evidence -- works when the development environment is remote -- can support multiple agent providers without provider-specific browser implementations - -The Codex in-app browser is a strong implementation reference for shared browser ownership, hidden tab retention, browser-client ergonomics, and human/agent handoff. It is not the target product boundary. T3 should preserve its provider-neutral and remote-capable architecture and improve on Codex where those requirements demand a different shape. - -This plan supersedes assumptions in: - -- `.plans/visible-preview-browser-automation-via-cdp-mcp.md` -- `.plans/shared-http-mcp-server-with-preview-automation-revised.md` - -Those plans remain useful implementation history, but their React-owned lifecycle, visibility-gated routing, fixed MCP command surface, and direct-only remote URL assumptions should not constrain this iteration. - -The Phase 0.5 review and follow-up spikes are authoritative amendments to Phase 0: - -- `.plans/browser-phase-0-5/findings.md` -- `.plans/browser-phase-0-5/006-renderer-failure-boundary.md` -- `.plans/browser-phase-0-5/007-playwright-injected-runtime.md` -- `.plans/browser-phase-0-5/008-recording-endurance.md` -- `.plans/browser-phase-0-5/009-loopback-threat-model.md` -- `.plans/browser-phase-0-5/010-human-input-interruption.md` - -## Product Principles - -### 1. The browser session is the product primitive - -The primary object is a durable logical `BrowserSession`, not a React panel, webview component, MCP request, or screenshot. The logical record can survive host loss; the live Chromium page state cannot. - -The panel is one view onto the session. Agent automation, recording, DevTools, snapshots, and user interaction all target the same session. - -### 2. Human and agent use the same page - -When an agent clicks, types, scrolls, navigates, or changes page state, the user must see that state when the browser is visible. When the user interacts, the agent's next observation must include the resulting state. - -Do not run a hidden Playwright browser beside an unrelated visible preview and attempt to synchronize them. - -### 3. Visibility is not lifecycle - -Closing or switching away from the browser panel must not destroy the browser session. Visibility, focus, control ownership, process lifetime, and recording state are separate concepts. - -The selected Electron `` is route-durable, not process-durable. A host-renderer reload, renderer crash, owning-window close, or application restart destroys the guest and must transition the logical tab to an explicit lost state. - -### 4. Prefer existing browser automation ecosystems - -Do not grow a large bespoke click/type/wait/selector framework when Playwright, CDP, and accessibility-based tooling already solve those problems. - -T3 should provide session discovery, authorization, routing, collaboration state, remote access, and artifact storage. Automation adapters should translate established browser APIs onto the T3-owned session. - -### 5. Remote environments are normal - -A browser attached to a remote environment must be able to open services bound to that environment's loopback interface without requiring the user to manually expose a LAN address. - -Remote access cannot be an afterthought or a warning-only UX. - -### 6. Evidence is first-class - -An agent must be able to start and stop a recording, capture screenshots, inspect console and network activity, and produce a machine-readable action trace. Evidence should be attached to the thread and usable by both the agent and user. - -Video recording is an artifact and debugging tool. It is not the mechanism used to render the interactive browser UI. - -## Current Architecture and Constraints - -The current browser implementation already has the most important property: the human-visible Electron `` and agent automation target the same guest `WebContents`. - -Current path: - -```text -agent - -> shared HTTP MCP tool - -> environment server PreviewAutomationBroker - -> WebSocket request to renderer owner - -> renderer PreviewAutomationOwner - -> Electron IPC - -> PreviewViewManager - -> guest webview WebContents through CDP -``` - -Current strengths: - -- real shared browser rather than a screenshot-driven duplicate -- provider-neutral MCP entry point -- environment and thread scoped authorization -- server-side routing compatible with remote T3 clients -- schema-validated contracts and tested broker behavior -- normal browser UX including navigation, history, zoom, DevTools, and annotations - -Current constraints to remove: - -- browser lifetime is anchored to React component mounting -- only three hidden preview threads are retained -- sheet and route behavior can unmount the browser -- most automation requires the owner to report `visible` -- ownership is inferred from a focused renderer rather than a durable browser host -- one global persistent preview partition is shared across projects and threads -- CDP attaches and detaches around individual operations -- the custom automation surface has limited selector and locator semantics -- requests take an avoidable renderer-mediated hop before reaching desktop browser control -- remote loopback URLs are not resolved to the remote environment -- recording, trace, console, network, and download artifacts are not modeled as one system -- a browser session cannot be intentionally handed between user and agent control modes - -The implementation may remove or replace these patterns rather than preserving compatibility internally. User-facing session state and existing thread behavior should be migrated deliberately. - -## Target Architecture - -Split the system into five boundaries: - -```text -Agent adapter / skill / MCP - | - v -Browser Control Service <----> Browser Artifact Service - | - v -Browser Session Registry - | - v -Browser Host Adapter <----> Environment Preview Gateway - | - v -Real Chromium page shown in T3 -``` - -### Browser Session Registry - -The environment server owns authoritative logical session metadata: - -```ts -interface BrowserSession { - id: BrowserSessionId; - environmentId: EnvironmentId; - threadId: ThreadId; - hostId: BrowserHostId | null; - activeTabId: BrowserTabId | null; - lifecycle: "creating" | "ready" | "suspended" | "recovering" | "closed"; - visibility: "visible" | "hidden" | "detached"; - controller: "human" | "agent" | "none"; - partitionId: BrowserPartitionId; - recordingId: BrowserRecordingId | null; - revision: number; - createdAt: string; - updatedAt: string; -} -``` - -The registry owns: - -- stable session and tab identities -- environment and thread association -- lifecycle and recovery state -- host assignment -- visibility and control state -- capabilities reported by the host -- recording and artifact references -- event revisions for reconnect and stale-command rejection - -The registry does not own Chromium directly. A browser host does. - -### Browser Host - -A `BrowserHost` is a process capable of owning and controlling real browser pages. - -Initial host adapter: - -- `ElectronBrowserHost` - - uses route-durable app-level renderer-hosted `` elements - - is mounted independently of thread routes, panel sheets, and preview components - - registers each guest `WebContents` with the desktop main process - - keeps desktop main authoritative for CDP, navigation, recording, and security - - moves tabs between visible panel bounds, offscreen idle parking, and covered recording parking - - binds each host to an owning window and reports renderer reload, renderer crash, or window close as host loss - -Future host adapter: - -- `EnvironmentChromiumHost` - - runs Chromium beside a remote/headless environment - - enables unattended testing and CI - - is not a second browser for the same active session - - creates a separate explicitly hosted session whose UI attachment strategy is a later phase - -Do not pretend a live browser process can migrate losslessly between hosts. A session is bound to one host. Migration requires an explicit restart/restore operation and must report what state cannot be preserved. - -### Browser Control Service - -The control service provides one internal session-oriented API regardless of the calling agent or host implementation. - -It owns: - -- authorization and invocation scope -- host discovery and routing -- command serialization per tab -- cancellation, deadlines, and stale revision checks -- automation adapter sessions -- control lease and user-interruption behavior -- structured event subscriptions -- artifact creation triggers - -It should expose a transport-neutral internal protocol. MCP is one adapter, not the core API. - -### Browser Artifact Service - -The artifact service stores and indexes: - -- screenshots -- video recordings -- action timelines -- console logs -- uncaught exceptions -- network request summaries -- HAR exports where supported -- DOM/accessibility snapshots -- downloads -- optional Playwright traces - -Artifacts are scoped to environment, thread, browser session, and provider session. Large payloads must not travel inline through the normal WebSocket event stream. - -### Environment Preview Gateway - -The gateway makes environment-local web services reachable by the browser host. - -Example: - -```text -remote environment localhost:5173 - -> environment TCP target - -> dedicated authenticated WebSocket tunnel - -> desktop loopback TCP listener - -> real webview navigation to desktop loopback -``` - -The browser may load a desktop-local authority so origin paths, root-relative assets, HMR, and application WebSockets retain normal semantics. Phase 0.5 proved that a bare loopback port is not an authorization boundary, changing ports loses origin storage, and absolute redirects can escape the authority. Browser-attributed ingress, stable authority assignment, and explicit redirect/HTTPS policy are required before the gateway is production-ready. The UI separately displays the environment-relative requested target. - -The gateway must support: - -- raw TCP forwarding with backpressure and half-close behavior -- HTTP, streaming responses, and WebSocket upgrades without application-level rewriting -- source maps, static assets, redirects, cookies, and arbitrary root-relative paths -- deterministic target identity -- connection loss and reconnect reporting -- explicit port authorization -- one dedicated tunnel connection per accepted TCP stream initially -- a separate priority class from normal RPC and event traffic - -The browser automation layer should receive both requested and resolved URLs. - -## Browser Ownership and UI Model - -### Move lifetime out of React - -Replace `usePreviewBridge` mount/unmount ownership with explicit commands: - -- `browserSession.create` -- `browserSession.attachView` -- `browserSession.detachView` -- `browserSession.setVisibility` -- `browserSession.close` - -The app-level browser host remains mounted for the desktop window lifetime. React panels report display bounds and visibility intent; they do not mount, reparent, or destroy browser elements. - -### Hidden browser behavior - -When hidden: - -- keep the webview and `WebContents` alive -- park idle tabs offscreen at their preserved viewport size to reduce compositor work -- move recording tabs to a full-size covered parking surface because offscreen tabs stop producing screencast frames -- preserve timers and network behavior by default -- allow automation and recording according to session policy -- expose an explicit low-resource suspension operation rather than silently evicting after an arbitrary count - -Resource pressure policy should be observable and deterministic: - -- warn before suspension -- store restorable metadata -- never silently destroy a session being controlled or recorded -- allow configurable limits by memory pressure and session activity - -### Tabs - -Make tabs first-class: - -```ts -interface BrowserTab { - id: BrowserTabId; - sessionId: BrowserSessionId; - origin: "human" | "agent" | "system"; - state: "opening" | "ready" | "closing" | "closed"; - url: string; - title: string; - active: boolean; - controller: "human" | "agent" | "none"; -} -``` - -Support: - -- agent-created tabs -- user-created tabs -- selecting and listing tabs -- adopting an existing user tab -- handing a tab back to the user -- closing only tabs owned by the current workflow when requested -- restoring tab metadata after client reconnect - -### Control and interruption - -Use an explicit control lease instead of assuming the most recently focused owner is always correct. - -Policy: - -- humans may always interrupt an agent action -- active user pointer or keyboard input cancels or pauses conflicting agent input -- automation receives `BrowserControlInterruptedError` -- read-only observations may continue during human control -- the UI shows when an agent controls a tab -- agent cursor and current action are visible but do not block normal browser input -- a user can pause, resume, or revoke automation for the session - -Avoid a heavyweight approval dialog for every action. Use session-level trust, site policy, and high-risk operation gates. - -## Programmatic Automation - -### Primary adapter decision - -Do not continue expanding a fixed set of hand-written CDP operations as the main agent interface. - -Implement an adapter boundary: - -```ts -interface BrowserAutomationAdapter { - connect(session: BrowserAutomationSession): Effect.Effect; -} -``` - -Phase 0.5 selected a **Playwright-injected semantic adapter behind a T3-owned browser-client boundary**: - -- persistent Electron debugger connection owned by desktop main -- the version-pinned Playwright injected runtime for selector parsing, semantic locators, shadow DOM, actionability, and hit-target behavior -- one injected runtime and execution context per frame/target, including explicit OOPIF target routing -- Playwright-like high-level semantics without Playwright owning the browser -- locator descriptions re-resolved at action time -- optional snapshot-scoped element references as ephemeral accelerators only - -Direct Playwright `connectOverCDP` does not expose Electron guest targets of type `webview` as Playwright pages. A `WebContentsView` does appear as a page, but it was rejected for this iteration because hidden/detached capture failed recording requirements and its native stacking model complicates collaboration UI. - -Playwright remains a behavior reference and test oracle. Its injected runtime is an internal, version-coupled dependency protected by compatibility fixtures and hidden behind T3 contracts. - -### Agent-facing API - -Offer multiple programmatic surfaces over the same control service: - -- MCP toolkit for providers that support MCP -- skill instructions and helper client for Codex-like runtimes -- CLI for debugging and providers that can execute shell commands -- internal TypeScript client for T3 features and tests -- optional raw CDP diagnostics only in explicitly trusted developer mode - -The default high-level surface should support: - -- create/open/list/close sessions and tabs -- navigate, reload, history, viewport, and zoom -- semantic locate, inspect, click, hover, drag, type, select, upload, and keyboard input -- frames, dialogs, popups, downloads, and new tabs -- wait for semantic conditions, requests, navigation, and page stability -- JavaScript evaluation with bounded results -- console and exception subscriptions -- request/response observation -- screenshots and recordings -- action groups and assertions - -Keep raw CDP as a privileged developer escape hatch, not the normal workflow. - -### Observation model - -Agents should not need a full screenshot after every action. - -Provide composable observations: - -- accessibility snapshot with stable element references -- semantic locator results -- bounded visible text -- viewport screenshot on demand -- current URL/title/loading state -- recent console and exception entries -- recent network failures -- DOM change summary since a known revision -- current control and recording state - -Locator descriptions are the primary durable handle and must re-resolve immediately before action. Snapshot references expire on navigation, document replacement, frame replacement, HMR, or node detachment; when the originating locator is available the adapter may retry through that locator. - -### Command execution - -Commands to a tab are serialized unless explicitly marked read-only and concurrent-safe. - -Each command includes: - -- command id -- browser session and tab id -- expected session/document revision where relevant -- provider and thread invocation scope -- deadline and cancellation token -- control requirement -- artifact/evidence policy - -Each result includes: - -- before and after revisions -- timing -- resulting URL and title -- structured error when applicable -- references to generated evidence - -## Recording, Tracing, and Debug Evidence - -### Recording requirements - -Agents and users can: - -- start recording the active tab or browser session -- stop recording and receive an artifact reference -- mark recording chapters around action groups -- capture a short rolling buffer after a failure -- attach recordings to thread messages or task completion evidence -- download or open recordings from the UI - -The browser remains a normal interactive webview while recording. - -### Selected capture pipeline - -Phase 0 selected: - -```text -guest webview Page.startScreencast - -> bounded frame sampler - -> isolated recording canvas - -> Chromium MediaRecorder - -> H.264 MP4 artifact -``` - -Initial defaults: - -- `video/mp4;codecs=avc1.42E01E` -- 12 frames per second -- current browser viewport with policy bounds -- dropped intermediate frames under encoder pressure instead of an unbounded queue - -When a recorded tab is hidden, the durable browser host places it on a covered full-size parking surface. Moving it offscreen stops continuous screencast frames. No external ffmpeg executable is required. - -CDP frame capture is internal to evidence generation. It does not replace the real browser as the interactive UX. - -### Action timeline - -Every recording can be paired with structured events: - -```ts -interface BrowserActionEvent { - id: BrowserActionEventId; - sessionId: BrowserSessionId; - tabId: BrowserTabId; - actor: "human" | "agent" | "system"; - action: string; - startedAt: string; - completedAt?: string; - status: "running" | "succeeded" | "failed" | "interrupted"; - inputSummary: unknown; - pageRevisionBefore: number; - pageRevisionAfter?: number; - screenshotBeforeId?: BrowserArtifactId; - screenshotAfterId?: BrowserArtifactId; - recordingOffsetMs?: number; - error?: BrowserErrorSummary; -} -``` - -This timeline is more useful to agents than video alone and allows the UI to jump from an action to the relevant recording timestamp. - -### Console and network capture - -Maintain bounded per-tab ring buffers for: - -- console entries -- uncaught exceptions -- failed requests -- response status summaries -- WebSocket connection failures -- navigation timing - -Allow explicit HAR/trace recording for deeper debugging. Redact authorization headers, cookies, and configured sensitive fields before persistence. - -## Remote-First Behavior - -### Required remote scenarios - -The design must support: - -1. Desktop UI and browser on a MacBook, environment and agent on a Mac mini. -2. Desktop UI and browser local, environment inside Docker, SSH, WSL, or a cloud VM. -3. Multiple desktop clients connected to one environment without ambiguous browser ownership. -4. Agent automation continuing when the browser panel is hidden. -5. Reconnecting a desktop client to an existing logical browser session after network interruption. -6. Unattended environment-side browser sessions for CI or automated verification in a later phase. - -### Preview target abstraction - -Agents should open an environment-relative target rather than constructing client-reachable URLs: - -```ts -type BrowserNavigationTarget = - | { kind: "url"; url: string } - | { kind: "environment-port"; port: number; protocol?: "http" | "https"; path?: string } - | { kind: "run-action"; actionId: string; path?: string }; -``` - -The environment resolves the target into: - -```ts -interface ResolvedBrowserTarget { - requested: BrowserNavigationTarget; - displayUrl: string; - browserUrl: string; - resolutionKind: "direct" | "environment-gateway" | "tunnel"; - environmentId: EnvironmentId; - expiresAt?: string; -} -``` - -This removes remote hostname guessing from prompts and agent logic. - -### Gateway security - -- do not treat loopback binding or port secrecy as authorization -- require browser-attributed ingress so unrelated pages and local processes cannot ride an authenticated environment tunnel -- keep the desktop authority stable for the granted environment/project/target tuple to preserve cookies, storage, service workers, and origin behavior -- scope gateway grants to environment, browser session, target host, and port -- use short-lived credentials inaccessible to page JavaScript where possible -- block cloud metadata and forbidden address ranges by policy -- require explicit configuration for non-loopback arbitrary upstream hosts -- validate redirects against the grant policy -- audit port openings and navigation resolutions -- do not place long-lived bearer tokens in visible URLs -- use a dedicated tunnel connection rather than the normal control/event WebSocket -- multiplex TCP streams over a bounded number of authenticated tunnel connections before T3 Connect rollout unless relay load tests prove per-stream connections safe -- define explicit compatibility behavior for absolute redirects, configured public origins, OAuth callbacks, and HTTPS upstreams - -### Reconnect behavior - -If the desktop connection drops: - -- mark the host unavailable without deleting the logical session -- fail or pause pending control commands deterministically -- retain artifact metadata and last-known tab state -- rebind when the same browser host reconnects and reports its session inventory -- report when the underlying `WebContents` was lost and recovery requires reload - -Do not claim browser process survival when the owning desktop application exited. - -## Security Model - -### Invocation scope - -Keep the existing session-scoped MCP authentication model, generalized to browser capabilities: - -- `browser:observe` -- `browser:interact` -- `browser:navigate` -- `browser:evaluate` -- `browser:record` -- `browser:network-inspect` -- `browser:raw-cdp` - -The agent cannot override environment, thread, or provider session identity in normal tool arguments. - -### Site policy - -Add environment/user policy for: - -- allowed origins and port ranges -- external internet navigation -- authentication pages -- file uploads and downloads -- clipboard access -- camera, microphone, geolocation, and notifications -- JavaScript evaluation -- raw CDP - -Local development origins may be trusted by default under a configurable policy. Sensitive external origins should require an explicit trust decision. - -### Browser partitions - -Replace the global `persist:t3code-preview` partition with deliberate isolation. - -Default proposal: - -- one persistent partition per environment and project -- optional isolated partition per browser session -- explicit user action to share authentication state between sessions -- clear partition lifecycle and storage deletion UX - -The final partition key must not contain raw secrets or unsafe filesystem characters. - -## Contract and Module Direction - -Likely contracts: - -```text -packages/contracts/src/browserSession.ts -packages/contracts/src/browserControl.ts -packages/contracts/src/browserArtifacts.ts -packages/contracts/src/browserGateway.ts -``` - -Keep contracts schema-only. - -Likely runtime modules: - -```text -apps/server/src/browser/ - Services/ - BrowserSessionRegistry.ts - BrowserControlService.ts - BrowserArtifactService.ts - BrowserTargetResolver.ts - Layers/ - Rpc/ - Mcp/ - -apps/desktop/src/browser/ - ElectronBrowserHost.ts - ElectronBrowserTab.ts - CdpConnection.ts - RecordingController.ts - ArtifactUploader.ts - -apps/web/src/browser/ - BrowserPanel.tsx - BrowserSessionProvider.tsx - BrowserTabs.tsx - BrowserControlIndicator.tsx - BrowserArtifactViewer.tsx - -packages/browser-client/ - session client - locator/action adapter - CLI or Node helper -``` - -Do not preserve `PreviewAutomationOwner` as the permanent routing boundary. Replace it with browser-host registration and session attachment protocols. - -## Migration Strategy - -### Phase 0: Technical Spikes and Decision Records - -**Status: completed.** - -Authoritative results and executable spikes: - -- `.plans/browser-phase-0/findings.md` -- `.plans/browser-phase-0/001-browser-host.md` -- `.plans/browser-phase-0/002-automation-adapter.md` -- `.plans/browser-phase-0/003-recording.md` -- `.plans/browser-phase-0/004-remote-preview-tunnel.md` -- `.plans/browser-phase-0/005-desktop-routing.md` -- `.plans/browser-phase-0/spikes/` - -Goals: - -- eliminate high-risk unknowns before reshaping contracts -- produce small executable prototypes rather than design-only conclusions - -Spikes: - -1. Attach Playwright or a Playwright-compatible locator runtime to the existing Electron guest webview. -2. Keep a guest `WebContents` alive and controllable while its panel is hidden and painting is reduced. -3. Record the guest content to a seekable video without replacing the interactive browser UX. -4. Proxy a remote environment's loopback HTTP and WebSocket dev server into the local desktop webview. -5. Measure CDP command latency through direct desktop routing versus the current renderer-mediated route. - -Deliverables: - -- decision record for automation adapter -- decision record for recording backend -- decision record for remote preview gateway transport -- measured CPU, memory, and latency results -- explicit unsupported cases - -Exit criteria: - -- completed: semantic role/name click and input worked against the real webview through CDP -- completed: Chromium MediaRecorder produced a seekable H.264 MP4 from covered webview capture -- completed: raw TCP tunneling carried HTTP and WebSocket traffic through a desktop loopback listener - -### Phase 0.5: Production-Risk Closure - -**Status: completed for the targeted desktop probes; browser-attributed tunnel ingress remains an implementation prerequisite.** - -Authoritative results: - -- `.plans/browser-phase-0-5/findings.md` -- `.plans/browser-phase-0-5/006-renderer-failure-boundary.md` -- `.plans/browser-phase-0-5/007-playwright-injected-runtime.md` -- `.plans/browser-phase-0-5/008-recording-endurance.md` -- `.plans/browser-phase-0-5/009-loopback-threat-model.md` -- `.plans/browser-phase-0-5/010-human-input-interruption.md` - -Confirmed: - -- host-renderer reload destroys the `` guest and live state, while a main-owned `WebContentsView` survives that specific reload -- Playwright's installed injected runtime can be evaluated in the real guest over CDP, resolves role/name locators through shadow DOM, and re-resolves after element replacement -- covered recording produced a seekable 1600×1200 H.264 MP4 for ten seconds at approximately 11.2 fps -- an unrelated browser page can cause a request to a loopback preview listener; loopback binding and port secrecy are not authorization -- changing the desktop port changes the browser origin and loses origin storage -- CDP keyboard dispatch did not emit Electron `before-input-event` in the tested guest - -Required carry-forward: - -- route-durable terminology and explicit host-loss UX -- version-pinned Playwright injected runtime with OOPIF routing and compatibility fixtures -- one-recording-per-window default until soak and concurrency budgets pass -- a browser-attributed desktop ingress design before remote preview implementation -- annotation/source attribution, port discovery, and guest isolation hardening in Phase 1 -- console and network ring buffers in Phase 2 - -### Phase 1: Durable Browser Session Core - -Goals: - -- separate browser lifetime from React -- introduce stable sessions, tabs, and host registration - -Tasks: - -1. Add browser session, tab, host, lifecycle, and capability schemas. -2. Add server `BrowserSessionRegistry` with revisioned events. -3. Add durable app-level `ElectronBrowserHost` plus desktop-main registration and heartbeat. -4. Move browser create/close ownership out of `usePreviewBridge` and panel components. -5. Add panel-bound reporting and visible/offscreen/covered parking transitions. -6. Remove the visibility requirement from control routing. -7. Replace hidden-thread count eviction with explicit resource policy. -8. Add per-environment/project partitioning. -9. Migrate existing preview open/close state to browser sessions. -10. Preserve the annotation/element-pick/source-attribution pipeline as a Browser Control Service capability. -11. Preserve discovered-server and run-action preview UX through environment-relative target contracts. -12. Harden guest isolation and document any narrowly required main-world injection. -13. Add owning-window identity, host-loss transitions, and explicit session-loss UX. - -Acceptance criteria: - -- hiding the panel does not destroy the page -- automation works while hidden -- reopening shows the exact current page state -- route changes do not accidentally close the session -- desktop reconnect reports and reconciles existing browser inventory -- browser closure is always an explicit lifecycle event - -### Phase 2: Control Service and Automation Adapter - -Goals: - -- replace fixed bespoke operations with a reusable automation connection -- preserve provider-neutral invocation - -Tasks: - -1. Add `BrowserControlService` with per-tab command queues and cancellation. -2. Add persistent CDP connection ownership in the desktop host. -3. Implement the selected Playwright or browser-client-style adapter. -4. Integrate the version-pinned Playwright injected runtime with locator re-resolution and compatibility fixtures. -5. Add target auto-attach, OOPIF routing, frames, popups, dialogs, downloads, drag, hover, and file upload support. -6. Add structured observation and incremental page revisions. -7. Add bounded console, exception, and network ring buffers. -8. Add a `packages/browser-client` helper and CLI. -9. Refactor MCP tools into a thin adapter over the control service. -10. Deprecate and remove redundant `PreviewAutomationOwner` operation dispatch. - -Acceptance criteria: - -- a provider can complete a multi-page workflow using semantic locators -- the same workflow can be driven through MCP and the browser client -- user interaction can interrupt an agent safely -- command failures identify stale page state, lost control, timeout, or host loss distinctly -- DevTools and automation coexist or fail with an explicit supported policy - -### Phase 3: Remote Preview Gateway - -Goals: - -- make remote loopback services first-class browser targets - -Tasks: - -1. Add environment-port and run-action target contracts. -2. Add target resolution, stable desktop authorities, and short-lived gateway grants. -3. Implement browser-attributed desktop ingress; reject a bare unauthenticated loopback listener. -4. Implement bounded multiplexed raw TCP streams over dedicated authenticated tunnel connections. -5. Add origin, cookie, service-worker, absolute-redirect, OAuth, HTTPS, and source-map compatibility tests. -6. Integrate run actions so agents can request the declared preview target directly. -7. Display requested and resolved target information in browser diagnostics. -8. Add reconnect and expired-grant recovery. - -Acceptance criteria: - -- a desktop browser can open `localhost` services from a remote environment without LAN exposure -- Vite HMR works through the gateway -- application WebSockets work through the gateway -- the agent never needs to guess the remote machine's IP address -- gateway permissions cannot be reused for unrelated ports or hosts - -### Phase 4: Recording and Evidence - -Goals: - -- make browser work inspectable, replayable, and attachable to tasks - -Tasks: - -1. Add browser artifact contracts and storage abstraction. -2. Add start/stop recording controls to agent and UI surfaces. -3. Add structured action timeline generation. -4. Add screenshot-before/after policies for action groups and failures. -5. Add artifact upload outside the normal WS payload path. -6. Add thread UI for video, screenshots, traces, and logs. -7. Add retention, cleanup, and size limits. -8. Add secret redaction for network and console artifacts. -9. Enforce the initial one-active-recording-per-window policy and expose encoder/drop health. - -Acceptance criteria: - -- an agent can record a test flow and return an artifact reference -- the user can watch the recording and jump to failed actions -- recordings work while the panel is hidden -- failed requests and console exceptions are included in the evidence bundle -- large artifacts do not block normal thread event delivery - -### Phase 5: Multi-Tab Collaboration and Unattended Hosts - -Goals: - -- mature collaboration UX -- add optional environment-side automation without compromising shared-session semantics - -Tasks: - -1. Add human/agent tab origin and ownership UX. -2. Add tab adoption, handoff, finalization, and workflow cleanup policies. -3. Add visible agent cursor and current-action overlays. -4. Add session pause/revoke controls. -5. Implement `EnvironmentChromiumHost` for CI and unattended verification. -6. Define how a remote-hosted session is viewed or attached without claiming it is the local Electron webview. -7. Add capability-based host selection. -8. Add test-report integrations using browser artifacts. - -Acceptance criteria: - -- agent-created tabs are identifiable and cleanly handed to the user -- user-created tabs can be intentionally adopted -- unattended browser workflows use a separately identified environment-hosted session -- no workflow silently operates a second browser while presenting another as the controlled browser - -## Testing Strategy - -### Contract tests - -- session, tab, host, control, artifact, and target schemas -- version and revision validation -- capability authorization -- invalid lifecycle transitions -- stale command rejection - -### Server tests - -- session registry lifecycle and event replay -- host assignment and reconnect -- per-tab command serialization -- cancellation and timeout behavior -- control lease interruption -- provider/thread isolation -- artifact metadata and cleanup -- gateway grant scoping - -### Desktop tests - -- webview registration cannot target unrelated `WebContents` -- hidden session remains navigable and controllable -- persistent CDP connection reconnects after navigation or target loss -- multiple tabs remain isolated -- recording starts, survives navigation, and finalizes -- resource policy never evicts active or recording sessions - -### Gateway integration tests - -- remote HTTP app -- Vite HMR WebSocket -- application WebSocket -- redirects -- cookies -- source maps -- upstream failure and reconnect -- forbidden host/port rejection - -### End-to-end tests - -1. Start a remote test environment and dev server bound only to loopback. -2. Open it through an environment-port target in the desktop browser. -3. Hide the panel. -4. Drive a semantic browser workflow through MCP or browser-client. -5. Record the workflow. -6. Reopen the panel and verify the same final page state. -7. Inspect the recording, action timeline, console, and network artifacts. - -Additional scenarios: - -- user interrupts agent typing -- desktop disconnects during navigation -- HMR reload occurs during locator interaction -- two desktop clients connect to the same environment -- two provider sessions cannot cross-control threads -- one provider loses authorization while another continues - -Do not mock core lifecycle, routing, or command-serialization logic. External Chromium, media encoding, filesystem artifact storage, and network transport boundaries may use test layers where necessary. - -## Performance and Reliability Budgets - -Measure and enforce budgets rather than treating performance as qualitative. - -Initial targets to validate during spikes: - -- control routing overhead excluding page work: p95 under 50 ms locally, under 150 ms over a typical remote T3 connection -- hidden idle session CPU: near-zero absent page activity -- hidden session memory: observable with configurable pressure policy -- screenshot capture: under 500 ms for a normal 1280px viewport -- semantic snapshot: under 300 ms for typical application pages -- recording overhead: under 15% CPU on supported desktop hardware at the selected resolution/frame rate -- browser host reconnect detection: under 5 seconds -- no unbounded console, network, screenshot, or action buffers - -Budgets may be adjusted after measurement, but the final values must be documented and covered by benchmarks or diagnostics. - -## Observability - -Add structured diagnostics for: - -- browser session and host lifecycle transitions -- tab creation, closure, and target changes -- command queue length and duration -- CDP connection state -- control lease changes and interruptions -- gateway resolution and proxy failures -- recording start, encoder health, and finalization -- artifact sizes and upload duration -- dropped or redacted evidence - -Provide a user-accessible browser diagnostics view or export so remote failures do not require reading opaque server logs. - -## Explicit Non-Goals - -- synchronizing two independent browsers and presenting them as one session -- replacing the interactive browser UI with JPEG or video streaming -- building a full custom Playwright clone -- preserving current internal APIs solely for compatibility -- guaranteeing browser process survival after the owning host exits -- transparent live migration of an active browser process between hosts -- unrestricted proxying to arbitrary private-network targets -- storing unlimited recordings or raw network bodies -- making Codex's browser architecture the permanent product boundary - -## Pinned Phase 0 + 0.5 Decisions - -1. Keep a route-durable renderer-hosted Electron ``; do not migrate to `WebContentsView` in this iteration. Explicitly model host-renderer reload, crash, and window close as live-page loss. -2. Use a T3-owned semantic CDP adapter backed initially by the version-pinned Playwright injected runtime. Do not depend on direct Playwright attachment and do not build locator/actionability semantics from scratch. -3. Record with CDP screencast plus Chromium MediaRecorder H.264 MP4 encoding. Default to one active recording per window until soak and concurrency budgets pass. -4. Keep raw TCP as the environment transport, but do not ship a bare loopback listener as the desktop authorization boundary. Require stable authorities, browser-attributed ingress, bounded multiplexing, and explicit redirect/HTTPS policy. -5. Keep the renderer as the environment transport relay initially because measured IPC overhead is negligible; remove its lifecycle authority. -6. Default browser partition scope is one persistent partition per environment and project, with optional isolated sessions. -7. Capability and site policy gates remain as defined in the Security Model section; guest isolation and the annotation/source-attribution pipeline are explicit Phase 1 requirements. -8. Artifact bytes use a dedicated storage/upload path; deployment-specific backends implement the shared artifact interface. -9. Locator descriptions re-resolve at action time. Snapshot node references are optional and ephemeral. -10. Initial controller modes are `human`, `agent`, and `none`; undefined shared control is removed. - -## Recommended Implementation Order - -1. Introduce session, tab, window, and host contracts from the completed Phase 0 and 0.5 ADRs. -2. Add the durable app-level webview host and desktop-main registry. -3. Move browser lifetime out of preview panels and `usePreviewBridge`. -4. Remove visibility-gated automation. -5. Add persistent CDP connections and command queues. -6. Integrate the Playwright injected runtime, per-frame routing, and semantic automation adapter. -7. Add console and network diagnostics. -8. Complete browser-attributed ingress and then add the multiplexed raw TCP environment preview tunnel. -9. Add the selected MediaRecorder evidence pipeline under the one-recording policy. -10. Add collaboration, tab handoff, and cursor UX. -11. Add optional environment-hosted Chromium sessions. -12. Remove superseded preview automation code while preserving annotations, source attribution, and discovered-server UX. - -## Completion Gates - -Each implementation phase must include tests for backend changes and must pass: - -- `vp check` -- `vp run typecheck` -- `vp test` - -Run `vp run lint:mobile` only when native mobile code changes. - -The next iteration is not complete merely when an agent can click the page. It is complete when the same durable browser session can be controlled by an agent, inspected and interrupted by a user, reached from remote environments, and reviewed through reliable evidence artifacts. diff --git a/.plans/shared-http-mcp-server-with-preview-automation-revised.md b/.plans/shared-http-mcp-server-with-preview-automation-revised.md deleted file mode 100644 index 12b2be5f78b..00000000000 --- a/.plans/shared-http-mcp-server-with-preview-automation-revised.md +++ /dev/null @@ -1,520 +0,0 @@ -# Shared HTTP MCP Server with Preview Automation - -## Summary - -Embed one reusable HTTP MCP server in each T3 environment server. Every agent session connects to that shared MCP endpoint using a session-scoped bearer token. - -Preview browser automation is the first MCP toolkit, not the purpose or boundary of the MCP server. Future T3 toolkits register with the same server. - -Architecture: - -`agent session` -> `shared T3 HTTP MCP server` -> `tool dispatcher` -> `preview broker` -> `focused desktop client` -> `Electron webview via CDP` - -No per-thread MCP process, stdio transport, headless browser, or automatic remote port forwarding. - -## Process Model - -Each `apps/server` process owns exactly one MCP server instance. - -- MCP transport: HTTP. -- MCP endpoint: `/mcp`. -- MCP lifetime: environment server lifetime. -- Agent sessions create MCP protocol sessions/connections, not OS processes. -- Toolkit registration happens once during server startup. -- Provider session termination revokes only its scoped credential. - -Implement the endpoint with: - -```ts -McpServer.layerHttp({ - name: "T3 Code", - version, - path: "/mcp", -}); -``` - -Use the API from: - -`effect/unstable/ai/McpServer` - -Reference source: - -`.repos/effect-smol/packages/effect/src/unstable/ai/McpServer.ts` - -Do not implement MCP framing, initialization, session management, or JSON-RPC manually. - -## Invocation Identity - -MCP `tools/call` does not include T3 thread identity. Bind identity to the MCP connection through authentication. - -When starting or resuming a provider session, issue an opaque bearer token associated internally with: - -```ts -interface McpInvocationScope { - environmentId: EnvironmentId; - threadId: ThreadId; - providerSessionId: string; - providerInstanceId: ProviderInstanceId; - allowedCapabilities: ReadonlySet; - issuedAt: string; - expiresAt: string; -} -``` - -Provider MCP configuration: - -```ts -{ - type: "http", - name: "t3", - url: `${environmentHttpBaseUrl}/mcp`, - headers: [ - { - name: "Authorization", - value: `Bearer ${token}`, - }, - ], -} -``` - -Requirements: - -- Agents never receive or pass `threadId` as a tool argument. -- Tool handlers obtain `McpInvocationScope` from request authentication middleware. -- Tokens are cryptographically random opaque values. -- Store only a hash of each token server-side. -- Revoke tokens when the provider session stops. -- Expire tokens after inactivity and at a fixed maximum lifetime. -- Server restart invalidates all tokens. -- Resuming a provider session issues a fresh token. - -## General MCP Architecture - -Add server modules such as: - -```text -apps/server/src/mcp/ - Services/ - McpSessionRegistry.ts - McpInvocationContext.ts - Layers/ - McpSessionRegistry.ts - McpHttpServer.ts - toolkits/ - preview/ - tools.ts - handlers.ts - layer.ts -``` - -### Toolkit Registration - -Define capabilities with Effect AI: - -```ts -import { McpServer, Tool, Toolkit } from "effect/unstable/ai"; -``` - -Each capability family owns: - -- `Tool.make` definitions -- a `Toolkit.make` collection -- handler services/layers -- an MCP registration layer - -Server startup merges all registration layers: - -```ts -const T3McpToolkits = Layer.mergeAll( - PreviewToolkitRegistration, - // Future toolkit registrations -); -``` - -Future filesystem, terminal, source-control, or environment tools must not require changes to MCP transport or authentication. - -### Naming - -Use stable capability-prefixed names: - -- `preview_status` -- `preview_open` -- `preview_navigate` -- `preview_snapshot` -- `preview_click` -- `preview_type` -- `preview_press` -- `preview_scroll` -- `preview_evaluate` -- `preview_wait_for` - -## MCP Authentication - -Integrate bearer authentication into the HTTP MCP route before MCP request handling. - -Authentication flow: - -1. Read `Authorization: Bearer `. -2. Hash token and resolve it through `McpSessionRegistry`. -3. Verify expiration and provider-session liveness. -4. Provide `McpInvocationContext` to MCP toolkit handlers. -5. Reject invalid credentials without invoking MCP tools. -6. Update token activity timestamp after authenticated requests. - -Capability authorization occurs inside handlers or common middleware: - -```ts -yield* McpInvocationContext.requireCapability("preview"); -``` - -## Preview Automation Contracts - -Add `packages/contracts/src/previewAutomation.ts`. - -Define schemas for: - -- preview status -- opening/showing preview -- navigation -- page snapshot -- selector or coordinate click -- text entry -- key press -- scrolling -- JavaScript evaluation -- waiting for selector, text, or URL - -Define tagged errors: - -- `PreviewAutomationUnavailableError` -- `PreviewAutomationNoFocusedOwnerError` -- `PreviewAutomationUnsupportedClientError` -- `PreviewAutomationTabNotFoundError` -- `PreviewAutomationTimeoutError` -- `PreviewAutomationExecutionError` -- `PreviewAutomationInvalidSelectorError` -- `PreviewAutomationResultTooLargeError` - -## Preview Toolkit - -Implement preview tools as an independent Effect AI toolkit. - -Apply annotations: - -- `preview_status` and `preview_snapshot`: read-only -- `preview_status`: idempotent -- navigation and page interaction tools: open-world -- browser operations: non-destructive -- all tools: human-readable title and precise description - -### `preview_open` - -Input: - -```ts -{ - url?: string; - show?: boolean; - reuseExistingTab?: boolean; -} -``` - -Defaults: - -- `show: true` -- `reuseExistingTab: true` - -Behavior: - -- Show the preview panel for the scoped thread. -- Reuse its active preview tab when available. -- Create and mount a tab otherwise. -- Navigate when `url` is supplied. -- Wait until the webview has registered before returning. - -### `preview_snapshot` - -Return: - -- current URL, title, loading state -- bounded visible text -- up to 200 interactive elements -- accessibility tree -- PNG screenshot scaled to a maximum width of 1280 pixels - -Expose the screenshot as MCP image content and metadata as structured content. - -### Browser Controls - -- `preview_click`: selector or viewport coordinates -- `preview_type`: optionally focus selector and clear existing value -- `preview_press`: common keys and modifiers -- `preview_scroll`: viewport or selector target -- `preview_evaluate`: execute bounded JavaScript -- `preview_wait_for`: selector, visible text, or URL substring -- `preview_navigate`: navigate and wait for selected readiness condition - -Default operation timeout: 15 seconds. - -Maximum serialized evaluation result: 64 KB. - -Maximum visible text: 20 KB. - -## Preview Broker - -Add `PreviewAutomationBroker` to `apps/server`. - -Responsibilities: - -- Track automation-capable desktop clients. -- Track preview ownership by environment and thread. -- Route operations to the correct desktop client. -- Correlate requests and responses. -- Enforce timeouts. -- Fail pending calls when clients disconnect. - -Owner state: - -```ts -interface PreviewAutomationOwner { - clientId: string; - environmentId: EnvironmentId; - threadId: ThreadId; - tabId: PreviewTabId | null; - visible: boolean; - supportsAutomation: boolean; - focusedAt: string; -} -``` - -Routing policy: - -- Use the most recently focused Electron window displaying the scoped thread. -- Never accept environment or thread overrides from tool arguments. -- Return `PreviewAutomationNoFocusedOwnerError` when no valid owner exists. -- Do not switch the UI to a different thread automatically. - -## Server-to-Desktop Protocol - -Add WS RPCs: - -- `previewAutomation.connect` -- `previewAutomation.respond` -- `previewAutomation.reportOwner` -- `previewAutomation.clearOwner` - -`connect` is a long-lived stream from the environment server to the desktop client. - -Request: - -```ts -{ - requestId: string; - threadId: ThreadId; - tabId?: PreviewTabId; - operation: PreviewAutomationOperation; - input: unknown; - timeoutMs: number; -} -``` - -Response: - -```ts -{ - requestId: string; - ok: boolean; - result?: unknown; - error?: { - _tag: string; - message: string; - detail?: unknown; - }; -} -``` - -## Desktop Automation - -Extend `PreviewViewManager` using Electron `webContents.debugger`. - -Use CDP domains: - -- `Runtime` -- `DOM` -- `Page` -- `Accessibility` -- `Input` - -Use `webContents.capturePage()` for screenshots. - -Create a scoped CDP helper that: - -1. Resolves the tab’s webContents. -2. Attaches lazily. -3. Enables required domains. -4. Executes one bounded operation. -5. Detaches in finalization. -6. Maps protocol failures to typed errors. - -If DevTools or another debugger owns the target, return a typed automation error rather than disrupting it. - -Place pure logic in separate modules: - -- DOM summary extraction -- selector generation -- result clamping -- key mapping -- CDP response parsing - -## Desktop IPC - -Extend `DesktopPreviewBridge` with: - -```ts -automation: { - status(...): Promise<...>; - snapshot(...): Promise<...>; - click(...): Promise<...>; - type(...): Promise<...>; - press(...): Promise<...>; - scroll(...): Promise<...>; - evaluate(...): Promise<...>; - waitFor(...): Promise<...>; -} -``` - -Add schema-validated IPC channels and handlers. - -## Web Client - -Add a preview ownership hook mounted with `PreviewView`. - -Report changes to: - -- active environment/thread -- tab id -- panel visibility -- window focus -- Electron automation availability - -Handle broker requests: - -- `preview_open` opens the right panel for the active scoped thread. -- Create a preview session and tab if needed. -- Wait for webview registration. -- Other operations invoke desktop automation IPC. -- Always send a correlated success or failure response. - -Clear ownership on unmount, thread change, panel close, or desktop disconnect. - -## Provider Integration - -Add `McpSessionRegistry` integration to provider lifecycle. - -For each provider session: - -1. Issue a scoped MCP bearer token. -2. Add the shared HTTP MCP configuration to session startup. -3. Start or resume the agent session. -4. Revoke the token during provider-session finalization. - -ACP providers use their existing HTTP MCP configuration fields. - -Codex uses its supported MCP/config override mechanism to register the same shared HTTP endpoint. - -Assume every supported provider can use HTTP MCP. Do not implement stdio fallback. - -## Remote Environment Behavior - -For a Mac mini environment viewed from a MacBook: - -1. Mac mini runs the T3 environment server and shared MCP endpoint. -2. Agent session connects to that endpoint locally/remotely using its scoped token. -3. Preview tool calls enter the Mac mini preview broker. -4. Broker routes them over the existing T3 connection to the focused MacBook desktop. -5. MacBook controls its visible Electron webview. - -URLs must already be reachable from the MacBook, such as: - -- `http://mac-mini.local:5173` -- `http://192.168.1.42:5173` - -Warn when a remote environment opens `localhost`, `127.0.0.1`, or `::1`, because loopback resolves on the preview client. - -Preserve URL-resolution metadata: - -```ts -{ - requestedUrl: string; - resolvedUrl: string; - resolutionKind: "direct"; - environmentId: EnvironmentId; -} -``` - -This leaves room for future SSH, relay, or Tailscale resolution. - -## Tests - -### MCP Server - -- one MCP server layer starts per environment server -- multiple authenticated MCP clients share the same server instance -- each client receives its own invocation scope -- toolkit registration is independent of transport -- a mock future toolkit can register without changing server runtime -- malformed parameters are rejected by Effect schemas -- tool annotations appear in `tools/list` - -### Authentication - -- valid token resolves correct thread and provider session -- concurrent tokens remain isolated -- revoked and expired tokens fail -- token cannot call unauthorized capability family -- thread identity cannot be overridden in arguments -- server restart invalidates tokens - -### Preview Broker - -- focused owner receives operation -- most recently focused client wins -- wrong-thread client is never selected -- no owner returns typed error -- disconnect fails pending calls -- stale responses are ignored - -### Desktop and Web - -- agent can show and open preview -- webview registration is awaited -- CDP click, typing, key press, scroll, evaluation, and wait work -- snapshot bounds screenshot, text, and interactive elements -- ownership updates on focus and visibility changes -- background threads are not automatically activated - -### End-to-End Integration - -Run two mocked agent sessions against one HTTP MCP server: - -1. Bind each bearer token to a different thread. -2. Call `preview_status` concurrently. -3. Verify each request routes to its own focused preview owner. -4. Verify no MCP child process is spawned. -5. Revoke one provider session and confirm only its MCP access fails. - -## Validation - -- `vp check` -- `vp run typecheck` -- `vp test` - -## Assumptions - -- HTTP MCP is supported by every target provider. -- One MCP server is embedded in each T3 environment server. -- MCP connection authentication supplies invocation identity. -- Agents never know or pass T3 thread IDs. -- Preview automation is the first of multiple future MCP toolkits. -- Only Electron desktop preview clients support browser automation in v1. -- No headless browser, stdio fallback, or automatic tunnel management is included. diff --git a/.plans/visible-preview-browser-automation-via-cdp-mcp.md b/.plans/visible-preview-browser-automation-via-cdp-mcp.md deleted file mode 100644 index 0b040d7a79c..00000000000 --- a/.plans/visible-preview-browser-automation-via-cdp-mcp.md +++ /dev/null @@ -1,670 +0,0 @@ -# Visible Preview Browser Automation via CDP + MCP - -## Summary - -Implement agent control of the user-visible T3 preview browser only. Do not add headless browser support. Do not add SSH/relay/private forwarding in v1; preview URLs must already be reachable from the desktop client, such as a Mac mini private IP URL opened on a MacBook. - -Architecture: - -`agent` -> `stdio MCP server in environment` -> `private T3 server bridge` -> `focused desktop client/window` -> `Electron preview webview via CDP` - -The stdio MCP server is the agent-facing integration because all target agents can speak MCP. The MCP server is intentionally thin: it does not automate Chromium directly. It calls the T3 environment server, which routes commands to the focused desktop client that owns the visible preview. - -## Explicit Non-Goals - -- No headless browser runner. -- No Playwright-managed browser. -- No arbitrary SSH dev-port forwarding in v1. -- No Cloudflare/Tailscale/relay URL rewriting in v1. -- No automation of browser/web clients that do not have Electron preview support. -- No per-action user approval prompts in v1. - -## Key Decisions - -- **Transport to agents:** stdio MCP. -- **Browser being controlled:** the actual integrated Electron preview webview. -- **Remote URL handling:** manual reachable URLs only. Agents may open `http://mac-mini.local:5173` or `http://192.168.x.y:5173`; T3 will not tunnel `127.0.0.1:5173` from remote to local yet. -- **Client routing:** route tool requests to the most recently focused desktop window/client for the agent’s thread. Return a typed error if no focused desktop preview owner is available. -- **Scope:** full control of preview browser, including opening/showing the preview panel. -- **Primary automation engine:** Chrome DevTools Protocol through Electron `webContents.debugger`, with `webContents.capturePage()` where it is simpler and more reliable. - -## New Contracts - -Add `packages/contracts/src/previewAutomation.ts`. - -### Branded IDs - -- `PreviewAutomationRequestId` -- `PreviewAutomationClientId` -- `PreviewAutomationOwnerId` - -### Tool Input/Output Schemas - -Add Effect schemas for these operations: - -- `PreviewAutomationOpenInput` - - `url?: string` - - `show?: boolean` default `true` - - `reuseExistingTab?: boolean` default `true` -- `PreviewAutomationNavigateInput` - - `url: string` - - `waitUntil?: "load" | "domcontentloaded" | "network-idle" | "none"` default `"load"` - - `timeoutMs?: number` default `15000` -- `PreviewAutomationSnapshotInput` - - `includeScreenshot?: boolean` default `true` - - `includeDomSummary?: boolean` default `true` - - `includeAccessibilityTree?: boolean` default `true` - - `screenshotMaxWidth?: number` default `1280` -- `PreviewAutomationClickInput` - - one of: - - `{ selector: string }` - - `{ x: number; y: number }` - - `button?: "left" | "middle" | "right"` default `"left"` - - `clickCount?: number` default `1` -- `PreviewAutomationTypeInput` - - `text: string` - - `selector?: string` - - `clearFirst?: boolean` default `false` -- `PreviewAutomationPressInput` - - `key: string` - - `modifiers?: readonly ("alt" | "control" | "meta" | "shift")[]` -- `PreviewAutomationScrollInput` - - `deltaX?: number` - - `deltaY?: number` - - optional target `{ selector: string }` -- `PreviewAutomationEvaluateInput` - - `expression: string` - - `awaitPromise?: boolean` default `true` - - `returnByValue?: boolean` default `true` -- `PreviewAutomationWaitForInput` - - one of: - - `{ selector: string }` - - `{ text: string }` - - `{ urlIncludes: string }` - - `timeoutMs?: number` default `10000` -- `PreviewAutomationStatusResult` - - `available: boolean` - - `visible: boolean` - - `threadId` - - `tabId: string | null` - - `url: string | null` - - `title: string | null` - - `loading: boolean` - - `ownerClientId: string | null` - -### Result Shape - -Every mutating or stateful operation returns: - -```ts -{ - ok: boolean; - status: PreviewAutomationStatusResult; - message?: string; -} -``` - -`snapshot` additionally returns: - -```ts -{ - status: PreviewAutomationStatusResult; - screenshot?: { - mimeType: "image/png"; - dataBase64: string; - width: number; - height: number; - }; - domSummary?: { - url: string; - title: string; - activeElement: string | null; - text: string; - interactiveElements: readonly { - index: number; - tag: string; - role: string | null; - name: string; - text: string; - selector: string | null; - rect: { x: number; y: number; width: number; height: number } | null; - }[]; - }; - accessibilityTree?: unknown; -} -``` - -### Error Types - -Add tagged errors: - -- `PreviewAutomationUnavailableError` -- `PreviewAutomationNoFocusedOwnerError` -- `PreviewAutomationUnsupportedClientError` -- `PreviewAutomationTabNotFoundError` -- `PreviewAutomationTimeoutError` -- `PreviewAutomationExecutionError` -- `PreviewAutomationInvalidSelectorError` - -## Server-Side Broker - -Add `apps/server/src/previewAutomation/Services/PreviewAutomationBroker.ts`. - -Responsibilities: - -- Track connected desktop automation clients. -- Track focus ownership by `(environmentId, threadId)`. -- Accept tool calls from MCP/stdin proxy. -- Route each call to the focused owner for the thread. -- Enforce timeouts and cleanup pending requests on disconnect. -- Return typed failures when no client/window is available. - -### Owner State - -Store: - -```ts -{ - clientId: PreviewAutomationClientId; - environmentId: EnvironmentId; - threadId: ThreadId; - tabId: PreviewTabId | null; - focusedAt: string; - visible: boolean; - supportsAutomation: boolean; -} -``` - -Ownership updates come from the web/desktop client when: - -- route/thread changes, -- preview panel opens/closes, -- window focus changes, -- tab id changes, -- desktop bridge availability changes. - -## WS Bridge: Server to Desktop Client - -The current preview RPCs are client-to-server plus server event streams. Add a request/response bridge using stream-style RPCs to avoid introducing bidirectional RPC infrastructure. - -### New WS Methods - -Add to `packages/contracts/src/rpc.ts`: - -- `previewAutomation.connect` - - client calls this as a long-lived stream - - input: - - `clientId` - - `capabilities` - - stream output: - - `PreviewAutomationClientRequest` -- `previewAutomation.respond` - - client sends response for a request id -- `previewAutomation.reportOwner` - - client reports focus/visibility/thread ownership -- `previewAutomation.clearOwner` - - client clears stale ownership on unmount/disconnect - -### Request Shape - -```ts -{ - requestId: string; - threadId: string; - tabId?: string; - operation: - | "open" - | "navigate" - | "snapshot" - | "click" - | "type" - | "press" - | "scroll" - | "evaluate" - | "waitFor" - | "status"; - input: unknown; - timeoutMs: number; -} -``` - -### Response Shape - -```ts -{ - requestId: string; - ok: boolean; - result?: unknown; - error?: { - _tag: string; - message: string; - detail?: unknown; - }; -} -``` - -## Desktop Preview Automation - -Extend `apps/desktop/src/preview-view-manager.ts`. - -### Add Methods - -- `getAutomationStatus(tabId)` -- `captureSnapshot(tabId, options)` -- `click(tabId, input)` -- `type(tabId, input)` -- `press(tabId, input)` -- `scroll(tabId, input)` -- `evaluate(tabId, input)` -- `waitFor(tabId, input)` - -### CDP Session Handling - -Add a small helper inside desktop preview code: - -- Attach `webContents.debugger` lazily per operation. -- Do not keep debugger attached forever unless needed. -- If already attached by DevTools or another debugger, return `PreviewAutomationExecutionError`. -- Use CDP domains: - - `Runtime.evaluate` - - `DOM.getDocument` - - `DOM.querySelector` - - `DOM.getBoxModel` - - `Accessibility.getFullAXTree` - - `Input.dispatchMouseEvent` - - `Input.dispatchKeyEvent` - - optionally `Page.captureScreenshot` if `webContents.capturePage()` is insufficient - -For screenshots, prefer `webContents.capturePage()` first because it is already used safely for annotations. - -### DOM Summary Script - -Use `Runtime.evaluate` with a bounded page script that returns: - -- `document.URL` -- `document.title` -- active element summary -- visible text truncated to a fixed limit, e.g. 20k chars -- up to 200 interactive elements: - - buttons - - links - - inputs - - selects - - textareas - - elements with roles - - elements with click handlers where detectable -- stable-ish CSS selectors generated in page context -- bounding rects - -Do not return full HTML by default. - -### Input Behavior - -- `click(selector)`: - - resolve selector in page - - scroll into view - - compute center of bounding box - - dispatch mouse move/down/up through CDP -- `click(x, y)`: - - dispatch at viewport coordinates -- `type(selector, text)`: - - focus selector if provided - - optionally clear existing value with platform shortcut - - dispatch text via CDP keyboard events or `Input.insertText` -- `press(key)`: - - map common key names: Enter, Escape, Tab, Backspace, Arrow keys - - support modifiers -- `scroll`: - - use CDP mouse wheel or page `scrollBy` fallback - -## Web Client Changes - -Update `apps/web/src/components/preview`. - -### Ownership Reporting - -Add a hook, likely `usePreviewAutomationOwner`, mounted near `PreviewView`. - -It reports owner state when: - -- the current route thread ref changes, -- preview panel visibility changes, -- browser window focus/blur changes, -- tab id changes, -- desktop preview bridge exists. - -Policy: - -- Only Electron desktop clients with `window.desktopBridge.preview` can report `supportsAutomation: true`. -- A visible preview panel gets ownership. -- The most recently focused window wins. -- On unmount or preview close, clear ownership. - -### Handling Server Requests - -Add a client-side subscriber using the new `previewAutomation.connect` stream. - -When a request arrives: - -- if operation is `open`, ensure preview panel is visible for the thread, call `api.preview.open` if needed, mount/create desktop tab, then navigate if URL is provided. -- for browser operations, call new `desktopBridge.preview.automation.*` methods. -- send `previewAutomation.respond`. - -Opening behavior: - -- If the right panel is closed, open it to preview. -- If the thread is not currently active in the UI, do not navigate the whole app in v1. Return `PreviewAutomationNoFocusedOwnerError`. -- If preview is supported but no tab exists and the agent calls `open`, create one. -- If no tab exists and the agent calls `snapshot/click/type/...`, return a clear “preview not open” error suggesting `preview_open`. - -## Desktop IPC / Preload - -Extend `packages/contracts/src/ipc.ts` `DesktopPreviewBridge`. - -Add: - -```ts -automation: { - status(tabId: string): Promise; - snapshot(tabId: string, input: PreviewAutomationSnapshotInput): Promise; - click(tabId: string, input: PreviewAutomationClickInput): Promise; - type(tabId: string, input: PreviewAutomationTypeInput): Promise; - press(tabId: string, input: PreviewAutomationPressInput): Promise; - scroll(tabId: string, input: PreviewAutomationScrollInput): Promise; - evaluate(tabId: string, input: PreviewAutomationEvaluateInput): Promise; - waitFor(tabId: string, input: PreviewAutomationWaitForInput): Promise; -} -``` - -Add IPC channels in `apps/desktop/src/ipc/channels.ts` and handlers in `apps/desktop/src/ipc/methods/preview.ts`. - -## Stdio MCP Server - -Add package: `packages/preview-mcp`. - -Purpose: - -- Implements the MCP stdio protocol. -- Exposes T3 preview tools. -- Calls a private loopback endpoint or local JSON-RPC bridge on the environment server. -- Does not know Electron/CDP details. - -### Binary - -Expose bin: - -```json -{ - "bin": { - "t3-preview-mcp": "./dist/index.js" - } -} -``` - -During local dev, provider config can call the source runner via workspace package script; production package uses built JS. - -### MCP Environment Variables - -Provider sessions launch the MCP server with: - -- `T3_PREVIEW_MCP_SERVER_URL` - - environment server loopback URL -- `T3_PREVIEW_MCP_TOKEN` - - short-lived token scoped to the provider session/thread -- `T3_PREVIEW_ENVIRONMENT_ID` -- `T3_PREVIEW_THREAD_ID` - -The token must be generated by the environment server and expire when the provider session ends. - -### MCP Tools - -Expose these tools: - -- `preview_status` -- `preview_open` -- `preview_navigate` -- `preview_snapshot` -- `preview_click` -- `preview_type` -- `preview_press` -- `preview_scroll` -- `preview_evaluate` -- `preview_wait_for` - -Tool descriptions must explicitly say they operate the visible T3 preview browser for the current thread. - -### MCP Output - -- Text results include concise status and URL/title. -- `preview_snapshot` returns: - - text summary - - MCP image content for screenshot when available -- Errors are MCP tool errors with the tagged T3 error message included. - -## Private MCP Bridge Endpoint - -Add an internal server route under `apps/server`, not public app UI: - -- `POST /internal/preview-automation/tool` -- Auth: bearer `T3_PREVIEW_MCP_TOKEN` -- Body: - - `{ tool: string, input: unknown }` -- Response: - - `{ ok: true, result } | { ok: false, error }` - -This endpoint is only for the stdio MCP proxy. It calls `PreviewAutomationBroker`. - -Bind it to the same host/port as the environment server, but require the short-lived token. The endpoint must reject requests without a token or with an expired/stale thread/session. - -## Provider Integration - -### Codex - -When starting a Codex provider session, add the T3 preview MCP server to Codex configuration if the app-server config path supports per-thread MCP injection. - -Implementation path: - -1. Add `PreviewMcpSessionService` in `apps/server`. -2. On provider session start: - - create scoped MCP token for `(environmentId, threadId, providerSessionId)`. - - build MCP server config: - - name: `t3-preview` - - command: `t3-preview-mcp` - - env vars listed above -3. Thread/session start passes the MCP server config through the provider’s supported config field. -4. If Codex app-server cannot accept injected MCP servers through typed params, use its `config` override field with Codex-compatible MCP config. - -### ACP Providers - -ACP session creation already passes `mcpServers: []`. Replace that with the same `t3-preview` MCP server config for providers that support MCP. - -### Provider Fallback - -If a provider does not support MCP injection yet, do not add provider-specific native tools in v1. The shared MCP server remains available for later provider wiring. - -## Remote Environment Behavior - -### Mac mini dev server viewed from MacBook - -Expected v1 workflow: - -1. Agent runs dev server on Mac mini. -2. Agent or user opens a reachable URL, e.g.: - - `http://mac-mini.local:5173` - - `http://192.168.1.42:5173` -3. T3 desktop on MacBook opens that URL in the local Electron preview. -4. Agent uses MCP tools. -5. T3 routes tool calls from Mac mini environment server to the focused MacBook preview client. - -### `localhost` Caveat - -In v1, if an agent opens `http://localhost:5173` from a remote environment, the MacBook preview will interpret that as MacBook localhost. The tool result should include a warning when: - -- environment is not the primary/local environment, and -- URL hostname is `localhost`, `127.0.0.1`, or `::1`. - -Warning text: - -`This URL is loopback on the preview client, not necessarily the remote environment. Use a client-reachable host/IP for remote dev servers.` - -### Future-Proofing - -Do not bake manual URL assumptions into the automation layer. Represent opened URLs as: - -```ts -{ - displayUrl: string; - requestedUrl: string; - resolutionKind: "direct"; - environmentId: string; -} -``` - -Later `resolutionKind` can add: - -- `ssh-forward` -- `relay` -- `tailscale` -- `cloudflare-tunnel` - -## Security and Safety - -- MCP tokens are scoped to one provider session/thread. -- MCP tokens expire on provider session stop and server restart. -- Browser automation only routes to focused desktop owner for that same thread. -- Do not allow MCP input to specify arbitrary `environmentId` or `threadId`; infer both from token/session. -- `preview_evaluate` is powerful. Keep it enabled in v1 because the user requested full control, but: - - limit result serialization size, e.g. 64 KB - - timeout evaluation - - return by value by default - - document that it executes in the preview page context -- Screenshot output should be bounded: - - max dimensions - - max base64 size - - return error if too large after scaling attempts -- Clear pending broker requests when desktop disconnects. -- CDP operations must timeout and detach debugger in `finally`. - -## Failure Modes - -Return typed errors for: - -- no desktop client connected -- desktop client connected but preview unsupported -- no focused owner for thread -- preview panel not open and operation is not `preview_open` -- webview not initialized yet -- navigation timeout -- selector not found -- CDP debugger unavailable -- page execution error -- screenshot too large -- stale request id or response after timeout - -## Tests - -### Contracts - -Add tests for: - -- schema decoding for every preview automation input/result -- error schema decoding -- invalid selector/click union inputs rejected -- snapshot options defaulting - -### Desktop Unit Tests - -Add tests around `PreviewViewManager` helpers: - -- owner-independent status when tab exists/does not exist -- selector summary script clamps output -- selector generation handles ids/classes/nth-child fallback -- error mapping for missing webContents -- screenshot result shape - -Where Electron/CDP is hard to unit test, isolate pure helpers and cover IPC handler validation. - -### Server Broker Tests - -Add tests for: - -- registering clients -- ownership updates -- focused owner wins -- request routed to focused owner -- request timeout -- client disconnect fails pending requests -- response with unknown request id ignored/rejected -- token scoped to thread/session -- remote loopback URL warning generated - -### Web Client Tests - -Add tests for: - -- ownership report sent only in Electron preview-supported runtime -- opening preview panel on `preview_open` -- no route switch for background thread -- clear ownership on unmount -- response sent for successful and failed desktop bridge calls - -### MCP Tests - -Add tests for: - -- tool list includes all expected preview tools -- each tool maps to internal bridge request -- token missing/invalid returns MCP error -- snapshot maps screenshot to MCP image content -- tool errors preserve tagged T3 error message - -### Integration Tests - -Add a focused integration test using mocked desktop client: - -1. Start server broker. -2. Register fake desktop automation client. -3. Mark it focused for thread. -4. Invoke MCP `preview_open`. -5. Assert fake client received open request. -6. Respond success. -7. Assert MCP response is successful. - -Add a second integration test: - -1. No focused owner. -2. Invoke `preview_snapshot`. -3. Assert `PreviewAutomationNoFocusedOwnerError`. - -## Validation Commands - -Before completion: - -- `vp check` -- `vp run typecheck` -- `vp test` - -No `vp run lint:mobile` required unless mobile code is changed. - -## Implementation Order - -1. Add contracts for preview automation schemas and WS methods. -2. Add `PreviewAutomationBroker` server service. -3. Add WS stream bridge for desktop clients. -4. Add desktop IPC and `PreviewViewManager` CDP automation methods. -5. Add web ownership reporting and request handling. -6. Add private `/internal/preview-automation/tool` endpoint with scoped token auth. -7. Add `packages/preview-mcp` stdio MCP server. -8. Wire provider sessions to launch/register `t3-preview` MCP server where provider protocols support MCP config. -9. Add remote loopback URL warning. -10. Add tests. -11. Run validation commands. - -## Assumptions - -- v1 only supports Electron desktop preview clients. -- The active/focused thread preview is the right target for agent control. -- Manual client-reachable URLs are acceptable for remote dev servers. -- Full browser control includes `evaluate`. -- Stdio MCP is the agent-facing transport. -- A private server bridge behind the stdio MCP server is acceptable and necessary because the browser lives on the desktop client, not beside the remote agent. From 4b6022738e4e6d0c868cf6112a081a150ecc5c57 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 13 Jun 2026 21:57:32 -0700 Subject: [PATCH 25/25] rm test artifacts --- .../t3-code-connect-auth-flow-3-page.html | 592 ------------------ docs/cloud/t3-code-connect-auth-flow.pdf | Bin 113502 -> 0 bytes 2 files changed, 592 deletions(-) delete mode 100644 docs/cloud/t3-code-connect-auth-flow-3-page.html delete mode 100644 docs/cloud/t3-code-connect-auth-flow.pdf diff --git a/docs/cloud/t3-code-connect-auth-flow-3-page.html b/docs/cloud/t3-code-connect-auth-flow-3-page.html deleted file mode 100644 index 9fe7bd59081..00000000000 --- a/docs/cloud/t3-code-connect-auth-flow-3-page.html +++ /dev/null @@ -1,592 +0,0 @@ - - - - - - T3 Connect Architecture Brief - - - -
-
Architecture brief · page 1 of 3
-

T3 Connect Control Plane and Managed Endpoint Flow

-

- T3 Connect links a locally authorized T3 environment to a signed-in cloud user, provisions a - managed HTTPS/WSS endpoint, and brokers proof-bound remote connections. The environment - remains the access authority; the relay coordinates identity, endpoint lifecycle, and - notifications. -

-
- -
-
-

Security Invariant

-

Cloud identity alone is not an environment login. Remote access requires:

-
    -
  • a Clerk-authenticated T3 Connect user;
  • -
  • an active user/environment relay link;
  • -
  • a DPoP private key held by the remote client;
  • -
  • a relay-signed mint request accepted locally; and
  • -
  • an environment token bound to the client's DPoP key.
  • -
-
- The relay cannot mint an environment access token by itself. The local environment - issues the bootstrap credential and final access token. -
-
-
-

Trust Boundaries

-
    -
  • Clerk: user identity and OAuth/PKCE.
  • -
  • Relay Worker: links, endpoint allocations, proofs, and notifications.
  • -
  • Environment: local sessions, signing keys, and access tokens.
  • -
  • Relay client: exposes only the validated loopback HTTP server.
  • -
  • Remote client: retains the DPoP private key and environment token.
  • -
-
- The relay remains a trusted mint broker because it holds the cloud-mint signing key. - DPoP limits credential reuse but does not neutralize compromise of that authority. -
-
- -
-

Current Topology

-
-
-

Clients

- Desktop web UI
Mobile app
Headless CLI

- Clerk bearer / relay DPoP -
-
-
-

T3 Code Relay

- Cloudflare Worker
Hyperdrive + Postgres
Tunnel/DNS APIs
APNs queue -
-
-
-

Linked Environment

- Managed HTTPS/WSS endpoint
Relay client
Loopback server
Local secrets -
-
-
- Steady-state HTTP and WebSocket traffic goes directly from the remote client through the - managed endpoint. The relay handles sparse health and bootstrap requests, not the active - session data path. -
-
- -
-

Authentication Boundaries

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
BoundaryAuthenticationPurpose
Client → relay link managementClerk bearer / CLI OAuthCreate, list, or remove links.
Client → protected relay APIsRelay DPoP token + proofStatus, connect, and mobile registration.
Relay → environmentShort-lived relay-signed JWTSigned health and credential-mint requests.
Client → environmentEnvironment token + DPoPNormal HTTPS APIs and WSS ticket issuance.
Environment → relayEnvironment bearer + signed proofPublish redacted agent activity.
-
-
- -
- -
-
Core workflows · page 2 of 3
-

Link, Bootstrap, and Operate

-

- Every workflow preserves local authority while making cloud coordination retry-safe and - suitable for desktop, mobile, and headless use. -

-
- -
-
-

1. Link a Local Environment

-
-
- AuthenticateDesktop/mobile uses a Clerk bearer. CLI uses browser PKCE and - persists desired link state. -
-
- Prove locallyRelay issues a short-lived challenge. The local environment - validates loopback origin and signs it. -
-
- ReconcileRelay verifies proof and capabilities, then creates or reuses the - tunnel, ingress, and CNAME. -
-
- ActivateRelay returns endpoint config and connector token. Environment persists - config and starts the relay client. -
-
-
- The local proof endpoint requires relay:write, rejects forwarded authority - headers, and atomically persists the environment key pair. -
-
- -
-

2. Remote Connection Bootstrap

-
-
- Proof keyClient creates a DPoP key and exchanges its Clerk JWT for a relay DPoP - access token. -
-
- Mint requestClient calls connect. Relay sends an audience-bound, short-lived - signed proof to the managed endpoint. -
-
- Local issueEnvironment validates user, scope, lifetime, nonce, and DPoP - thumbprint, then returns a signed bootstrap credential. -
-
- Direct sessionClient redeems locally for an environment token, gets a one-time - WSS ticket, and opens the WebSocket. -
-
-
- The relay validates the environment response signature, nonce, endpoint, and DPoP - binding before returning bootstrap data to the client. -
-
- -
-

Managed Endpoint Lifecycle

-
    -
  1. - Reserve deterministic hostname and tunnel name for - (userId, environmentId). -
  2. -
  3. - Reuse or create the named tunnel; rewrite ingress to the validated loopback origin. -
  4. -
  5. - Reconcile CNAME records and remove duplicates; recover safely from create conflicts. -
  6. -
  7. Mark ready only after resource IDs and namespace checks are persisted.
  8. -
-
-
-

Unlink Cleanup

-
    -
  • Delete managed DNS and tunnel resources.
  • -
  • Remove the allocation and revoke the user link.
  • -
  • Revoke publication credentials after the final active link disappears.
  • -
  • Stop the local relay client and clear persisted relay configuration.
  • -
  • Retain checkpoints when teardown fails so cleanup can be retried.
  • -
-
- -
-

3. Agent Activity Notifications

-
-
- ProjectEnvironment creates a narrow publishable thread state and redacts/caps - failure details. -
-
- SignEnvironment posts with its bearer credential and a per-state signed proof. -
-
- PersistRelay checks credential, signature, scope, expiry, and nonce, then - stores current state. -
-
- DeliverQueue workers send push notifications or Live Activity updates through - APNs. -
-
-
-
- -
- -
-
Controls and operations · page 3 of 3
-

Hardening and Deployment Contract

-

- The design constrains tunnel origins, proof audiences, egress destinations, redirects, - credential exposure, and teardown behavior. -

-
- -
-
-

SSRF and Tunnel Controls

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
RiskImplemented control
Arbitrary tunnel upstreamBoth sides accept managed origins only for loopback hosts and valid ports.
Forwarded-origin confusion - Link proof requires the exact loopback URL and rejects forwarded host/protocol - headers. -
Stored endpoint replacement - Status/connect resolve ready allocations from Postgres and require the managed DNS - namespace. -
Redirected relay egressRelay health and mint requests use manual redirect handling.
Impersonated tunnel backendRelay verifies signed environment responses, nonce, and request binding.
Stolen access token replayRelay and environment tokens require DPoP proofs and replay guards.
Connector token exposure - Relay client is spawned without a shell and receives the token in - TUNNEL_TOKEN. -
-
- -
-

Credential Ownership

-
    -
  • Environment Ed25519 private key stays local.
  • -
  • - Cloud-mint private key stays in hosted relay config; only its public key is installed - locally. -
  • -
  • - Relay environment bearer is hashed in Postgres and stored plaintext only locally. -
  • -
  • Remote DPoP private keys and environment access tokens remain client-side.
  • -
  • WebSocket tickets are short-lived and one-time.
  • -
-
-
-

Operational Profile

-
    -
  • One named tunnel and DNS record per active user/environment allocation.
  • -
  • HTTPS request/response and WSS only; no arbitrary TCP exposure.
  • -
  • Relay traffic is limited to health and bootstrap control calls.
  • -
  • - Active sessions are interactive and WebSocket-heavy; inactive links are nearly idle. -
  • -
  • - Dedicated registrable tunnel domain and Public Suffix List entry are required before - broad untrusted use. -
  • -
-
- -
-

Deployment and Runtime

- - - - - - - - - - - - - - - - - - - - - -
LayerContract
Relay - Cloudflare Worker, tunnel/DNS bindings, Hyperdrive, PlanetScale Postgres, APNs - queue + DLQ, replay-row pruning cron, and Axiom OTLP traces. -
Environment - Loopback server, atomic secret store, relay-client lifecycle, signed health/mint - responses, OAuth exchange, and WebSocket tickets. -
Clients - Secure relay URL, Clerk configuration, DPoP key management, environment token - storage, and direct managed-endpoint traffic. -
-
- -
-

Release Gate

-
-
-

Identity

- Keep private signing keys in their owning trust domain. Validate issuer, audience, - scope, expiry, nonce, and proof-key binding. -
-
-

Network

- Permit loopback tunnel origins only. Resolve relay egress from ready allocations and - disable redirects. -
-
-

Lifecycle

- Make provisioning idempotent, preserve failed-cleanup checkpoints, and revoke shared - credentials only after the final link disappears. -
-
-
- Standards basis: RFC 8252 for native-app OAuth, RFC 8693 for token exchange, and RFC - 9449 for DPoP proof of possession. -
-
-
- -
- - diff --git a/docs/cloud/t3-code-connect-auth-flow.pdf b/docs/cloud/t3-code-connect-auth-flow.pdf deleted file mode 100644 index 42c604f29ce1564cdee6b1bdef626e0517d6bf44..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 113502 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