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; + 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 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; + 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. diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 339f7963702..bba35c8de8b 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -20,13 +20,16 @@ "@t3tools/tailscale": "workspace:*", "effect": "catalog:", "electron": "41.5.0", - "electron-updater": "^6.6.2" + "electron-updater": "^6.6.2", + "playwright-core": "1.60.0", + "react-grab": "^0.1.32" }, "devDependencies": { "@effect/vitest": "catalog:", "@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..c45f81268a6 --- /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", "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")); + +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/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/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/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 40f84054878..a6c8428efa9 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -48,9 +48,11 @@ import { setTheme, showContextMenu, } from "./methods/window.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; + yield* PreviewIpc.installPreviewEventForwarding(); yield* ipc.handleSync(getAppBranding); yield* ipc.handleSync(getLocalEnvironmentBootstrap); @@ -92,4 +94,7 @@ export const installDesktopIpcHandlers = Effect.gen(function* () { yield* ipc.handle(downloadUpdate); yield* ipc.handle(installUpdate); yield* ipc.handle(checkForUpdate); -}).pipe(Effect.withSpan("desktop.ipc.installHandlers")); + for (const previewMethod of PreviewIpc.methods) { + yield* ipc.handle(previewMethod); + } +}); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 1ded238c663..c5dabe0930f 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -39,3 +39,38 @@ export const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mod export const SET_TAILSCALE_SERVE_ENABLED_CHANNEL = "desktop:set-tailscale-serve-enabled"; export const GET_ADVERTISED_ENDPOINTS_CHANNEL = "desktop:get-advertised-endpoints"; export const SSH_PASSWORD_PROMPT_CANCELLED_RESULT = "ssh-password-prompt-cancelled"; +export const PREVIEW_CREATE_TAB_CHANNEL = "desktop:preview-create-tab"; +export const PREVIEW_CLOSE_TAB_CHANNEL = "desktop:preview-close-tab"; +export const PREVIEW_REGISTER_WEBVIEW_CHANNEL = "desktop:preview-register-webview"; +export const PREVIEW_NAVIGATE_CHANNEL = "desktop:preview-navigate"; +export const PREVIEW_GO_BACK_CHANNEL = "desktop:preview-go-back"; +export const PREVIEW_GO_FORWARD_CHANNEL = "desktop:preview-go-forward"; +export const PREVIEW_REFRESH_CHANNEL = "desktop:preview-refresh"; +export const PREVIEW_ZOOM_IN_CHANNEL = "desktop:preview-zoom-in"; +export const PREVIEW_ZOOM_OUT_CHANNEL = "desktop:preview-zoom-out"; +export const PREVIEW_RESET_ZOOM_CHANNEL = "desktop:preview-reset-zoom"; +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_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"; +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_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"; +export const PREVIEW_POINTER_EVENT_CHANNEL = "desktop:preview-pointer-event"; 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..92336cc7362 --- /dev/null +++ b/apps/desktop/src/ipc/methods/preview.test.ts @@ -0,0 +1,54 @@ +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"; + +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: { + 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(); + }); + + 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 new file mode 100644 index 00000000000..8adae374ad0 --- /dev/null +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -0,0 +1,377 @@ +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 * as PreviewManager from "../../preview/Manager.ts"; +import { PREVIEW_WEBVIEW_PREFERENCES } from "../../preview/WebviewPreferences.ts"; +import * as IpcChannels from "../channels.ts"; +import { makeIpcMethod } from "../DesktopIpc.ts"; + +const broadcast = (channel: string, ...args: ReadonlyArray): void => { + for (const window of BrowserWindow.getAllWindows()) { + if (!window.isDestroyed()) { + window.webContents.send(channel, ...args); + } + } +}; + +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); + }); +}); + +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); + }), +}); + +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); + }), +}); + +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); + }), +}); + +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 tabMethod = ( + channel: string, + name: string, + invoke: ( + manager: PreviewManager.PreviewManagerShape, + tabId: string, + ) => Effect.Effect, +) => + 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 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: yield* manager.getBrowserPartition(environmentId), + webPreferences: PREVIEW_WEBVIEW_PREFERENCES, + preloadUrl: pathToFileURL(`${__dirname}/preview-pick-preload.cjs`).href, + }; + }), +}); + +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); + }), +}); + +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/preload.ts b/apps/desktop/src/preload.ts index 84f7580cb07..ce12f19bf72 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -1,4 +1,9 @@ -import type { DesktopBridge } from "@t3tools/contracts"; +import type { + DesktopBridge, + DesktopPreviewPointerEvent, + DesktopPreviewRecordingFrame, + DesktopPreviewTabState, +} from "@t3tools/contracts"; import { contextBridge, ipcRenderer } from "electron"; import * as IpcChannels from "./ipc/channels.ts"; @@ -141,4 +146,97 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.removeListener(IpcChannels.UPDATE_STATE_CHANNEL, wrappedListener); }; }, + preview: { + createTab: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_CREATE_TAB_CHANNEL, { tabId }), + closeTab: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_CLOSE_TAB_CHANNEL, { tabId }), + registerWebview: (tabId, webContentsId) => + ipcRenderer.invoke(IpcChannels.PREVIEW_REGISTER_WEBVIEW_CHANNEL, { tabId, webContentsId }), + navigate: (tabId, url) => + ipcRenderer.invoke(IpcChannels.PREVIEW_NAVIGATE_CHANNEL, { tabId, url }), + goBack: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_GO_BACK_CHANNEL, { tabId }), + goForward: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_GO_FORWARD_CHANNEL, { tabId }), + refresh: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_REFRESH_CHANNEL, { tabId }), + zoomIn: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_ZOOM_IN_CHANNEL, { tabId }), + zoomOut: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_ZOOM_OUT_CHANNEL, { tabId }), + resetZoom: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_RESET_ZOOM_CHANNEL, { tabId }), + hardReload: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_HARD_RELOAD_CHANNEL, { tabId }), + openDevTools: (tabId) => + 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: (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 }), + 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 }), + 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, + tabId: unknown, + state: unknown, + ) => { + if (typeof tabId !== "string" || typeof state !== "object" || state === null) return; + listener(tabId, state as DesktopPreviewTabState); + }; + ipcRenderer.on(IpcChannels.PREVIEW_STATE_CHANGE_CHANNEL, wrappedListener); + 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-pick-preload.ts b/apps/desktop/src/preview-pick-preload.ts new file mode 100644 index 00000000000..84e6abb29ee --- /dev/null +++ b/apps/desktop/src/preview-pick-preload.ts @@ -0,0 +1 @@ +import "./preview/PickPreload.ts"; 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/AnnotationStyles.generated.ts b/apps/desktop/src/preview/AnnotationStyles.generated.ts new file mode 100644 index 00000000000..5b6b73c8ba7 --- /dev/null +++ b/apps/desktop/src/preview/AnnotationStyles.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: "";\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: "";\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: "";\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: "";\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/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; + readonly clearStorageData: ReturnType; + readonly getUserAgent: ReturnType; + readonly setPermissionRequestHandler: ReturnType; + readonly setUserAgent: ReturnType; + } + >(), +})); + +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 new file mode 100644 index 00000000000..ead28c12f9b --- /dev/null +++ b/apps/desktop/src/preview/BrowserSession.ts @@ -0,0 +1,107 @@ +import type { Session } from "electron"; +import { session } from "electron"; +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-"; + +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) => Effect.Effect; + readonly isPartition: (partition: string) => boolean; + readonly getSession: (scope?: string) => Effect.Effect; + readonly clearCookies: () => Effect.Effect; + readonly clearCache: () => Effect.Effect; +} + +export class BrowserSession extends Context.Service()( + "@t3tools/desktop/preview/BrowserSession", +) {} + +const make = Effect.gen(function* BrowserSessionMake() { + const crypto = yield* Crypto.Crypto; + const sessionsRef = yield* SynchronizedRef.make>(new Map()); + + 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 Effect.succeed([existing, sessions] as const); + return 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)); + }); + 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* () { + 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); 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/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts new file mode 100644 index 00000000000..ac32d74ec69 --- /dev/null +++ b/apps/desktop/src/preview/Manager.test.ts @@ -0,0 +1,374 @@ +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"; + +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as BrowserSession from "./BrowserSession.ts"; +import * as PreviewManager from "./Manager.ts"; + +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: { + writeImage, + }, + nativeImage: { + createFromPath, + }, + shell: { + showItemInFolder, + }, + session: { + fromPartition: vi.fn(), + }, + webContents: { + fromId, + }, +})); + +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 = ( + use: ( + manager: PreviewManager.PreviewManagerShape, + ) => Effect.Effect, +) => + 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(); + writeFile.mockClear(); + showItemInFolder.mockClear(); + writeImage.mockClear(); + createFromPath.mockClear(); + webviewSend.mockClear(); + }); + + 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, + }); + + yield* manager.createTab("tab_1"); + + expect(yield* manager.automationStatus("tab_1")).toEqual({ + available: false, + visible: true, + tabId: "tab_1", + url: null, + title: null, + loading: false, + }); + expect(fromId).not.toHaveBeenCalled(); + }), + ), + ); + + 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 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); + + yield* manager.createTab("tab_1"); + yield* manager.registerWebview("tab_1", 42); + + expect(webviewSend).toHaveBeenCalledWith( + "preview:annotation-theme", + expect.objectContaining({ + colorScheme: "light", + primary: "oklch(0.488 0.217 264)", + }), + ); + + const artifact = yield* manager.captureScreenshot("tab_1"); + + 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$/, + ); + }), + ), + ); + + 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"); + + 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.", + }); + }), + ), + ); + + 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"; + + 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.", + }); + }), + ), + ); + + 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) => { + 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); + + 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, + }); + }), + ), + ); + + 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(), + }, + } as never); + + 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 new file mode 100644 index 00000000000..f82741d908f --- /dev/null +++ b/apps/desktop/src/preview/Manager.ts @@ -0,0 +1,2301 @@ +/** + * Desktop side of the in-app browser preview. + * + * Hosts per-tab Chromium WebContents references (the actual + * elements live in the renderer; we only attach listeners and forward state + * here). Single layer-scoped browser session partition. + */ +import type { + DesktopPreviewAnnotationTheme, + DesktopPreviewPointerEvent, + PreviewAnnotationPayload, + PreviewAnnotationRect, + DesktopPreviewRecordingArtifact, + DesktopPreviewRecordingFrame, + DesktopPreviewScreenshotArtifact, + PreviewAutomationClickInput, + PreviewAutomationActionEvent, + PreviewAutomationConsoleEntry, + PreviewAutomationEvaluateInput, + PreviewAutomationPressInput, + PreviewAutomationNetworkEntry, + PreviewAutomationScrollInput, + PreviewAutomationSnapshot, + PreviewAutomationStatus, + PreviewAutomationTypeInput, + PreviewAutomationWaitForInput, +} from "@t3tools/contracts"; +import { normalizePreviewUrl } from "@t3tools/shared/preview"; +import { + type BrowserWindow, + type Session, + clipboard, + nativeImage, + shell, + webContents, +} from "electron"; +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 * as Scope from "effect/Scope"; +import * as SynchronizedRef from "effect/SynchronizedRef"; + +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" } + | { kind: "Loading"; url: string; title: string } + | { kind: "Success"; url: string; title: string } + | { + kind: "LoadFailed"; + url: string; + title: string; + code: number; + description: string; + }; + +export interface PreviewTabState { + tabId: string; + webContentsId: number | null; + navStatus: PreviewNavStatus; + canGoBack: boolean; + canGoForward: boolean; + zoomFactor: number; + controller: "human" | "agent" | "none"; + updatedAt: string; +} + +/** Discrete zoom levels mirroring Chrome's preset list. */ +const ZOOM_LEVELS: ReadonlyArray = [ + 0.25, 0.33, 0.5, 0.67, 0.75, 0.8, 0.9, 1.0, 1.1, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0, 4.0, 5.0, +]; + +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; +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", + 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?: { + readonly value?: unknown; + readonly description?: string; + }; + readonly exceptionDetails?: { + readonly text?: string; + readonly exception?: { readonly description?: string }; + }; +} + +const automationError = ( + tag: + | "PreviewAutomationExecutionError" + | "PreviewAutomationInvalidSelectorError" + | "PreviewAutomationResultTooLargeError" + | "PreviewAutomationTimeoutError" + | "PreviewAutomationControlInterruptedError", + 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; + const rect = value as Record; + 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 = ( + wc: Electron.WebContents, + cropRect: PreviewAnnotationRect | null, +): Effect.Effect => + 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 findZoomStep = (current: number): number => { + 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 => { + const step = findZoomStep(current); + if (direction === "in") { + return ZOOM_LEVELS[Math.min(step + 1, ZOOM_LEVELS.length - 1)] ?? current; + } + return ZOOM_LEVELS[Math.max(step - 1, 0)] ?? current; +}; + +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 { + readonly scope: Scope.Closeable; +} + +interface PickSession { + readonly cancel: Effect.Effect; +} + +interface BrowserControlSession { + readonly webContentsId: number; + readonly semaphore: Semaphore.Semaphore; + readonly scope: Scope.Closeable; + readonly onMessage: ( + event: Electron.Event, + method: string, + params: Record, + ) => void; +} + +interface BrowserDiagnostics { + readonly consoleEntries: ReadonlyArray; + readonly networkEntries: ReadonlyArray; + readonly requests: ReadonlyMap; +} + +type PointerEventListener = (event: DesktopPreviewPointerEvent) => void; + +interface ExpectedAgentInput { + readonly signal: PreviewInputSignal; + readonly expiresAt: number; +} + +const APP_FORWARDED_SHORTCUTS: ReadonlyArray<{ + key: string; + meta: boolean; + shift: boolean; + control: boolean; +}> = Object.freeze([ + // mod+shift+J → preview.toggle + { key: "j", meta: true, shift: true, control: false }, + // mod+K → command palette + { key: "k", meta: true, shift: false, control: false }, + // mod+, → settings (macOS convention) + { key: ",", meta: true, shift: false, control: false }, + // mod+W → close tab/panel + { 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 + ); +}; + +const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function* ( + artifactDirectory: string, +) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const parentScope = yield* Scope.Scope; + const context = yield* Effect.context(); + 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.none()); + const tabsRef = yield* SynchronizedRef.make>(new Map()); + const attachedRef = yield* Ref.make>(new Map()); + const listenersRef = yield* Ref.make>(new Set()); + const pointerEventListenersRef = yield* Ref.make>(new Set()); + const recordingFrameListenersRef = yield* Ref.make>( + new Set(), + ); + const pickSessionsRef = yield* Ref.make>(new Map()); + const controlSessionsRef = yield* SynchronizedRef.make< + ReadonlyMap + >(new Map()); + const diagnosticsRef = yield* Ref.make>(new Map()); + const expectedAgentInputsRef = yield* Ref.make< + ReadonlyMap> + >(new Map()); + const controlEpochRef = yield* Ref.make>(new Map()); + const actionTimelineRef = yield* Ref.make< + ReadonlyMap> + >(new Map()); + const actionSequenceRef = yield* Ref.make(0); + const pointerSequenceRef = yield* Ref.make(0); + const recordingTabIdRef = yield* Ref.make>(Option.none()); + + const fail = (operation: string, cause: unknown): PreviewManagerError => + new PreviewManagerError({ operation, cause }); + const attempt = (operation: string, evaluate: () => A) => + Effect.try({ try: evaluate, catch: (cause) => fail(operation, cause) }); + const attemptPromise = (operation: string, evaluate: () => PromiseLike) => + 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) => + Ref.modify(ref, (value) => [value, value + 1] as const); + const replaceMap = ( + source: ReadonlyMap, + update: (copy: Map) => void, + ): ReadonlyMap => { + const copy = new Map(source); + update(copy); + return copy; + }; + + 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, + ) { + const updatedAt = yield* currentIso; + const next = yield* SynchronizedRef.modify(tabsRef, (tabs) => { + const current = tabs.get(tabId); + if (!current) return [Option.none(), 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)); + } + const wc = webContents.fromId(tab.webContentsId); + if (!wc) { + return yield* fail( + "requireWebContents", + new PreviewWebContentsNotFoundError(tabId, tab.webContentsId), + ); + } + return wc; + }); + + 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 = (buffer: ReadonlyArray, entry: A): ReadonlyArray => + [...buffer, entry].slice(-DIAGNOSTIC_BUFFER_LIMIT); + + const captureDiagnosticMessage = Effect.fn("PreviewManager.captureDiagnosticMessage")(function* ( + webContentsId: number, + method: string, + params: Record, + ) { + 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; + 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) + : {}; + 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) + : {}; + 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) + : {}; + 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) + : {}; + 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); + }), + ]); + 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 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.", + ), + ), + ); + } + 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, + ) { + 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) + : {}; + 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(handleDebuggerMessage(method, params)); + }; + yield* Scope.addFinalizer( + scope, + Effect.all( + [ + Ref.update(diagnosticsRef, (diagnostics) => + replaceMap(diagnostics, (copy) => { + copy.delete(wc.id); + }), + ), + attempt("detachControlSession", () => { + wc.debugger.off("message", onMessage); + if (wc.debugger.isAttached()) wc.debugger.detach(); + }).pipe(Effect.ignore), + ], + { discard: true }, + ), + ); + 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(); + }); + }); + + 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, + ) => Effect.Effect; + + const withControlSession = Effect.fn("PreviewManager.withControlSession")(function* ( + tabId: string, + wc: Electron.WebContents, + action: string, + use: (send: SendCommand) => Effect.Effect, + ) { + 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, + ) { + 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))); + }); + + const evaluateWithDebugger = ( + send: SendCommand, + expression: string, + returnByValue: boolean, + awaitPromise = true, + ): Effect.Effect => + 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); + }), + ); + + const automationLocator = (input: { + readonly selector?: string | undefined; + readonly locator?: string | undefined; + }): string | null => input.locator ?? (input.selector ? `css=${input.selector}` : null); + + const ensurePlaywrightInjected = Effect.fn("PreviewManager.ensurePlaywrightInjected")(function* ( + send: SendCommand, + ) { + const installed = yield* evaluateWithDebugger( + send, + "Boolean(globalThis.__t3PlaywrightInjected)", + true, + ); + if (installed) return; + const expression = yield* playwrightInstallExpression; + yield* evaluateWithDebugger(send, expression, true); + }); + + const cancelPickElement = Effect.fn("PreviewManager.cancelPickElement")(function* ( + tabId: string, + ) { + const session = (yield* Ref.get(pickSessionsRef)).get(tabId); + if (session) yield* session.cancel; + }); + + const detachListeners = Effect.fn("PreviewManager.detachListeners")(function* ( + webContentsId: number, + ) { + const managed = yield* Ref.modify(attachedRef, (attached) => [ + attached.get(webContentsId), + replaceMap(attached, (copy) => { + copy.delete(webContentsId); + }), + ]); + if (managed) yield* Scope.close(managed.scope, Exit.void).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 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( + update(tabId, { + navStatus: { + kind: "LoadFailed", + url: wc.getURL(), + title: wc.getTitle(), + code, + description, + }, + }), + ); + }; + 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(forwardShortcut(event, input)); + }; + 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); + }); + yield* Ref.update(attachedRef, (attached) => + replaceMap(attached, (copy) => { + copy.set(wc.id, { scope }); + }), + ); + }); + yield* install().pipe(Effect.onError(() => Scope.close(scope, Exit.void).pipe(Effect.ignore))); + }); + + 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; + yield* cancelPickElement(tabId); + if (tab.webContentsId != null) { + yield* Effect.all( + [detachControlSession(tab.webContentsId), detachListeners(tab.webContentsId)], + { concurrency: 2, discard: true }, + ); + } + const updatedAt = yield* currentIso; + const closed: PreviewTabState = { + ...tab, + webContentsId: null, + navStatus: { kind: "Idle" }, + canGoBack: false, + canGoForward: false, + zoomFactor: DEFAULT_ZOOM_FACTOR, + controller: "none", + updatedAt, + }; + yield* SynchronizedRef.update(tabsRef, (tabs) => + replaceMap(tabs, (copy) => { + copy.delete(tabId); + }), + ); + yield* emit(tabId, closed); + }); + + const registerWebview = Effect.fn("PreviewManager.registerWebview")(function* ( + tabId: string, + webContentsId: number, + ) { + const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); + if (!tab) { + return yield* fail("registerWebview", new PreviewTabNotFoundError(tabId)); + } + const wc = webContents.fromId(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), + ); + } + 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) { + yield* Effect.all( + [ + detachControlSession(tab.webContentsId), + detachListeners(tab.webContentsId), + cancelPickElement(tabId), + ], + { concurrency: 3, discard: true }, + ); + } + yield* attachListeners(tabId, wc); + runFork(ensureControlSession(wc).pipe(Effect.ignore)); + if (Math.abs(tab.zoomFactor - DEFAULT_ZOOM_FACTOR) > ZOOM_EPSILON) { + yield* attempt("registerWebview.restoreZoom", () => wc.setZoomFactor(tab.zoomFactor)).pipe( + Effect.ignore, + ); + } + yield* update(tabId, { + webContentsId, + navStatus: computeNavStatus(wc), + canGoBack: wc.navigationHistory.canGoBack(), + canGoForward: wc.navigationHistory.canGoForward(), + zoomFactor: tab.zoomFactor, + }); + yield* attempt("registerWebview.sendTheme", () => + wc.send(ANNOTATION_THEME_CHANNEL, annotationTheme), + ); + }); + + 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) { + yield* attempt("navigate.reload", () => wc.reload()); + return; + } + yield* attemptPromise("navigate.loadURL", () => wc.loadURL(url)); + }); + + 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)); + }); + + 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()); + + const openDevTools = Effect.fn("PreviewManager.openDevTools")(function* (tabId: string) { + const wc = yield* requireWebContents(tabId); + if (wc.isDevToolsOpened()) { + yield* attempt("openDevTools.focus", () => wc.devToolsWebContents?.focus()); + return; + } + yield* detachControlSession(wc.id); + yield* attempt("openDevTools", () => { + wc.once("devtools-closed", () => { + if (!wc.isDestroyed()) runFork(ensureControlSession(wc).pipe(Effect.ignore)); + }); + wc.openDevTools({ mode: "detach" }); + }); + }); + + 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( + (resume) => { + const cleanup = Effect.fn("PreviewManager.cleanupPickElement")(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 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(settlePick(payload)); + }; + const cancelPickSession = Effect.fn("PreviewManager.cancelPickSession")(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, + ); + } + } + resume(Effect.succeed(null)); + }); + const cancel = cancelPickSession(); + 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); + 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( + registerPickElement().pipe( + Effect.catch((error: PreviewManagerError) => { + resume(Effect.fail(error)); + return cleanup(); + }), + ), + ); + return cancel; + }, + ); + }); + + 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()) { + yield* attempt("applyZoom", () => wc.setZoomFactor(next)); + } + } + yield* update(tabId, { zoomFactor: next }); + }); + + 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, + }; + }); + + 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) { + 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", startScreencast); + yield* Ref.set(recordingTabIdRef, Option.some(tabId)); + }); + + 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()); + }); + + const saveRecording = Effect.fn("PreviewManager.saveRecording")(function* ( + tabId: string, + mimeType: string, + data: Uint8Array, + ) { + const [createdAt, millis] = yield* Effect.all([currentIso, currentMillis]); + const id = `browser-recording-${millis.toString(36)}`; + const extension = mimeType.includes("mp4") ? "mp4" : "webm"; + 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, + }; + }); + + 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 { + 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); + 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(), + }; + }); + + 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) => + captureAutomationSnapshot(tabId, wc, send), + ); + }); + + 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, + ); + if ("invalidSelector" in point) { + return yield* fail( + "automationClick", + automationError("PreviewAutomationInvalidSelectorError", point.message, { + selector: locator, + }), + ); + } + if ("notFound" in point) { + return yield* fail( + "automationClick", + automationError( + "PreviewAutomationExecutionError", + `No element matches locator ${locator}.`, + ), + ); + } + return point; + }); + + 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 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) => + performAutomationClick(tabId, input, 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 = ${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}) { + 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 result) { + return yield* fail( + "automationType", + automationError("PreviewAutomationInvalidSelectorError", result.message, { + selector: input.selector ?? "", + }), + ); + } + if ("notFound" in result) { + return yield* fail( + "automationType", + automationError( + "PreviewAutomationExecutionError", + locator + ? `No element matches locator ${locator}.` + : "No element is focused in the preview.", + ), + ); + } + }); + + 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) => + 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) => + 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) => + 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) => + performAutomationEvaluate(input, send), + ); + }); + + const performAutomationWaitFor = Effect.fn("PreviewManager.performAutomationWaitFor")(function* ( + input: PreviewAutomationWaitForInput, + send: SendCommand, + ) { + const timeoutMs = input.timeoutMs ?? 15_000; + 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.`, + ), + ); + }); + + 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), + ); + }); + + const revealArtifact = Effect.fn("PreviewManager.revealArtifact")(function* ( + artifactPath: string, + ) { + const resolvedPath = yield* resolveArtifactPath(artifactPath); + yield* attempt("revealArtifact", () => shell.showItemInFolder(resolvedPath)); + }); + + 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."), + ); + } + yield* attempt("copyArtifactToClipboard.write", () => clipboard.writeImage(image)); + }); + + const subscribe = ( + ref: Ref.Ref>, + listener: A, + ): Effect.Effect => + 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 }, + ); + }); + + yield* Effect.addFinalizer(() => destroy().pipe(Effect.ignore)); + + 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; + constructor(tabId: string) { + super(`Preview tab not found: ${tabId}`); + this.name = "PreviewTabNotFoundError"; + this.tabId = tabId; + } +} + +export class PreviewWebContentsNotFoundError extends Error { + readonly tabId: string; + readonly webContentsId: number; + constructor(tabId: string, webContentsId: number) { + super(`WebContents ${webContentsId} not found for preview tab ${tabId}`); + this.name = "PreviewWebContentsNotFoundError"; + this.tabId = tabId; + this.webContentsId = webContentsId; + } +} + +export class PreviewWebviewNotInitializedError extends Error { + readonly tabId: string; + constructor(tabId: string) { + super(`Preview tab "${tabId}" has no webview registered`); + this.name = "PreviewWebviewNotInitializedError"; + this.tabId = tabId; + } +} + +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; + readonly getBrowserSession: (scope?: string) => Effect.Effect; + readonly isBrowserPartition: (partition: string) => boolean; + readonly createTab: (tabId: string) => Effect.Effect; + readonly closeTab: (tabId: string) => Effect.Effect; + readonly registerWebview: ( + tabId: string, + webContentsId: number, + ) => Effect.Effect; + readonly navigate: (tabId: string, url: string) => Effect.Effect; + readonly goBack: (tabId: string) => Effect.Effect; + readonly goForward: (tabId: string) => Effect.Effect; + readonly refresh: (tabId: string) => Effect.Effect; + readonly zoomIn: (tabId: string) => Effect.Effect; + readonly zoomOut: (tabId: string) => Effect.Effect; + readonly resetZoom: (tabId: string) => Effect.Effect; + readonly hardReload: (tabId: string) => Effect.Effect; + readonly openDevTools: (tabId: string) => Effect.Effect; + readonly clearCookies: () => Effect.Effect; + readonly clearCache: () => Effect.Effect; + readonly getBrowserPartition: (scope?: string) => Effect.Effect; + readonly setAnnotationTheme: ( + theme: DesktopPreviewAnnotationTheme, + ) => Effect.Effect; + readonly pickElement: ( + tabId: string, + ) => Effect.Effect; + readonly cancelPickElement: (tabId: string) => Effect.Effect; + readonly captureScreenshot: ( + tabId: string, + ) => Effect.Effect; + readonly revealArtifact: (path: string) => Effect.Effect; + readonly copyArtifactToClipboard: (path: string) => Effect.Effect; + readonly startRecording: (tabId: string) => Effect.Effect; + readonly stopRecording: (tabId: string) => Effect.Effect; + readonly saveRecording: ( + tabId: string, + mimeType: string, + data: Uint8Array, + ) => Effect.Effect; + readonly automationStatus: ( + tabId: string, + ) => Effect.Effect; + readonly automationSnapshot: ( + tabId: string, + ) => Effect.Effect; + readonly automationClick: ( + tabId: string, + input: PreviewAutomationClickInput, + ) => Effect.Effect; + readonly automationType: ( + tabId: string, + input: PreviewAutomationTypeInput, + ) => Effect.Effect; + readonly automationPress: ( + tabId: string, + input: PreviewAutomationPressInput, + ) => Effect.Effect; + readonly automationScroll: ( + tabId: string, + input: PreviewAutomationScrollInput, + ) => Effect.Effect; + readonly automationEvaluate: ( + tabId: string, + input: PreviewAutomationEvaluateInput, + ) => Effect.Effect; + readonly automationWaitFor: ( + tabId: string, + input: PreviewAutomationWaitForInput, + ) => Effect.Effect; + readonly subscribeStateChanges: (listener: Listener) => Effect.Effect; + readonly subscribePointerEvents: ( + listener: PointerEventListener, + ) => Effect.Effect; + readonly subscribeRecordingFrames: ( + listener: RecordingFrameListener, + ) => Effect.Effect; +} + +export class PreviewManager extends Context.Service()( + "@t3tools/desktop/preview/Manager/PreviewManager", +) {} + +const make = Effect.gen(function* PreviewManagerMake() { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const browserSession = yield* BrowserSession.BrowserSession; + const operations = yield* makeNativeOperations(environment.browserArtifactsDir); + const browserSessionEffect = ( + operation: string, + effect: Effect.Effect, + ): Effect.Effect => + effect.pipe(Effect.mapError((cause) => new PreviewManagerError({ operation, cause }))); + + return PreviewManager.of({ + setMainWindow: operations.setMainWindow, + getBrowserSession: Effect.fn("PreviewManager.getBrowserSession")(function* (scope) { + return yield* browserSessionEffect("getBrowserSession", browserSession.getSession(scope)); + }), + isBrowserPartition: browserSession.isPartition, + 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: Effect.fn("PreviewManager.getBrowserPartition")(function* (scope) { + return yield* browserSessionEffect("getBrowserPartition", browserSession.getPartition(scope)); + }), + 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); diff --git a/apps/desktop/src/preview/PickLabelPosition.ts b/apps/desktop/src/preview/PickLabelPosition.ts new file mode 100644 index 00000000000..cf7f3c811f8 --- /dev/null +++ b/apps/desktop/src/preview/PickLabelPosition.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 (`PickPreload.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/PickPreload.test.ts b/apps/desktop/src/preview/PickPreload.test.ts new file mode 100644 index 00000000000..5696fe50812 --- /dev/null +++ b/apps/desktop/src/preview/PickPreload.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { computeLabelPosition } from "./PickLabelPosition.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/PickPreload.ts b/apps/desktop/src/preview/PickPreload.ts new file mode 100644 index 00000000000..2654b898102 --- /dev/null +++ b/apps/desktop/src/preview/PickPreload.ts @@ -0,0 +1,1263 @@ +// @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 { + 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; +} + +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, + 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 { + 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): 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, + 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 = + ''; + 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(); + const regions: PreviewAnnotationRegionTarget[] = []; + const strokes: PreviewAnnotationStrokeTarget[] = []; + const styleChanges = new Map(); + const toolButtons = new Map(); + 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 = "WidthHeight"; + 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 + ? '' + : ''; + 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/preview/PickedElementPayload.test.ts b/apps/desktop/src/preview/PickedElementPayload.test.ts new file mode 100644 index 00000000000..d7a96732477 --- /dev/null +++ b/apps/desktop/src/preview/PickedElementPayload.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { isPickedElementPayload, isPreviewAnnotationPayload } from "./PickedElementPayload.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); + }); +}); + +function validAnnotation(overrides?: Record): Record { + 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/preview/PickedElementPayload.ts b/apps/desktop/src/preview/PickedElementPayload.ts new file mode 100644 index 00000000000..e2d596120db --- /dev/null +++ b/apps/desktop/src/preview/PickedElementPayload.ts @@ -0,0 +1,146 @@ +/** + * Strict structural validator for `PickedElementPayload` messages received + * 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. + * + * 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, PreviewAnnotationPayload } 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; +} + +function isRect(value: unknown): boolean { + if (typeof value !== "object" || value === null) return false; + const rect = value as Record; + 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; + 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; + 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; + 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; + 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; + 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; + 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/PlaywrightInjectedRuntime.test.ts b/apps/desktop/src/preview/PlaywrightInjectedRuntime.test.ts new file mode 100644 index 00000000000..33915dba0be --- /dev/null +++ b/apps/desktop/src/preview/PlaywrightInjectedRuntime.test.ts @@ -0,0 +1,26 @@ +import { it as effectIt } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { describe, expect } from "vite-plus/test"; + +import { + playwrightInjectedRuntimeInstallExpression, + playwrightInjectedRuntimeSource, +} from "./PlaywrightInjectedRuntime.ts"; + +describe("playwright injected runtime", () => { + 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"); + }), + ); + + 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 new file mode 100644 index 00000000000..1a4dce14f87 --- /dev/null +++ b/apps/desktop/src/preview/PlaywrightInjectedRuntime.ts @@ -0,0 +1,90 @@ +// @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 { 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); +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}`; + } +} + +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) { + 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) { + return yield* fail( + "findSourceTerminator", + new Error("Playwright injected runtime terminator was not found."), + ); + } + const literal = coreBundle.slice(literalStart, literalEnd); + 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) { + return yield* fail( + "validateSource", + new Error("Playwright injected runtime extraction returned invalid source."), + ); + } + return source; + }, +); + +export const playwrightInjectedRuntimeInstallExpression = Effect.fn( + "PlaywrightInjectedRuntime.installExpression", +)(function* () { + const source = yield* playwrightInjectedRuntimeSource(); + const options = yield* encodeUnknownJson({ + isUnderTest: false, + sdkLanguage: "javascript", + testIdAttributeName: "data-testid", + stableRafCount: 1, + browserName: "chromium", + 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, ${options}); + return true; + })()`; +}); diff --git a/apps/desktop/src/preview/WebviewPreferences.test.ts b/apps/desktop/src/preview/WebviewPreferences.test.ts new file mode 100644 index 00000000000..498c1df4665 --- /dev/null +++ b/apps/desktop/src/preview/WebviewPreferences.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { PREVIEW_WEBVIEW_PREFERENCES } from "./WebviewPreferences.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/WebviewPreferences.ts b/apps/desktop/src/preview/WebviewPreferences.ts new file mode 100644 index 00000000000..085c75232b3 --- /dev/null +++ b/apps/desktop/src/preview/WebviewPreferences.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 `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 `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. + * - 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.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 5e977de2dea..2d133517132 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()), + 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"; @@ -18,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", @@ -155,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: () => Effect.succeed("persist:t3code-preview-test"), + }), ), ), ); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 35145cc1d53..f24485fd879 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -11,6 +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 * 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"; @@ -36,7 +37,8 @@ type DesktopWindowRuntimeServices = | ElectronMenu.ElectronMenu | ElectronShell.ElectronShell | ElectronTheme.ElectronTheme - | ElectronWindow.ElectronWindow; + | ElectronWindow.ElectronWindow + | PreviewManager.PreviewManager; export class DesktopWindowDevServerUrlMissingError extends Data.TaggedError( "DesktopWindowDevServerUrlMissingError", @@ -48,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; @@ -162,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(); @@ -170,6 +174,7 @@ const make = Effect.gen(function* () { const createWindow = Effect.fn("desktop.window.createWindow")(function* ( backendHttpUrl: URL, ): Effect.fn.Return { + yield* previewManager.getBrowserSession(); const applicationUrl = environment.isDevelopment ? yield* resolveDesktopDevServerUrl(environment) : backendHttpUrl.href; @@ -192,9 +197,25 @@ const make = Effect.gen(function* () { contextIsolation: true, nodeIntegration: false, sandbox: true, + webviewTag: true, }, }); + yield* previewManager.setMainWindow(window); + window.webContents.on("will-attach-webview", (event, webPreferences, params) => { + if ( + typeof params.partition !== "string" || + !previewManager.isBrowserPartition(params.partition) + ) { + 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..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": { @@ -56,5 +57,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/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, ''); + 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, "

outside

"); + + 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, ""); + + 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 = ``; + +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 = ``; 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/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/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts new file mode 100644 index 00000000000..f60652609f5 --- /dev/null +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -0,0 +1,165 @@ +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 * 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"); +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 = McpHttpServer.PreviewToolkitRegistrationLive.pipe( + Layer.provideMerge(McpServer.McpServer.layer), + Layer.provideMerge(PreviewAutomationBroker.layer), +); + +it("normalizes empty successful notification responses to accepted", () => { + const notificationResponse = McpHttpServer.normalizeMcpHttpResponse( + HttpServerResponse.text("", { status: 200, contentType: "application/json" }), + ); + expect(notificationResponse.status).toBe(202); + + const resultResponse = McpHttpServer.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 broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; + const requests = yield* broker.connect("mcp-test-client"); + yield* Stream.runForEach(requests, (request) => + broker.respond({ + requestId: request.requestId, + ok: true, + result: + request.operation === "snapshot" + ? { + url: "http://example.test/", + title: "Example", + loading: false, + visibleText: "Example", + interactiveElements: [], + accessibilityTree: {}, + consoleEntries: [], + networkEntries: [], + actionTimeline: [], + screenshot: { + mimeType: "image/png", + data: Buffer.from("png").toString("base64"), + width: 10, + height: 5, + }, + } + : request.operation === "press" + ? undefined + : { + available: true, + visible: true, + tabId, + url: "http://example.test/", + title: "Example", + loading: false, + }, + }), + ).pipe(Effect.forkScoped); + yield* Effect.yieldNow; + yield* broker.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); + 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: {} }) + .pipe( + Effect.provideService(McpInvocationContext.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.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.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 }, + }); + + const press = yield* server + .callTool({ name: "preview_press", arguments: { key: "Enter" } }) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.provideService(McpSchema.McpServerClient, client), + ); + expect(press.isError).toBe(false); + 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 new file mode 100644 index 00000000000..6cde2017a9e --- /dev/null +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -0,0 +1,191 @@ +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 type * as Types from "effect/Types"; +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 * as McpInvocationContext from "./McpInvocationContext.ts"; +import * as McpSessionRegistry from "./McpSessionRegistry.ts"; +import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts"; +import { + PreviewSnapshotToolkitHandlersLive, + PreviewStandardToolkitHandlersLive, +} from "./toolkits/preview/handlers.ts"; +import { + PreviewSnapshotTool, + PreviewSnapshotToolkit, + PreviewStandardToolkit, +} 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", + }, + }, +); + +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 => { + 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 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; +}>()(makeMcpAuthMiddleware).layer; + +const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot")(function* () { + const server = yield* McpServer.McpServer; + 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: ({ encodedResult }) => { + const snapshot = 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 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({ + name: "T3 Code", + version: packageJson.version, + path: "/mcp", +}).pipe(Layer.provide(McpAuthMiddlewareLive)); + +export const layer = PreviewToolkitRegistrationLive.pipe( + Layer.provideMerge(McpTransportLive), + Layer.provide(PreviewAutomationBroker.layer), +); diff --git a/apps/server/src/mcp/McpInvocationContext.ts b/apps/server/src/mcp/McpInvocationContext.ts new file mode 100644 index 00000000000..0d3f84df42c --- /dev/null +++ b/apps/server/src/mcp/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; + readonly issuedAt: number; + readonly expiresAt: number; +} + +export class McpInvocationContext extends Context.Service< + McpInvocationContext, + McpInvocationScope +>()("t3/mcp/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/McpProviderSession.ts b/apps/server/src/mcp/McpProviderSession.ts new file mode 100644 index 00000000000..d5dc582046c --- /dev/null +++ b/apps/server/src/mcp/McpProviderSession.ts @@ -0,0 +1,28 @@ +import type { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; + +export interface McpProviderSessionConfig { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly providerSessionId: string; + readonly providerInstanceId: ProviderInstanceId; + readonly endpoint: string; + readonly authorizationHeader: string; +} + +const sessionsByThread = new Map(); + +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/McpSessionRegistry.test.ts b/apps/server/src/mcp/McpSessionRegistry.test.ts new file mode 100644 index 00000000000..d6540d567af --- /dev/null +++ b/apps/server/src/mcp/McpSessionRegistry.test.ts @@ -0,0 +1,68 @@ +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 * as McpSessionRegistry 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) => + 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* () { + 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/McpSessionRegistry.ts b/apps/server/src/mcp/McpSessionRegistry.ts new file mode 100644 index 00000000000..1ee7d278c62 --- /dev/null +++ b/apps/server/src/mcp/McpSessionRegistry.ts @@ -0,0 +1,207 @@ +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 SynchronizedRef from "effect/SynchronizedRef"; +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; + readonly resolve: ( + rawToken: string, + ) => Effect.Effect; + readonly revokeProviderSession: (providerSessionId: string) => Effect.Effect; + readonly revokeThread: (threadId: ThreadId) => Effect.Effect; + readonly revokeAll: Effect.Effect; +} + +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; +} + +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 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* SynchronizedRef.make({ 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 = + 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, timestamp: number) => { + 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 = 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); + 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* SynchronizedRef.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 = yield* currentTimeMillis; + return yield* SynchronizedRef.modify(state, ({ records }) => { + const current = pruneExpired(records, timestamp); + const record = current.get(tokenHash); + if (!record) return [undefined, { records: current }] as const; + const next = new Map(current); + next.set(tokenHash, { ...record, lastUsedAt: timestamp }); + return [record.scope, { records: next }] as const; + }); + }, + ); + + const revokeWhere = (predicate: (record: CredentialRecord) => boolean) => + SynchronizedRef.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: SynchronizedRef.set(state, { records: new Map() }), + }); +}); + +let activeMcpSessionRegistry: McpSessionRegistryShape | undefined; + +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 => + activeMcpSessionRegistry + ? activeMcpSessionRegistry + .revokeThread(request.threadId) + .pipe(Effect.andThen(activeMcpSessionRegistry.issue(request))) + : Effect.sync((): McpIssuedCredential | undefined => undefined); + +export const revokeActiveMcpThread = (threadId: ThreadId): Effect.Effect => + activeMcpSessionRegistry ? activeMcpSessionRegistry.revokeThread(threadId) : Effect.void; + +export const revokeAllActiveMcpCredentials = (): Effect.Effect => + activeMcpSessionRegistry ? activeMcpSessionRegistry.revokeAll : Effect.void; + +/** Exposed for tests. */ +export const __testing = { + make: makeWithOptions, +}; diff --git a/apps/server/src/mcp/PreviewAutomationBroker.test.ts b/apps/server/src/mcp/PreviewAutomationBroker.test.ts new file mode 100644 index 00000000000..353353aaef2 --- /dev/null +++ b/apps/server/src/mcp/PreviewAutomationBroker.test.ts @@ -0,0 +1,89 @@ +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 * as PreviewAutomationBroker 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 = yield* PreviewAutomationBroker.__testing.make; + 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 = yield* PreviewAutomationBroker.__testing.make; + const error = yield* broker + .invoke({ scope, operation: "status", input: {} }) + .pipe(Effect.flip); + expect(error).toBeInstanceOf(PreviewAutomationNoFocusedOwnerError); + }), +); + +it.effect("routes interactive commands to a hidden durable browser host", () => + Effect.scoped( + Effect.gen(function* () { + 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 }), + ).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({ scope, operation: "click", input: { x: 10, y: 10 } }); + }), + ), +); diff --git a/apps/server/src/mcp/PreviewAutomationBroker.ts b/apps/server/src/mcp/PreviewAutomationBroker.ts new file mode 100644 index 00000000000..e0a7b0c9285 --- /dev/null +++ b/apps/server/src/mcp/PreviewAutomationBroker.ts @@ -0,0 +1,309 @@ +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 Stream from "effect/Stream"; +import * as SynchronizedRef from "effect/SynchronizedRef"; + +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>; + readonly reportOwner: ( + owner: PreviewAutomationOwner, + ) => Effect.Effect; + readonly clearOwner: (clientId: string) => Effect.Effect; + readonly respond: ( + response: PreviewAutomationResponse, + ) => Effect.Effect; + readonly invoke:
( + request: PreviewAutomationInvokeInput, + ) => Effect.Effect; +} + +export class PreviewAutomationBroker extends Context.Service< + PreviewAutomationBroker, + PreviewAutomationBrokerShape +>()("t3/mcp/PreviewAutomationBroker") {} + +interface ClientConnection { + readonly clientId: string; + readonly queue: Queue.Queue< + Parameters[0] extends never + ? never + : import("@t3tools/contracts").PreviewAutomationRequest + >; +} + +interface PendingRequest { + readonly clientId: string; + readonly deferred: Deferred.Deferred; +} + +interface BrokerState { + readonly clients: ReadonlyMap; + readonly owners: ReadonlyMap; + readonly pending: ReadonlyMap; + readonly requestSequence: number; +} + +const makeResponseError = ( + error: NonNullable, +): 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 make = Effect.gen(function* PreviewAutomationBrokerMake() { + const state = yield* SynchronizedRef.make({ + clients: new Map(), + owners: new Map(), + pending: new Map(), + requestSequence: 0, + }); + + const disconnect = Effect.fn("PreviewAutomationBroker.disconnect")(function* ( + clientId: string, + queue: ClientConnection["queue"], + ) { + const toFail = yield* SynchronizedRef.modify(state, (current) => { + if (current.clients.get(clientId)?.queue !== queue) { + return [[] as ReadonlyArray, 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); + disconnected.push(entry); + } + } + return [disconnected, { ...current, clients, owners, pending }] as const; + }); + 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(); + const previous = yield* SynchronizedRef.modify(state, (current) => { + const clients = new Map(current.clients); + clients.set(clientId, { clientId, queue }); + 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))); + }); + + const reportOwner: PreviewAutomationBrokerShape["reportOwner"] = Effect.fn( + "PreviewAutomationBroker.reportOwner", + )(function* (owner) { + yield* SynchronizedRef.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* SynchronizedRef.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) { + 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 [entry, { ...current, pending: next }] as const; + }); + 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* ( + input: Parameters[0], + ): Effect.fn.Return { + const current = yield* SynchronizedRef.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 timeoutMs = input.timeoutMs ?? 15_000; + const deferred = yield* Deferred.make(); + 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 [requestId, { ...next, pending, requestSequence: next.requestSequence + 1 }] as const; + }); + 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 }; + }); + 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); + +/** Exposed for tests. */ +export const __testing = { + make, +}; 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..6013b1cac9e --- /dev/null +++ b/apps/server/src/mcp/toolkits/preview/handlers.ts @@ -0,0 +1,63 @@ +import * as Effect from "effect/Effect"; +import type { + PreviewAutomationOperation, + PreviewAutomationRecordingArtifact, + PreviewAutomationRecordingStatus, + PreviewAutomationSnapshot, + PreviewAutomationStatus, +} from "@t3tools/contracts"; + +import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts"; +import { PreviewSnapshotToolkit, PreviewStandardToolkit, PreviewToolkit } from "./tools.ts"; + +const invoke = Effect.fn("PreviewToolkit.invoke")(function* ( + operation: PreviewAutomationOperation, + input: unknown, + timeoutMs?: number, +): Effect.fn.Return< + A, + import("@t3tools/contracts").PreviewAutomationError, + McpInvocationContext.McpInvocationContext | PreviewAutomationBroker.PreviewAutomationBroker +> { + const scope = yield* McpInvocationContext.requireMcpCapability("preview"); + const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; + return yield* broker.invoke({ + scope, + operation, + input, + ...(timeoutMs === undefined ? {} : { timeoutMs }), + }); +}); + +const handlers = { + preview_status: () => invoke("status", {}), + preview_open: (input) => + invoke("open", { + ...input, + show: input.show ?? true, + reuseExistingTab: input.reuseExistingTab ?? true, + }), + preview_navigate: (input) => invoke("navigate", input, input.timeoutMs), + preview_snapshot: () => invoke("snapshot", {}), + preview_click: (input) => invoke("click", input, input.timeoutMs).pipe(Effect.as(null)), + preview_type: (input) => invoke("type", input, input.timeoutMs).pipe(Effect.as(null)), + preview_press: (input) => invoke("press", input).pipe(Effect.as(null)), + preview_scroll: (input) => invoke("scroll", input).pipe(Effect.as(null)), + preview_evaluate: (input) => + invoke("evaluate", input).pipe(Effect.map((result) => result ?? null)), + preview_wait_for: (input) => + invoke("waitFor", input, input.timeoutMs).pipe(Effect.as(null)), + preview_recording_start: () => invoke("recordingStart", {}), + preview_recording_stop: () => invoke("recordingStop", {}), +} satisfies Parameters[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.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; + 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>; + 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 new file mode 100644 index 00000000000..fd2fedbb369 --- /dev/null +++ b/apps/server/src/mcp/toolkits/preview/tools.ts @@ -0,0 +1,196 @@ +import { + PreviewAutomationClickInput, + PreviewAutomationError, + PreviewAutomationEvaluateInput, + PreviewAutomationNavigateInput, + PreviewAutomationOpenInput, + PreviewAutomationPressInput, + PreviewAutomationRecordingArtifact, + PreviewAutomationRecordingStatus, + PreviewAutomationScrollInput, + PreviewAutomationSnapshot, + PreviewAutomationStatus, + PreviewAutomationTypeInput, + PreviewAutomationWaitForInput, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import { Tool, Toolkit } from "effect/unstable/ai"; + +import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts"; + +const dependencies = [ + McpInvocationContext.McpInvocationContext, + PreviewAutomationBroker.PreviewAutomationBroker, +]; + +const browserTool = (tool: T): T => + tool.annotate(Tool.OpenWorld, true).annotate(Tool.Destructive, true) as T; + +const safeBrowserTool = (tool: T): T => + browserTool(tool).annotate(Tool.Destructive, false) as T; + +const readonlyBrowserTool = (tool: T): T => + safeBrowserTool(tool).annotate(Tool.Readonly, true).annotate(Tool.Idempotent, 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, +}) + .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, + }) + .annotate(Tool.Title, "Open browser preview") + .annotate(Tool.Destructive, false), +); + +export const PreviewNavigateTool = safeBrowserTool( + Tool.make("preview_navigate", { + description: + "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, + dependencies, + }).annotate(Tool.Title, "Navigate browser preview"), +); + +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, + }).annotate(Tool.Title, "Inspect browser page"), +); + +export const PreviewClickTool = browserTool( + Tool.make("preview_click", { + 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"), +); + +export const PreviewTypeTool = browserTool( + Tool.make("preview_type", { + 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"), +); + +export const PreviewPressTool = browserTool( + Tool.make("preview_press", { + 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"), +); + +export const PreviewScrollTool = safeBrowserTool( + Tool.make("preview_scroll", { + 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"), +); + +export const PreviewEvaluateTool = browserTool( + Tool.make("preview_evaluate", { + description: + "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, + dependencies, + }).annotate(Tool.Title, "Evaluate JavaScript in preview"), +); + +export const PreviewWaitForTool = readonlyBrowserTool( + Tool.make("preview_wait_for", { + 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"), +); + +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, + }).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, + }).annotate(Tool.Title, "Stop browser recording"), +); + +export const PreviewToolkit = Toolkit.make( + PreviewStatusTool, + PreviewOpenTool, + PreviewNavigateTool, + PreviewSnapshotTool, + PreviewClickTool, + PreviewTypeTool, + PreviewPressTool, + PreviewScrollTool, + PreviewEvaluateTool, + PreviewWaitForTool, + 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.test.ts b/apps/server/src/preview/Manager.test.ts new file mode 100644 index 00000000000..a910e27470d --- /dev/null +++ b/apps/server/src/preview/Manager.test.ts @@ -0,0 +1,260 @@ +import { it } from "@effect/vitest"; +import { type PreviewEvent, ThreadId } from "@t3tools/contracts"; +import { Effect, PubSub } from "effect"; +import { expect } from "vite-plus/test"; + +import * as PreviewManager from "./Manager.ts"; + +const DRAIN_LIMIT = 100; + +interface EventCollector { + /** Drain everything published since the last call (or since subscribe). */ + readonly drain: Effect.Effect>; +} + +/** + * Each `it.effect` shares the live PreviewManager layer across the whole + * `it.layer` block, so tests that assert per-thread counts must use a unique + * thread id to avoid bleeding state from earlier tests. + */ +let nextThreadId = 0; +const freshThreadId = () => ThreadId.make(`thread-${++nextThreadId}`); + +/** + * Subscribe to the manager's event stream BEFORE the test publishes. We + * use `subscribeEvents` (synchronous PubSub.subscribe under the hood) so + * no event can land between subscribe and the consumer drain. + */ +const collectEvents = Effect.gen(function* () { + const manager = yield* PreviewManager.PreviewManager; + const subscription = yield* manager.subscribeEvents; + const collector: EventCollector = { + drain: PubSub.takeUpTo(subscription, DRAIN_LIMIT), + }; + return collector; +}).pipe(Effect.withSpan("preview.test.collectEvents")); + +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.PreviewManager; + const collector = yield* collectEvents; + + const snapshot = yield* manager.open({ threadId, url: "localhost:5173" }); + expect(snapshot.tabId.startsWith("tab_")).toBe(true); + expect(snapshot.navStatus._tag).toBe("Loading"); + if (snapshot.navStatus._tag === "Loading") { + expect(snapshot.navStatus.url).toBe("http://localhost:5173/"); + } + + const events = yield* collector.drain; + expect(events).toHaveLength(1); + expect(events[0]?.type).toBe("opened"); + if (events[0]?.type === "opened") { + expect(events[0].tabId).toBe(snapshot.tabId); + } + }), + ); + + it.effect("opens an Idle tab when no URL is supplied", () => + Effect.gen(function* () { + const threadId = freshThreadId(); + const manager = yield* PreviewManager.PreviewManager; + const snapshot = yield* manager.open({ threadId }); + expect(snapshot.navStatus._tag).toBe("Idle"); + }), + ); + + it.effect("treats bare hosts as https", () => + Effect.gen(function* () { + const threadId = freshThreadId(); + 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/"); + } + }), + ); + + it.effect("rejects empty URL with PreviewInvalidUrlError", () => + Effect.gen(function* () { + const threadId = freshThreadId(); + const manager = yield* PreviewManager.PreviewManager; + const error = yield* Effect.flip(manager.open({ threadId, url: " " })); + expect(error._tag).toBe("PreviewInvalidUrlError"); + }), + ); + + it.effect("navigate updates snapshot and emits navigated", () => + Effect.gen(function* () { + const threadId = freshThreadId(); + const manager = yield* PreviewManager.PreviewManager; + const collector = yield* collectEvents; + + const opened = yield* manager.open({ threadId, url: "http://localhost:5173" }); + const snapshot = yield* manager.navigate({ + threadId, + tabId: opened.tabId, + url: "http://localhost:5173/about", + resolvedTitle: "About", + }); + + expect(snapshot.navStatus._tag).toBe("Success"); + if (snapshot.navStatus._tag === "Success") { + expect(snapshot.navStatus.url).toBe("http://localhost:5173/about"); + expect(snapshot.navStatus.title).toBe("About"); + } + const events = yield* collector.drain; + expect(events.map((e) => e.type)).toEqual(["opened", "navigated"]); + }), + ); + + it.effect("navigate fails for unknown tab", () => + Effect.gen(function* () { + const threadId = freshThreadId(); + const manager = yield* PreviewManager.PreviewManager; + const error = yield* Effect.flip( + manager.navigate({ + threadId, + tabId: "tab_missing", + url: "http://localhost:5173", + }), + ); + expect(error._tag).toBe("PreviewSessionLookupError"); + }), + ); + + it.effect("reportStatus emits failed for LoadFailed nav", () => + Effect.gen(function* () { + const threadId = freshThreadId(); + const manager = yield* PreviewManager.PreviewManager; + const collector = yield* collectEvents; + + const opened = yield* manager.open({ threadId, url: "http://localhost:5173" }); + yield* manager.reportStatus({ + threadId, + tabId: opened.tabId, + navStatus: { + _tag: "LoadFailed", + url: "http://localhost:5173", + title: "", + code: -105, + description: "ERR_NAME_NOT_RESOLVED", + }, + canGoBack: false, + canGoForward: false, + }); + + const events = yield* collector.drain; + const failed = events.find((e) => e.type === "failed"); + expect(failed?.type).toBe("failed"); + if (failed?.type === "failed") { + expect(failed.code).toBe(-105); + expect(failed.description).toBe("ERR_NAME_NOT_RESOLVED"); + } + }), + ); + + it.effect("close removes the session and emits closed", () => + Effect.gen(function* () { + const threadId = freshThreadId(); + const manager = yield* PreviewManager.PreviewManager; + const collector = yield* collectEvents; + + yield* manager.open({ threadId, url: "http://localhost:5173" }); + yield* manager.close({ threadId }); + + const result = yield* manager.list({ threadId }); + expect(result.sessions).toHaveLength(0); + const events = yield* collector.drain; + const closed = events.find((e) => e.type === "closed"); + expect(closed?.type).toBe("closed"); + }), + ); + + it.effect("close is idempotent for unknown threads", () => + Effect.gen(function* () { + const threadId = freshThreadId(); + const manager = yield* PreviewManager.PreviewManager; + yield* manager.close({ threadId }); + const result = yield* manager.list({ threadId }); + expect(result.sessions).toHaveLength(0); + }), + ); + + it.effect("list returns every snapshot for the thread sorted by updatedAt", () => + Effect.gen(function* () { + const threadId = freshThreadId(); + 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 }); + expect(result.sessions).toHaveLength(2); + const ids = result.sessions.map((s) => s.tabId); + expect(ids).toContain(first.tabId); + expect(ids).toContain(second.tabId); + }), + ); + + it.effect("open creates an independent tab on every call", () => + Effect.gen(function* () { + const threadId = freshThreadId(); + const manager = yield* PreviewManager.PreviewManager; + const collector = yield* collectEvents; + + const a = yield* manager.open({ threadId, url: "http://localhost:5173" }); + const b = yield* manager.open({ threadId, url: "http://localhost:3000/path" }); + + expect(a.tabId).not.toBe(b.tabId); + const list = yield* manager.list({ threadId }); + expect(list.sessions).toHaveLength(2); + + const events = yield* collector.drain; + expect(events.map((e) => e.type)).toEqual(["opened", "opened"]); + }), + ); + + it.effect("close with mismatching tabId is a no-op", () => + Effect.gen(function* () { + const threadId = freshThreadId(); + const manager = yield* PreviewManager.PreviewManager; + yield* manager.open({ threadId, url: "http://localhost:5173" }); + yield* manager.close({ threadId, tabId: "tab_missing" }); + + const list = yield* manager.list({ threadId }); + expect(list.sessions).toHaveLength(1); + }), + ); + + it.effect("close with explicit tabId removes only that tab", () => + Effect.gen(function* () { + const threadId = freshThreadId(); + 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" }); + + yield* manager.close({ threadId, tabId: a.tabId }); + + const list = yield* manager.list({ threadId }); + expect(list.sessions.map((s) => s.tabId)).toEqual([b.tabId]); + }), + ); + + it.effect("multiple subscribers receive every event independently", () => + Effect.gen(function* () { + const threadId = freshThreadId(); + const manager = yield* PreviewManager.PreviewManager; + const aSub = yield* manager.subscribeEvents; + const bSub = yield* manager.subscribeEvents; + + yield* manager.open({ threadId, url: "http://localhost:5173" }); + yield* manager.open({ threadId, url: "http://localhost:3000" }); + + const aEvents = yield* PubSub.takeUpTo(aSub, DRAIN_LIMIT); + const bEvents = yield* PubSub.takeUpTo(bSub, DRAIN_LIMIT); + expect(aEvents.map((e) => e.type)).toEqual(["opened", "opened"]); + expect(bEvents.map((e) => e.type)).toEqual(["opened", "opened"]); + }), + ); +}); diff --git a/apps/server/src/preview/Manager.ts b/apps/server/src/preview/Manager.ts new file mode 100644 index 00000000000..8fa3a3668bf --- /dev/null +++ b/apps/server/src/preview/Manager.ts @@ -0,0 +1,362 @@ +/** + * In-memory PreviewManager implementation. + * + * Sessions are keyed by `(threadId, tabId)`; a single thread can host + * multiple tabs (browser-style). `open` always creates a new tab — tab + * lifecycle is owned by the renderer. + * + * Events are published via Effect's `PubSub`, so subscriber failures are + * isolated from the publishing call (a closed WS subscriber queue cannot + * 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"; +import { + newPreviewTabId, + normalizePreviewUrl, + PreviewUrlNormalizationError, +} from "@t3tools/shared/preview"; +import { + Context, + DateTime, + Effect, + Layer, + PubSub, + type Scope, + Stream, + SynchronizedRef, +} from "effect"; + +export interface PreviewManagerShape { + readonly open: (input: PreviewOpenInput) => Effect.Effect; + readonly navigate: ( + input: PreviewNavigateInput, + ) => Effect.Effect; + readonly reportStatus: (input: PreviewReportStatusInput) => Effect.Effect; + readonly refresh: (input: PreviewRefreshInput) => Effect.Effect; + readonly close: (input: PreviewCloseInput) => Effect.Effect; + readonly list: (input: PreviewListInput) => Effect.Effect; + readonly events: Stream.Stream; + readonly subscribeEvents: Effect.Effect, never, Scope.Scope>; +} + +export class PreviewManager extends Context.Service()( + "t3/preview/Manager/PreviewManager", +) {} + +interface PreviewSessionState { + readonly threadId: string; + readonly tabId: string; + readonly snapshot: PreviewSessionSnapshot; +} + +interface ManagerState { + /** All sessions across every thread, keyed by `${threadId}\u0000${tabId}`. */ + readonly sessions: ReadonlyMap; +} + +const initialState: ManagerState = { sessions: new Map() }; + +const compositeKey = (threadId: string, tabId: string): string => `${threadId}\u0000${tabId}`; + +const sessionsForThread = ( + state: ManagerState, + threadId: string, +): ReadonlyArray => { + const out: PreviewSessionState[] = []; + for (const session of state.sessions.values()) { + if (session.threadId === threadId) out.push(session); + } + return out; +}; + +const normalizeUrl = (rawUrl: string): Effect.Effect => + Effect.try({ + try: () => normalizePreviewUrl(rawUrl), + catch: (cause) => + new PreviewInvalidUrlError({ + rawUrl, + detail: + cause instanceof PreviewUrlNormalizationError + ? cause.detail + : cause instanceof Error + ? cause.message + : String(cause), + }), + }); + +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: 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: input.updatedAt, +}); + +const make = Effect.gen(function* PreviewManagerMake() { + const stateRef = yield* SynchronizedRef.make(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 + // their own queues downstream. + const eventsPubSub = yield* PubSub.unbounded(); + const events: Stream.Stream = Stream.fromPubSub(eventsPubSub); + + /** + * Atomic read-modify-write over the session for `(threadId, tabId)`. The + * mutator runs under the SynchronizedRef so concurrent writers cannot + * interleave. Lookup failures travel through the modify result so both + * branches yield the same `[A, S]` shape `modifyEffect` requires. + * + * The event is published INSIDE the lock so observers see events in the + * same order as the underlying state transitions. Publishing an unbounded + * PubSub is non-blocking, so this is cheap. + */ + const mutateExistingSession = ( + threadId: string, + tabId: string, + mutator: ( + session: PreviewSessionState, + ) => Effect.Effect<{ next: PreviewSessionState; emit: PreviewEvent | null; result: R }, E>, + ): Effect.Effect => { + type ModifyResult = + | { kind: "fail"; error: PreviewSessionLookupError } + | { kind: "ok"; result: R }; + + return SynchronizedRef.modifyEffect(stateRef, (state) => { + const session = state.sessions.get(compositeKey(threadId, tabId)); + if (!session) { + return Effect.succeed([ + { kind: "fail", error: new PreviewSessionLookupError({ threadId, tabId }) }, + state, + ] as readonly [ModifyResult, ManagerState]); + } + return mutator(session).pipe( + 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); + return [{ kind: "ok", result } as ModifyResult, { sessions }] as readonly [ + ModifyResult, + ManagerState, + ]; + }), + ), + ); + }).pipe( + Effect.flatMap((modify) => + modify.kind === "fail" ? Effect.fail(modify.error) : Effect.succeed(modify.result), + ), + ); + }; + + 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, + 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, + snapshot, + }); + return { sessions }; + }); + yield* PubSub.publish(eventsPubSub, { + type: "opened", + threadId: input.threadId, + tabId, + createdAt: snapshot.updatedAt, + snapshot, + }); + return snapshot; + }); + + const navigate: PreviewManagerShape["navigate"] = Effect.fn("PreviewManager.navigate")( + function* (input) { + const url = yield* normalizeUrl(input.url); + 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; + const resolvedTitle = input.resolvedTitle ?? previousTitle; + const snapshot: PreviewSessionSnapshot = { + threadId: session.threadId, + tabId: session.tabId, + navStatus: { _tag: "Success", url, title: resolvedTitle }, + canGoBack: session.snapshot.canGoBack, + canGoForward: session.snapshot.canGoForward, + updatedAt, + }; + return { + next: { ...session, snapshot }, + emit: { + type: "navigated", + threadId: session.threadId, + tabId: session.tabId, + createdAt: snapshot.updatedAt, + snapshot, + }, + result: snapshot, + }; + }), + ); + }, + ); + + 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, + tabId: session.tabId, + navStatus: input.navStatus, + canGoBack: input.canGoBack, + canGoForward: input.canGoForward, + updatedAt, + }; + const emit: PreviewEvent = + input.navStatus._tag === "LoadFailed" + ? { + type: "failed", + threadId: session.threadId, + tabId: session.tabId, + createdAt: snapshot.updatedAt, + url: input.navStatus.url, + title: input.navStatus.title, + code: input.navStatus.code, + description: input.navStatus.description, + } + : { + type: "navigated", + threadId: session.threadId, + tabId: session.tabId, + createdAt: snapshot.updatedAt, + snapshot, + }; + return { + next: { ...session, snapshot }, + emit, + 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"] = 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"] = Effect.fn("PreviewManager.list")(function* (input) { + return yield* SynchronizedRef.get(stateRef).pipe( + Effect.map( + (state): PreviewListResult => ({ + sessions: sessionsForThread(state, input.threadId) + .map((s) => s.snapshot) + .toSorted((a, b) => a.updatedAt.localeCompare(b.updatedAt)), + }), + ), + ); + }); + + return { + open, + navigate, + reportStatus, + refresh, + close, + list, + events, + subscribeEvents: PubSub.subscribe(eventsPubSub), + } satisfies PreviewManagerShape; +}).pipe(Effect.withSpan("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 new file mode 100644 index 00000000000..8b37e86d8a9 --- /dev/null +++ b/apps/server/src/preview/PortScanner.test.ts @@ -0,0 +1,267 @@ +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 { describe, expect, it } from "vite-plus/test"; + +import { ProcessRunner } from "../processRunner.ts"; +import * as PortScanner from "./PortScanner.ts"; + +const { parseLsofOutput, parsePortFromLsofName, parseWindowsListenerOutput, serversEqual } = + PortScanner.__testing; +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(Layer.mergeAll(TestProcessRunner, Net.layer)), +); + +const openServer = (port: number): Effect.Effect => + 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 => + 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, +) { + 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", () => { + expect(parsePortFromLsofName("*:5173")).toBe(5173); + }); + + it("parses 127.0.0.1:port", () => { + expect(parsePortFromLsofName("127.0.0.1:5173")).toBe(5173); + }); + + it("parses localhost:port", () => { + expect(parsePortFromLsofName("localhost:5173")).toBe(5173); + }); + + it("parses [::1]:port", () => { + expect(parsePortFromLsofName("[::1]:5173")).toBe(5173); + }); + + it("ignores non-local hosts", () => { + expect(parsePortFromLsofName("192.168.1.10:5173")).toBeNull(); + }); + + it("strips trailing description", () => { + expect(parsePortFromLsofName("*:5173 (LISTEN)")).toBe(5173); + }); + + it("rejects garbage", () => { + expect(parsePortFromLsofName("")).toBeNull(); + expect(parsePortFromLsofName("not-a-port")).toBeNull(); + expect(parsePortFromLsofName("*:0")).toBeNull(); + expect(parsePortFromLsofName("*:99999")).toBeNull(); + }); +}); + +describe("parseLsofOutput", () => { + it("parses a typical lsof -F pcn output", () => { + const sample = [ + "p12345", + "cnode", + "n*:5173", + "p67890", + "cnext-server", + "n127.0.0.1:3000", + "n127.0.0.1:9229", // node debug port too — same process + "p13579", + "cChrome", + "n192.168.1.10:443", // not local — ignored + ].join("\n"); + + const servers = parseLsofOutput(sample); + expect(servers).toEqual([ + { + host: "localhost", + port: 3000, + url: "http://localhost:3000", + processName: "next-server", + pid: 67890, + terminal: null, + }, + { + host: "localhost", + port: 5173, + url: "http://localhost:5173", + processName: "node", + pid: 12345, + terminal: null, + }, + { + host: "localhost", + port: 9229, + url: "http://localhost:9229", + processName: "next-server", + pid: 67890, + terminal: null, + }, + ]); + }); + + it("handles empty input", () => { + expect(parseLsofOutput("")).toEqual([]); + }); + + it("dedupes by host:port", () => { + const sample = ["p1", "cnode", "n*:5173", "n127.0.0.1:5173"].join("\n"); + const servers = parseLsofOutput(sample); + 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", () => { + const a = { + host: "localhost", + port: 5173, + url: "http://localhost:5173", + processName: "node", + pid: 1, + terminal: null, + }; + it("returns true for identical lists", () => { + expect(serversEqual([a], [{ ...a }])).toBe(true); + }); + it("returns false for different lengths", () => { + expect(serversEqual([a], [])).toBe(false); + }); + it("returns false for different processName", () => { + expect(serversEqual([a], [{ ...a, processName: "other" }])).toBe(false); + }); +}); + +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. + */ +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"); + }), + ); + + 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; + yield* scanner.subscribe((servers) => + Effect.sync(() => { + for (const server of servers) received.push(server.port); + }), + ); + yield* scanner.retain; + expect(received).toContain(port); + }), + ); +}); diff --git a/apps/server/src/preview/PortScanner.ts b/apps/server/src/preview/PortScanner.ts new file mode 100644 index 00000000000..c8d9a051ed6 --- /dev/null +++ b/apps/server/src/preview/PortScanner.ts @@ -0,0 +1,369 @@ +/** + * In-process PortScanner implementation. + * + * macOS/Linux: parses `lsof -iTCP -sTCP:LISTEN -P -n -F pcn` (-F output is a + * stable line-prefixed field format; this is the only `lsof` flag set we rely + * on). + * + * Windows / lsof missing: checks a curated list of common dev ports through + * the shared Net service. + * + * 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, Scope } from "effect"; + +import { ProcessRunner } from "../processRunner.ts"; + +export interface PortDiscoveryShape { + readonly scan: () => Effect.Effect>; + readonly subscribe: ( + listener: (servers: ReadonlyArray) => Effect.Effect, + ) => Effect.Effect; + readonly retain: Effect.Effect; + readonly registerTerminalProcesses: (input: { + readonly threadId: string; + readonly terminalId: string; + readonly processIds: ReadonlyArray; + }) => Effect.Effect; + readonly unregisterTerminal: (input: { + readonly threadId: string; + readonly terminalId: string; + }) => Effect.Effect; +} + +export class PortDiscovery extends Context.Service()( + "t3/preview/PortScanner/PortDiscovery", +) {} + +export const COMMON_DEV_PORTS: ReadonlyArray = 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 LSOF_TIMEOUT_MS = 5_000; +const WINDOWS_LISTENER_TIMEOUT_MS = 5_000; + +type Listener = (servers: ReadonlyArray) => Effect.Effect; + +interface ScannerState { + readonly lastSnapshot: ReadonlyArray; + readonly listeners: ReadonlySet; + readonly terminalProcesses: ReadonlyMap< + string, + { + readonly owner: TerminalProcessOwner; + readonly processIds: ReadonlySet; + } + >; + readonly retainCount: number; +} + +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 = new Map(), +): ReadonlyArray => { + const seen = new Map(); + let pid: number | null = null; + let processName: string | null = null; + + for (const line of raw.split("\n")) { + if (line.length === 0) continue; + const tag = line.charAt(0); + const value = line.slice(1); + if (tag === "p") { + const parsed = Number.parseInt(value, 10); + pid = Number.isFinite(parsed) && parsed > 0 ? parsed : null; + processName = null; + continue; + } + if (tag === "c") { + processName = value.trim() || null; + continue; + } + if (tag === "n") { + const portMatch = parsePortFromLsofName(value); + if (portMatch == null) continue; + const url = `http://localhost:${portMatch}`; + const key = `localhost:${portMatch}`; + if (seen.has(key)) continue; + seen.set(key, { + host: "localhost", + port: portMatch, + url, + processName, + pid, + terminal: pid === null ? null : (terminalByProcessId.get(pid) ?? null), + }); + } + } + + return Array.from(seen.values()).toSorted((a, b) => a.port - b.port); +}; + +const parsePortFromLsofName = (name: string): number | null => { + // Examples: "*:5173", "127.0.0.1:5173", "[::1]:5173", "localhost:5173", + // "192.168.1.10:5173 (LISTEN)" — we only care if the host part is local. + const trimmed = name.split(" ", 1)[0]?.trim() ?? ""; + if (trimmed.length === 0) return null; + const lastColon = trimmed.lastIndexOf(":"); + if (lastColon < 0) return null; + const hostPart = trimmed.slice(0, lastColon); + const portPart = trimmed.slice(lastColon + 1); + if (!LSOF_LOCAL_HOST_TOKENS.has(hostPart)) return null; + const port = Number.parseInt(portPart, 10); + if (!Number.isFinite(port) || port <= 0 || port >= 65536) return null; + return port; +}; + +const parseWindowsListenerOutput = ( + raw: string, + terminalByProcessId: ReadonlyMap = new Map(), +): ReadonlyArray => { + const seen = new Map(); + 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 serversEqual = ( + left: ReadonlyArray, + right: ReadonlyArray, +): boolean => { + if (left.length !== right.length) return false; + for (let i = 0; i < left.length; i += 1) { + const a = left[i]; + const b = right[i]; + if (!a || !b) return false; + if ( + a.host !== b.host || + a.port !== b.port || + a.url !== b.url || + a.processName !== b.processName || + a.pid !== b.pid || + a.terminal?.threadId !== b.terminal?.threadId || + a.terminal?.terminalId !== b.terminal?.terminalId + ) { + return false; + } + } + return true; +}; + +const make = Effect.gen(function* PortDiscoveryMake() { + const net = yield* Net.NetService; + const processRunner = yield* ProcessRunner; + const stateRef = yield* Ref.make({ + lastSnapshot: [], + listeners: new Set(), + terminalProcesses: new Map(), + 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((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 state = yield* Ref.get(stateRef); + const terminalByProcessId = new Map(); + for (const registration of state.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 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 = Effect.fn("PortDiscovery.broadcast")(function* ( + servers: ReadonlyArray, + ) { + const listeners = (yield* Ref.get(stateRef)).listeners; + yield* Effect.forEach(listeners, (listener) => listener(servers), { discard: true }); + }); + + const pollTick = Effect.fn("PortDiscovery.pollTick")( + function* () { + if ((yield* Ref.get(stateRef)).retainCount <= 0) return; + const next = yield* scanOnce(); + 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) => + Effect.logWarning("preview port scan failed", Cause.pretty(cause)), + ), + ); + + // 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)))); + + 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(); + } + }); + + 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")( + (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) { + 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); + } else { + terminalProcesses.set(key, { owner, processIds }); + } + return { ...state, terminalProcesses }; + }); + }); + + const unregisterTerminal: PortDiscoveryShape["unregisterTerminal"] = Effect.fn( + "PortDiscovery.unregisterTerminal", + )(function* (input) { + yield* Ref.update(stateRef, (state) => { + const terminalProcesses = new Map(state.terminalProcesses); + terminalProcesses.delete(terminalOwnerKey(input)); + return { ...state, terminalProcesses }; + }); + }); + + return { + scan: scanOnce, + subscribe, + retain, + registerTerminalProcesses, + unregisterTerminal, + } satisfies PortDiscoveryShape; +}).pipe(Effect.withSpan("PortDiscovery.make")); + +export const layer = Layer.effect(PortDiscovery, make); + +/** Exposed for tests. */ +export const __testing = { + parseLsofOutput, + parsePortFromLsofName, + parseWindowsListenerOutput, + serversEqual, +}; 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, + relativeCandidates: ReadonlyArray, ): Effect.fn.Return { - 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 { + 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/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 = `# 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 \`\` block in your response. Alternatively, they can decide to stay in Plan mode and continue refining the plan. Only produce at most one \`\` block per turn, and only when you are presenting a complete spec. +${T3_CODE_BROWSER_TOOL_INSTRUCTIONS} `; export const CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS = `# 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} `; diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 38b77c69262..c91f305b174 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 * as McpProviderSession from "../../mcp/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 = McpProviderSession.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.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 8c9969e2bc4..270126e934b 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 * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import { ProviderAdapterRequestError, @@ -234,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"; @@ -475,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 } : {}), }, @@ -1382,6 +1386,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( input.modelSelection?.instanceId === boundInstanceId ? getCodexServiceTierOptionValue(input.modelSelection) : undefined; + const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); const runtimeInput: CodexSessionRuntimeOptions = { threadId: input.threadId, providerInstanceId: boundInstanceId, @@ -1397,6 +1402,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.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 f9b9c6ab4fb..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 | undefined): boolean { + return appServerArgs?.some((argument) => argument.includes("mcp_servers.")) === true; +} + export const CodexResumeCursorSchema = Schema.Struct({ threadId: Schema.String, }); @@ -103,6 +107,7 @@ export interface CodexSessionRuntimeOptions { readonly model?: string; readonly serviceTier?: CodexServiceTier | undefined; readonly resumeCursor?: CodexResumeCursor; + readonly appServerArgs?: ReadonlyArray; } export interface CodexSessionRuntimeSendTurnInput { @@ -720,7 +725,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, @@ -1255,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/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index cdb3c224b97..1560332ad7f 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 * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import { ProviderAdapterProcessError, ProviderAdapterRequestError, @@ -530,6 +531,7 @@ export function makeCursorAdapter( ? yield* options.resolveSettings : cursorSettings; + const mcpSession = McpProviderSession.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..a21a2bb9fc7 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 * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import { ProviderAdapterProcessError, ProviderAdapterRequestError, @@ -374,6 +375,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte threadId: input.threadId, }); + const mcpSession = McpProviderSession.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..1eb6e47bc19 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 * as McpProviderSession from "../../mcp/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 = McpProviderSession.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..ecb1dd2dbd3 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -56,6 +56,8 @@ import { import { type EventNdjsonLogger } from "./EventNdjsonLogger.ts"; import { ProviderEventLoggers } from "./ProviderEventLoggers.ts"; import { AnalyticsService } from "../../telemetry/Services/AnalyticsService.ts"; +import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; +import * as McpSessionRegistry from "../../mcp/McpSessionRegistry.ts"; const isModelSelection = Schema.is(ModelSelection); /** @@ -212,6 +214,18 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( const directory = yield* ProviderSessionDirectory; const runtimeEventPubSub = yield* PubSub.unbounded(); const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + const prepareMcpSession = (threadId: ThreadId, providerInstanceId: ProviderInstanceId) => + McpSessionRegistry.issueActiveMcpCredential({ threadId, providerInstanceId }).pipe( + Effect.tap((credential) => + credential + ? Effect.sync(() => McpProviderSession.setMcpProviderSession(credential.config)) + : Effect.void, + ), + ); + const clearMcpSession = (threadId: ThreadId) => + McpSessionRegistry.revokeActiveMcpThread(threadId).pipe( + Effect.tap(() => Effect.sync(() => McpProviderSession.clearMcpProviderSession(threadId))), + ); const publishRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect => Effect.succeed(event).pipe( @@ -383,16 +397,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 +590,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 +849,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 +1021,8 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ), ).pipe(Effect.asVoid); yield* Effect.forEach(currentAdapters, ([, adapter]) => adapter.stopAll()).pipe(Effect.asVoid); + 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/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; readonly requestLogger?: (event: AcpSessionRequestLogEvent) => Effect.Effect; 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.test.ts b/apps/server/src/server.test.ts index 0bf2f6589f0..40c9c7cd9a8 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -20,6 +20,7 @@ import { type OrchestrationCommand, type OrchestrationEvent, ORCHESTRATION_WS_METHODS, + type PreviewEvent, ProjectId, ProviderDriverKind, ProviderInstanceId, @@ -48,6 +49,7 @@ import * as Layer from "effect/Layer"; import * as ManagedRuntime from "effect/ManagedRuntime"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as PubSub from "effect/PubSub"; import * as Stream from "effect/Stream"; import { ChildProcessSpawner } from "effect/unstable/process"; import { @@ -69,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, @@ -97,6 +98,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 * as PreviewManager from "./preview/Manager.ts"; +import * as PortScanner from "./preview/PortScanner.ts"; import { BrowserTraceCollector, type BrowserTraceCollectorShape, @@ -512,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), @@ -663,6 +666,29 @@ const buildAppUnderTest = (options?: { ...options?.layers?.terminalManager, }), ), + Layer.provide( + Layer.mergeAll( + 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, + refresh: () => Effect.void, + close: () => Effect.void, + list: () => Effect.succeed({ sessions: [] }), + events: Stream.empty, + subscribeEvents: Effect.flatMap(PubSub.unbounded(), (pubsub) => + PubSub.subscribe(pubsub), + ), + }), + Layer.mock(PortScanner.PortDiscovery)({ + scan: () => Effect.succeed([]), + subscribe: () => Effect.void, + retain: Effect.void, + registerTerminalProcesses: () => Effect.void, + unregisterTerminal: () => Effect.void, + }), + ), + ), Layer.provide( Layer.mock(OrchestrationEngineService)({ readEvents: () => Stream.empty, @@ -1252,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"), - "router-project-favicon", - ); - - 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, "router-project-favicon"); - }).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(); @@ -3168,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]({}))), @@ -3733,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(); @@ -3826,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<{ @@ -4169,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 b7331f95e6e..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, @@ -35,6 +34,11 @@ 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 * 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"; import { ServerRuntimeStartup, ServerRuntimeStartupLive } from "./serverRuntimeStartup.ts"; @@ -231,7 +235,17 @@ const CheckpointingLayerLive = Layer.empty.pipe( Layer.provideMerge(CheckpointStoreLive.pipe(Layer.provide(VcsDriverRegistryLayerLive))), ); -const TerminalLayerLive = TerminalManagerLive.pipe(Layer.provide(PtyAdapterLive)); +const PortScannerLayerLive = PortScanner.layer.pipe(Layer.provide(ProcessRunner.layer)); + +const TerminalLayerLive = TerminalManagerLive.pipe( + Layer.provide(PtyAdapterLive), + Layer.provide(PortScannerLayerLive), +); + +const PreviewLayerLive = Layer.empty.pipe( + Layer.provideMerge(PreviewManager.layer), + Layer.provideMerge(PortScannerLayerLive), +); const WorkspaceEntriesLayerLive = WorkspaceEntriesLive.pipe( Layer.provide(WorkspacePathsLive), @@ -249,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), @@ -274,7 +292,7 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( Layer.provideMerge(GitLayerLive), Layer.provideMerge(VcsLayerLive), Layer.provideMerge(ProviderRuntimeLayerLive), - Layer.provideMerge(TerminalLayerLive), + Layer.provideMerge(Layer.mergeAll(TerminalLayerLive, PreviewLayerLive)), Layer.provideMerge(PersistenceLayerLive), Layer.provideMerge(KeybindingsLive), Layer.provideMerge(ProviderRegistryLive), @@ -298,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), @@ -327,18 +345,20 @@ 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), + ), + otlpTracesProxyRouteLayer, + assetRouteLayer, + staticAndDevRouteLayer, + websocketRpcRouteLayer, ), - attachmentsRouteLayer, - otlpTracesProxyRouteLayer, - projectFaviconRouteLayer, - staticAndDevRouteLayer, - websocketRpcRouteLayer, + 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.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; }>; 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; + } = { 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..f2b466a4390 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 * as PortScanner from "../../preview/PortScanner.ts"; import { TerminalCwdError, TerminalHistoryError, @@ -82,6 +83,7 @@ class TerminalProcessSignalError extends Schema.TaggedErrorClass; } 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(); + const childrenByParent = new Map(); + 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([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([terminalPid]); + const psResult = yield* Effect.exit(runPs); + if (psResult._tag === "Success" && psResult.value.code === 0) { + const childrenByParent = new Map(); + 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; + }) => Effect.Effect; + unregisterTerminal?: (input: { + readonly threadId: string; + readonly terminalId: string; + }) => Effect.Effect; } const makeTerminalManager = Effect.fn("makeTerminalManager")(function* () { const { terminalLogsDir } = yield* ServerConfig; const ptyAdapter = yield* PtyAdapter; + const portDiscovery = yield* PortScanner.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 = Option.fromNullishOr( diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 0f2a8f790bf..2823923e033 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -22,6 +22,7 @@ import { type AuthEnvironmentScope, AuthSessionId, CommandId, + type DiscoveredLocalServerList, EventId, type OrchestrationCommand, type GitActionProgressEvent, @@ -39,6 +40,7 @@ import { type RelayClientInstallProgressEvent, OrchestrationReplayEventsError, FilesystemBrowseError, + AssetAccessError, EnvironmentAuthorizationError, ThreadId, type TerminalAttachStreamEvent, @@ -70,6 +72,10 @@ import { ServerLifecycleEvents } from "./serverLifecycleEvents.ts"; import { ServerRuntimeStartup } from "./serverRuntimeStartup.ts"; import { redactServerSettingsForClient, ServerSettingsService } from "./serverSettings.ts"; 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"; import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePaths.ts"; @@ -158,6 +164,7 @@ const RPC_REQUIRED_SCOPE = new Map([ [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], @@ -180,6 +187,18 @@ const RPC_REQUIRED_SCOPE = new Map([ [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.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], [WS_METHODS.subscribeServerLifecycle, AuthOrchestrationReadScope], [WS_METHODS.subscribeAuthAccess, AuthAccessReadScope], @@ -240,6 +259,9 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => const vcsProvisioning = yield* VcsProvisioningService; const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; const terminalManager = yield* TerminalManager; + 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; @@ -1184,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, @@ -1350,6 +1418,80 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => ), { "rpc.aggregate": "terminal" }, ), + [WS_METHODS.previewOpen]: (input) => + observeRpcEffect(WS_METHODS.previewOpen, previewManager.open(input), { + "rpc.aggregate": "preview", + }), + [WS_METHODS.previewNavigate]: (input) => + observeRpcEffect(WS_METHODS.previewNavigate, previewManager.navigate(input), { + "rpc.aggregate": "preview", + }), + [WS_METHODS.previewRefresh]: (input) => + observeRpcEffect(WS_METHODS.previewRefresh, previewManager.refresh(input), { + "rpc.aggregate": "preview", + }), + [WS_METHODS.previewClose]: (input) => + observeRpcEffect(WS_METHODS.previewClose, previewManager.close(input), { + "rpc.aggregate": "preview", + }), + [WS_METHODS.previewList]: (input) => + observeRpcEffect(WS_METHODS.previewList, previewManager.list(input), { + "rpc.aggregate": "preview", + }), + [WS_METHODS.previewReportStatus]: (input) => + 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", + }), + [WS_METHODS.subscribeDiscoveredLocalServers]: (_input) => + observeRpcStream( + WS_METHODS.subscribeDiscoveredLocalServers, + Stream.callback((queue) => + 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" }, + ), [WS_METHODS.subscribeServerConfig]: (_input) => observeRpcStreamEffect( WS_METHODS.subscribeServerConfig, @@ -1473,6 +1615,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( 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(); +const assetUrlRequests = new Map>(); + +function assetCacheKey(environmentId: EnvironmentId, resource: AssetResource): string { + return `${environmentId}:${JSON.stringify(resource)}`; +} + +export async function resolveAssetUrl( + environmentId: EnvironmentId, + resource: AssetResource, +): Promise { + 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(() => assetUrlCache.get(key)?.url ?? null); + + useEffect(() => { + let cancelled = false; + let refreshTimer: ReturnType | 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/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(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
; +} diff --git a/apps/web/src/browser/ElectronBrowserHost.tsx b/apps/web/src/browser/ElectronBrowserHost.tsx new file mode 100644 index 00000000000..feac8ed0f22 --- /dev/null +++ b/apps/web/src/browser/ElectronBrowserHost.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { parseScopedThreadKey } from "@t3tools/client-runtime"; +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( + () => + 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], + ); + + 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 ( +
+ {sessions.map(({ threadRef, snapshot }) => { + const url = snapshot.navStatus._tag === "Idle" ? null : snapshot.navStatus.url; + return ( + + ); + })} +
+ ); +} diff --git a/apps/web/src/browser/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx new file mode 100644 index 00000000000..276a9090af2 --- /dev/null +++ b/apps/web/src/browser/HostedBrowserWebview.tsx @@ -0,0 +1,104 @@ +"use client"; + +import type { ScopedThreadRef } from "@t3tools/contracts"; +import { useShallow } from "zustand/react/shallow"; +import { useCallback, useEffect, useRef } 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"; +import { usePreviewWebviewConfig } from "./previewWebviewConfigState"; + +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 = usePreviewWebviewConfig(threadRef.environmentId); + const initialSrcRef = useRef(initialUrl ?? "about:blank"); + const webviewRef = useRef(null); + const presentation = useBrowserSurfaceStore(useShallow((state) => state.byTabId[tabId] ?? null)); + const recording = useBrowserRecordingStore((state) => state.activeTabId === tabId); + + usePreviewBridge({ threadRef, tabId }); + + useEffect(() => acquireDesktopTab(tabId), [tabId]); + + 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 ( + + ); +} 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; + readonly apply: (event: DesktopPreviewPointerEvent) => void; + readonly clear: (tabId: string) => void; +} + +export const useBrowserPointerStore = create()((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/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()((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 { + 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 { + const bridge = previewBridge; + const recording = active; + if (!bridge || !recording || recording.tabId !== tabId) return null; + await bridge.recording.stopScreencast(tabId); + const stopped = new Promise((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; + 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()((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(); + +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/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 { + 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 { + 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/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/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()), + isPreviewSupportedInRuntime: () => true, +})); + +vi.mock("../browser/openFileInPreview", async (importOriginal) => ({ + ...(await importOriginal()), + 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( + , + ); + + 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( + , + ); + + 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>; 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) => { + 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 ( +
+ {children} + + ); +} + 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 = ( - { @@ -1156,7 +1239,7 @@ function ChatMarkdown({ ) : ( children )} - + ); 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 5a92a244c52..81b9c74231c 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"; @@ -249,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"], @@ -268,6 +281,32 @@ function createMockEnvironmentApi(input: { subscribeThread: (() => () => undefined) as EnvironmentApi["orchestration"]["subscribeThread"], }, + preview: { + open: () => { + throw new Error("Not implemented in browser test."); + }, + navigate: () => { + throw new Error("Not implemented in browser test."); + }, + refresh: () => { + throw new Error("Not implemented in browser test."); + }, + close: () => { + throw new Error("Not implemented in browser test."); + }, + list: () => Promise.resolve({ sessions: [] }), + 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"], }; } @@ -346,7 +385,6 @@ function createSnapshotForTargetUser(options: { name: `attachment-${attachmentIndex + 1}.png`, mimeType: "image/png", sizeBytes: 128, - previewUrl: `/attachments/attachment-${attachmentIndex + 1}`, })) : undefined; @@ -1126,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 { @@ -1775,6 +1812,8 @@ describe("ChatView timeline estimator parity (full app)", () => { useTerminalUiStateStore.setState({ terminalUiStateByThreadKey: {}, }); + useRightPanelStore.persist.clearStorage(); + useRightPanelStore.setState({ byThreadKey: {} }); }); afterEach(() => { @@ -2028,12 +2067,12 @@ describe("ChatView timeline estimator parity (full app)", () => { }); try { - const toggle = await waitForElement( + const terminalToggle = await waitForElement( () => document.querySelector('button[aria-label="Toggle terminal drawer"]'), "Unable to find terminal drawer toggle.", ); - toggle.click(); + terminalToggle.click(); await vi.waitFor( () => { @@ -2046,9 +2085,131 @@ 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 { + 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"]'), + "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('[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('[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('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.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 13bd175e0c9..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, @@ -73,6 +75,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", () => { @@ -250,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 de69c573046..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; + openThreadIds: ReadonlyArray; + 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); @@ -185,6 +202,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 +218,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 75fc0ad3235..bfeff47dec4 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, @@ -36,13 +31,15 @@ import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/proje import { truncate } from "@t3tools/shared/String"; import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels"; import { Debouncer } from "@tanstack/react-pacer"; -import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { lazy, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useNavigate, useSearch } from "@tanstack/react-router"; 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"; import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; import { @@ -70,11 +67,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,11 +91,33 @@ 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, + selectActiveRightPanelSurface, + selectThreadRightPanelState, + type RightPanelSurface, + useRightPanelStore, +} from "../rightPanelStore"; +import { + isPreviewSupportedInRuntime, + selectThreadPreviewState, + 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(() => + 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"; @@ -116,7 +131,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, @@ -141,6 +156,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"; @@ -153,7 +173,6 @@ import { ProviderStatusBanner } from "./chat/ProviderStatusBanner"; import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack"; import { - MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, buildExpiredTerminalContextToastCopy, buildLocalDraftThread, collectUserMessageBlobPreviewUrls, @@ -168,7 +187,6 @@ import { cloneComposerImageForRetry, deriveLockedProvider, readFileAsDataUrl, - reconcileMountedTerminalThreadIds, resolveSendEnvMode, revokeBlobPreviewUrl, revokeUserMessagePreviewUrls, @@ -519,9 +537,11 @@ interface PersistentThreadTerminalDrawerProps { threadRef: { environmentId: EnvironmentId; threadId: ThreadId }; threadId: ThreadId; visible: boolean; + mode?: "drawer" | "panel"; launchContext: PersistentTerminalLaunchContext | null; focusRequestId: number; splitShortcutLabel: string | undefined; + splitVerticalShortcutLabel: string | undefined; newShortcutLabel: string | undefined; closeShortcutLabel: string | undefined; keybindings: ResolvedKeybindingsConfig; @@ -532,9 +552,11 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra threadRef, threadId, visible, + mode = "drawer", launchContext, focusRequestId, splitShortcutLabel, + splitVerticalShortcutLabel, newShortcutLabel, closeShortcutLabel, keybindings, @@ -555,16 +577,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(); - 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, @@ -578,7 +617,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; @@ -596,13 +635,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); @@ -694,6 +736,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); @@ -779,8 +848,9 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra } return ( -
+
; + 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(); + 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; + } + >(); + 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 ( + undefined} + onAddTerminalContext={onAddTerminalContext} + terminalLabelsById={terminalLabelsById} + terminalLaunchLocationsById={terminalLaunchLocationsById} + keybindings={keybindings} + /> + ); +}); + export default function ChatView(props: ChatViewProps) { const { environmentId, @@ -865,6 +1108,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( @@ -889,6 +1135,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); @@ -910,7 +1157,6 @@ export default function ChatView(props: ChatViewProps) { >({}); const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] = useState>({}); - const [planSidebarOpen, setPlanSidebarOpen] = useState(false); const shouldUsePlanSidebarSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY); // Tracks whether the user explicitly dismissed the sidebar for the active turn. const planSidebarDismissedForTurnRef = useRef(null); @@ -943,43 +1189,12 @@ 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 storeSplitTerminalVertical = useTerminalUiStateStore((s) => s.splitTerminalVertical); 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([]); - const mountedTerminalThreadRefs = useMemo( - () => - mountedTerminalThreadKeys.flatMap((mountedThreadKey) => { - const mountedThreadRef = parseScopedThreadKey(mountedThreadKey); - return mountedThreadRef ? [{ key: mountedThreadKey, threadRef: mountedThreadRef }] : []; - }), - [mountedTerminalThreadKeys], - ); const fallbackDraftProjectRef = draftThread ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) @@ -1039,40 +1254,85 @@ export default function ChatView(props: ChatViewProps) { () => [...new Set([...activeServerOrderedTerminalIds, ...terminalUiState.terminalIds])], [activeServerOrderedTerminalIds, terminalUiState.terminalIds], ); + const activeTerminalLabelsById = useMemo(() => { + const next = new Map(); + 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 planSidebarOpen = activeRightPanelKind === "plan"; + const previewPanelOpen = activeRightPanelKind === "preview" && isPreviewSupportedInRuntime(); + const rightPanelOpen = rightPanelState.isOpen; + const inlineRightPanelOwnsTitleBar = rightPanelOpen && !shouldUsePlanSidebarSheet; + + useEffect(() => { + if (!activeThreadRef) return; + useRightPanelStore + .getState() + .reconcileBrowserSurfaces(activeThreadRef, Object.keys(activePreviewState.sessions)); + }, [activePreviewState.sessions, activeThreadRef]); - const existingOpenTerminalThreadKeys = useMemo(() => { - const existingThreadKeys = new Set([...serverThreadKeys, ...draftThreadKeys]); - return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey)); - }, [draftThreadKeys, openTerminalThreadKeys, serverThreadKeys]); + useEffect(() => { + if (!activeThreadRef || !diffOpen) return; + useRightPanelStore.getState().open(activeThreadRef, "diff"); + }, [activeThreadRef, diffOpen]); const activeLatestTurn = activeThread?.latestTurn ?? null; const threadPlanCatalog = useThreadPlanCatalog( useMemo(() => { @@ -1087,21 +1347,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]); const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); const activeProjectRef = activeThread ? scopeProjectRef(activeThread.environmentId, activeThread.projectId) @@ -1109,6 +1354,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") { @@ -1667,8 +1916,64 @@ export default function ChatView(props: ChatViewProps) { }); }, []); const serverMessages = activeThread?.messages; + const [attachmentAssetUrlById, setAttachmentAssetUrlById] = useState>({}); + useEffect(() => { + 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" || !serverMessages || serverMessages.length === 0) { + if ( + typeof Image === "undefined" || + !serverMessagesWithAssetUrls || + serverMessagesWithAssetUrls.length === 0 + ) { return; } @@ -1681,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) { @@ -1747,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 @@ -1800,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), @@ -1896,23 +2205,15 @@ 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], ); + const splitTerminalVerticalShortcutLabel = useMemo( + () => + shortcutLabelForCommand(keybindings, "terminal.splitVertical", terminalShortcutLabelOptions), + [keybindings, terminalShortcutLabelOptions], + ); const newTerminalShortcutLabel = useMemo( () => shortcutLabelForCommand(keybindings, "terminal.new", terminalShortcutLabelOptions), [keybindings, terminalShortcutLabelOptions], @@ -1921,16 +2222,88 @@ export default function ChatView(props: ChatViewProps) { () => shortcutLabelForCommand(keybindings, "terminal.close", terminalShortcutLabelOptions), [keybindings, terminalShortcutLabelOptions], ); - const diffPanelShortcutLabel = useMemo( - () => shortcutLabelForCommand(keybindings, "diff.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 + // openness; when both fight, `selectActiveRightPanelKindWithUrl` lets + // diff win (URL is truth). + // - The two toggles below treat the panels as mutually exclusive: opening + // one always tears down the other in BOTH the URL and the store. Without + // this, e.g. clicking the browser button while diff is URL-pinned would + // just toggle the (overridden) store value and look like a no-op. + const onTogglePreview = useCallback(() => { + if (!activeThreadRef) return; + if (previewPanelOpen) { + useRightPanelStore.getState().close(activeThreadRef); + return; + } + if (diffOpen) { + void navigate({ + to: "/$environmentId/$threadId", + params: { environmentId, threadId }, + replace: true, + search: (previous) => ({ ...stripDiffSearchParams(previous), diff: undefined }), + }); + } + 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) { + const diffPanelOpen = activeRightPanelKind === "diff"; + if (!diffPanelOpen) { onDiffPanelOpen?.(); + if (activeThreadRef) { + useRightPanelStore.getState().open(activeThreadRef, "diff"); + } + } else if (activeThreadRef) { + useRightPanelStore.getState().closeSurface(activeThreadRef, "diff"); } void navigate({ to: "/$environmentId/$threadId", @@ -1941,10 +2314,28 @@ 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" }; }, }); - }, [diffOpen, environmentId, isServerThread, navigate, onDiffPanelOpen, threadId]); + }, [ + activeRightPanelKind, + activeThreadRef, + environmentId, + isServerThread, + navigate, + onDiffPanelOpen, + threadId, + ]); + + // Route the global mod+shift+J shortcut (dispatched from `routes/_chat.tsx` + // via `previewActionBus`) through the URL-aware toggle defined above. + useEffect(() => { + return subscribePreviewAction((action) => { + if (action !== "toggle-panel") return; + if (!isPreviewSupportedInRuntime()) return; + onTogglePreview(); + }); + }, [onTogglePreview]); const envLocked = Boolean( activeThread && @@ -2023,56 +2414,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; - setTerminalOpen(!terminalUiState.terminalOpen); - }, [activeThreadRef, setTerminalOpen, terminalUiState.terminalOpen]); - 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; @@ -2223,6 +2742,23 @@ export default function ChatView(props: ChatViewProps) { terminalId: targetTerminalId, data: `${script.command}\r`, }); + if ( + script.autoOpenPreview && + script.previewUrl && + isPreviewSupportedInRuntime() && + activeThreadRef + ) { + try { + 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. + } + } } catch (error) { setThreadError( activeThreadId, @@ -2295,6 +2831,8 @@ export default function ChatView(props: ChatViewProps) { command: input.command, icon: input.icon, runOnWorktreeCreate: input.runOnWorktreeCreate, + ...(input.previewUrl ? { previewUrl: input.previewUrl } : {}), + ...(input.autoOpenPreview ? { autoOpenPreview: input.autoOpenPreview } : {}), }; const nextScripts = input.runOnWorktreeCreate ? [ @@ -2330,6 +2868,10 @@ export default function ChatView(props: ChatViewProps) { command: input.command, icon: input.icon, runOnWorktreeCreate: input.runOnWorktreeCreate, + ...(input.previewUrl ? { previewUrl: input.previewUrl } : { previewUrl: undefined }), + ...(input.autoOpenPreview + ? { autoOpenPreview: input.autoOpenPreview } + : { autoOpenPreview: undefined }), }; const nextScripts = activeProject.scripts.map((script) => script.id === scriptId @@ -2424,22 +2966,99 @@ export default function ChatView(props: ChatViewProps) { handleInteractionModeChange(interactionMode === "plan" ? "default" : "plan"); }, [handleInteractionModeChange, interactionMode]); const togglePlanSidebar = useCallback(() => { - setPlanSidebarOpen((open) => { - if (open) { - planSidebarDismissedForTurnRef.current = - activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__"; - } else { - planSidebarDismissedForTurnRef.current = null; - } - return !open; - }); - }, [activePlan?.turnId, sidebarProposedPlan?.turnId]); + if (!activeThreadRef) return; + if (planSidebarOpen) { + planSidebarDismissedForTurnRef.current = + activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__"; + } else { + planSidebarDismissedForTurnRef.current = null; + } + useRightPanelStore.getState().toggle(activeThreadRef, "plan"); + }, [activePlan?.turnId, activeThreadRef, planSidebarOpen, sidebarProposedPlan?.turnId]); const closePlanSidebar = useCallback(() => { - setPlanSidebarOpen(false); + if (!activeThreadRef) return; + useRightPanelStore.getState().close(activeThreadRef); planSidebarDismissedForTurnRef.current = activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__"; - }, [activePlan?.turnId, sidebarProposedPlan?.turnId]); - + }, [activePlan?.turnId, activeThreadRef, sidebarProposedPlan?.turnId]); + const closePreviewPanel = useCallback(() => { + if (!activeThreadRef) return; + useRightPanelStore.getState().close(activeThreadRef); + }, [activeThreadRef]); + 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") { + setTerminalFocusRequestId((value) => value + 1); + } + 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, threadId], + ); + const toggleRightPanel = useCallback(() => { + if (!activeThreadRef) return; + useRightPanelStore.getState().toggleVisibility(activeThreadRef); + }, [activeThreadRef]); + const closeRightPanelSurface = useCallback( + (surface: RightPanelSurface) => { + if (!activeThreadRef) return; + 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") { + 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({ + to: "/$environmentId/$threadId", + params: { environmentId, threadId }, + replace: true, + search: (previous) => ({ ...stripDiffSearchParams(previous), diff: undefined }), + }); + } + }, + [activeThreadRef, diffOpen, environmentId, navigate, threadId], + ); const persistThreadSettingsForNextTurn = useCallback( async (input: { threadId: ThreadId; @@ -2523,12 +3142,12 @@ export default function ChatView(props: ChatViewProps) { setShowScrollToBottom(false); if (planSidebarOpenOnNextThreadRef.current) { planSidebarOpenOnNextThreadRef.current = false; - setPlanSidebarOpen(true); - } else { - planSidebarOpenOnNextThreadRef.current = false; - setPlanSidebarOpen(false); + if (activeThreadRef) { + useRightPanelStore.getState().open(activeThreadRef, "plan"); + } } planSidebarDismissedForTurnRef.current = null; + // eslint-disable-next-line react-hooks/exhaustive-deps -- activeThreadRef is reset transitively }, [activeThread?.id]); // Auto-open the plan sidebar when plan/todo steps arrive for the current turn. @@ -2541,10 +3160,13 @@ export default function ChatView(props: ChatViewProps) { if (latestTurnId && activePlan.turnId !== latestTurnId) return; const turnKey = activePlan.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__"; if (planSidebarDismissedForTurnRef.current === turnKey) return; - setPlanSidebarOpen(true); + if (activeThreadRef) { + useRightPanelStore.getState().open(activeThreadRef, "plan"); + } }, [ activePlan, activeLatestTurn?.turnId, + activeThreadRef, autoOpenPlanSidebar, planSidebarOpen, sidebarProposedPlan?.turnId, @@ -2715,8 +3337,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, }; @@ -2745,9 +3368,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); } @@ -2755,9 +3389,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; @@ -2766,6 +3418,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); } @@ -2798,17 +3454,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, ]); @@ -2893,6 +3554,7 @@ export default function ChatView(props: ChatViewProps) { const { images: composerImages, terminalContexts: composerTerminalContexts, + elementContexts: composerElementContexts, selectedProvider: ctxSelectedProvider, selectedModel: ctxSelectedModel, selectedProviderModels: ctxSelectedProviderModels, @@ -2909,6 +3571,7 @@ export default function ChatView(props: ChatViewProps) { prompt: promptForSend, imageCount: composerImages.length, terminalContexts: composerTerminalContexts, + elementContextCount: composerElementContexts.length, }); if (showPlanFollowUpPrompt && activeProposedPlan) { const followUp = resolvePlanFollowUpSubmission({ @@ -2925,7 +3588,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) { @@ -2973,9 +3638,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(); @@ -3056,6 +3722,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"; } @@ -3141,7 +3809,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); @@ -3155,9 +3824,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, @@ -3462,7 +4133,9 @@ export default function ChatView(props: ChatViewProps) { // step-tracking activities that the sidebar will display. if (nextInteractionMode === "default" && autoOpenPlanSidebar) { planSidebarDismissedForTurnRef.current = null; - setPlanSidebarOpen(true); + if (activeThreadRef) { + useRightPanelStore.getState().open(activeThreadRef, "plan"); + } } sendInFlightRef.current = false; } catch (err) { @@ -3796,277 +4469,370 @@ export default function ChatView(props: ChatViewProps) { } return ( -
- {/* Top bar */} -
- + {isElectron && activeThreadRef ? ( + + ) : null} +
+ {/* Top bar */} +
+ +
+ + {/* Error banner */} + + setThreadError(activeThread.id, null)} /> -
+ {/* Main content area with optional plan sidebar */} +
+ {/* Chat column */} +
+ {/* Messages Wrapper */} +
+ {/* Messages — LegendList handles virtualization and scrolling internally */} + - {/* Error banner */} - - setThreadError(activeThread.id, null)} - /> - {/* Main content area with optional plan sidebar */} -
- {/* Chat column */} -
- {/* Messages Wrapper */} -
- {/* Messages — LegendList handles virtualization and scrolling internally */} - + {/* scroll to bottom pill — shown when user has scrolled away from the bottom */} + {showScrollToBottom && ( +
+ +
+ )} +
- {/* scroll to bottom pill — shown when user has scrolled away from the bottom */} - {showScrollToBottom && ( -
- + {/* Input bar */} +
+
+ +
+ +
- )} -
- - {/* Input bar */} -
-
- -
- -
+ )}
- {isGitRepo && ( - { + if (!open) { + closePullRequestDialog(); + } + }} + onPrepared={handlePreparedPullRequestThread} /> - )} + ) : null}
- - {pullRequestDialogState ? ( - { - if (!open) { - closePullRequestDialog(); - } - }} - onPrepared={handlePreparedPullRequestThread} - /> - ) : null} + {/* end chat column */}
- {/* end chat column */} - - {/* Plan sidebar */} - {planSidebarOpen && !shouldUsePlanSidebarSheet ? ( - ) : null}
- {/* end horizontal flex container */} - - {mountedTerminalThreadRefs.map(({ key: mountedThreadKey, threadRef: mountedThreadRef }) => ( - - ))} - {shouldUsePlanSidebarSheet ? ( - - + {activeRightPanelSurface?.kind === "preview" ? ( + + + + ) : activeRightPanelSurface?.kind === "terminal" ? ( + + ) : activeRightPanelSurface?.kind === "diff" ? ( + + + + + + ) : null} + + ) : null} + + {shouldUsePlanSidebarSheet && rightPanelOpen && activeThreadRef ? ( + + + surfaces={rightPanelState.surfaces} + 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 === "terminal" ? ( + + ) : activeRightPanelSurface?.kind === "diff" ? ( + + + + + + ) : activeRightPanelSurface?.kind === "plan" ? ( + + ) : null} + ) : 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 (
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 62bd1ba89db..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,10 +56,11 @@ interface PlanSidebarProps { activeProposedPlan: LatestProposedPlanState | null; label?: string; environmentId: EnvironmentId; + threadRef?: ScopedThreadRef | undefined; markdownCwd: string | undefined; workspaceRoot: string | undefined; timestampFormat: TimestampFormat; - mode?: "sheet" | "sidebar"; + mode?: "sheet" | "sidebar" | "embedded"; onClose: () => void; } @@ -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({
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(); @@ -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/ProjectScriptsControl.tsx b/apps/web/src/components/ProjectScriptsControl.tsx index 4588ac51bdd..a9c218c0c9e 100644 --- a/apps/web/src/components/ProjectScriptsControl.tsx +++ b/apps/web/src/components/ProjectScriptsControl.tsx @@ -85,6 +85,10 @@ export interface NewProjectScriptInput { icon: ProjectScriptIcon; runOnWorktreeCreate: boolean; keybinding: string | null; + /** Optional URL to open in the in-app preview when this script runs. */ + previewUrl: string | null; + /** When true, automatically open the preview panel pointed at `previewUrl`. */ + autoOpenPreview: boolean; } interface ProjectScriptsControlProps { @@ -115,6 +119,8 @@ export default function ProjectScriptsControl({ const [iconPickerOpen, setIconPickerOpen] = useState(false); const [runOnWorktreeCreate, setRunOnWorktreeCreate] = useState(false); const [keybinding, setKeybinding] = useState(""); + const [previewUrl, setPreviewUrl] = useState(""); + const [autoOpenPreview, setAutoOpenPreview] = useState(false); const [validationError, setValidationError] = useState(null); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); @@ -166,12 +172,15 @@ export default function ProjectScriptsControl({ keybinding, command: commandForProjectScript(scriptIdForValidation), }); + const trimmedPreviewUrl = previewUrl.trim(); const payload = { name: trimmedName, command: trimmedCommand, icon, runOnWorktreeCreate, keybinding: keybindingRule?.key ?? null, + previewUrl: trimmedPreviewUrl.length > 0 ? trimmedPreviewUrl : null, + autoOpenPreview: trimmedPreviewUrl.length > 0 ? autoOpenPreview : false, } satisfies NewProjectScriptInput; if (editingScriptId) { await onUpdateScript(editingScriptId, payload); @@ -193,6 +202,8 @@ export default function ProjectScriptsControl({ setIconPickerOpen(false); setRunOnWorktreeCreate(false); setKeybinding(""); + setPreviewUrl(""); + setAutoOpenPreview(false); setValidationError(null); setDialogOpen(true); }; @@ -205,6 +216,8 @@ export default function ProjectScriptsControl({ setIconPickerOpen(false); setRunOnWorktreeCreate(script.runOnWorktreeCreate); setKeybinding(keybindingValueForCommand(keybindings, commandForProjectScript(script.id)) ?? ""); + setPreviewUrl(script.previewUrl ?? ""); + setAutoOpenPreview(script.autoOpenPreview ?? false); setValidationError(null); setDialogOpen(true); }; @@ -327,6 +340,8 @@ export default function ProjectScriptsControl({ setIcon("play"); setRunOnWorktreeCreate(false); setKeybinding(""); + setPreviewUrl(""); + setAutoOpenPreview(false); setValidationError(null); }} open={dialogOpen} @@ -413,6 +428,18 @@ export default function ProjectScriptsControl({ onChange={(event) => setCommand(event.target.value)} />
+
+ + setPreviewUrl(event.target.value)} + /> +

+ Open this URL in the in-app preview when this action runs. +

+
+ {validationError &&

{validationError}

} diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx new file mode 100644 index 00000000000..b4d5c9cbb45 --- /dev/null +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -0,0 +1,254 @@ +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"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; +import { faviconUrlForOrigin } from "~/lib/favicon"; + +import { PreviewPanelShell, type PreviewPanelMode } from "./preview/PreviewPanelShell"; + +interface RightPanelTabsProps { + mode: PreviewPanelMode; + surfaces: readonly RightPanelSurface[]; + activeSurfaceId: string | null; + previewSessions: Readonly>; + terminalLabelsById: ReadonlyMap; + 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 ( +
+
+
+

Open a surface

+

+ Choose what to show in the right panel. +

+
+
+ {actions.map((action) => { + const Icon = action.icon; + return ( + + ); + })} +
+
+
+ ); +} + +function surfaceTitle( + surface: RightPanelSurface, + sessions: Readonly>, + terminalLabelsById: ReadonlyMap, +): string { + switch (surface.kind) { + case "diff": + return "Diff"; + case "terminal": + return ( + terminalLabelsById.get(surface.activeTerminalId) ?? + getTerminalLabel(surface.activeTerminalId) + ); + 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(null); + if (!faviconUrl || failedUrl === faviconUrl) return ; + return ( + setFailedUrl(faviconUrl)} + /> + ); +} + +function SurfaceIcon({ + surface, + sessions, +}: { + surface: RightPanelSurface; + sessions: Readonly>; +}) { + 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 ; + } + case "diff": + return ; + case "terminal": + return ; + case "plan": + return ; + } +} + +export function RightPanelTabs(props: RightPanelTabsProps) { + const ownsDesktopTitleBar = isElectron && props.mode === "inline"; + + return ( + +
+
+ {props.surfaces.map((surface) => { + const active = surface.id === props.activeSurfaceId; + const title = surfaceTitle(surface, props.previewSessions, props.terminalLabelsById); + return ( +
+ + props.onActivate(surface)} + > + + {title} + + } + /> + {title} + + +
+ ); + })} +
+ + + + + + + + Browser + + + + Terminal + + + + Diff + + + +
+
+ {props.activeSurfaceId === null ? ( + + ) : ( + props.children + )} +
+
+ ); +} 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 = { 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) => { + 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 )}
+ {discoveredPorts.length > 0 && ( + + + } + > + + + + Open localhost:{discoveredPorts[0]?.port} + {discoveredPorts.length > 1 ? ` (+${discoveredPorts.length - 1})` : ""} + + + )} {terminalStatus && ( ({ })); 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, })); diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index b740a9ed7cf..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, @@ -20,6 +28,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 { @@ -36,6 +45,7 @@ import { isTerminalCloseShortcut, isTerminalNewShortcut, isTerminalSplitShortcut, + isTerminalSplitVerticalShortcut, isTerminalToggleShortcut, terminalDeleteShortcutData, terminalNavigationShortcutData, @@ -48,6 +58,9 @@ import { 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; @@ -435,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) @@ -509,11 +523,26 @@ export function TerminalViewport({ } if (match.kind === "url") { - void localApi.shell.openExternal(match.text).catch((error: unknown) => { - writeSystemMessage( - latestTerminal, - error instanceof Error ? error.message : "Unable to open link", - ); + const fallbackToBrowser = () => { + void localApi.shell.openExternal(match.text).catch((error: unknown) => { + writeSystemMessage( + latestTerminal, + error instanceof Error ? error.message : "Unable to open link", + ); + }); + }; + const api = readEnvironmentApi(threadRef.environmentId); + if (!api) { + fallbackToBrowser(); + return; + } + void openTerminalLinkInPreview({ + url: match.text, + position: { x: event.clientX, y: event.clientY }, + threadRef, + api, + localApi, + fallbackToBrowser, }); return; } @@ -706,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(); @@ -775,6 +836,7 @@ export function TerminalViewport({ } interface ThreadTerminalDrawerProps { + mode?: "drawer" | "panel"; threadRef: ScopedThreadRef; threadId: ThreadId; cwd: string; @@ -788,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; @@ -833,6 +897,7 @@ function TerminalActionButton({ label, className, onClick, children }: TerminalA } export default function ThreadTerminalDrawer({ + mode = "drawer", threadRef, threadId, cwd, @@ -846,8 +911,10 @@ export default function ThreadTerminalDrawer({ activeTerminalGroupId, focusRequestId, onSplitTerminal, + onSplitTerminalVertical, onNewTerminal, splitShortcutLabel, + splitVerticalShortcutLabel, newShortcutLabel, closeShortcutLabel, onActiveTerminalChange, @@ -858,6 +925,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); @@ -937,6 +1005,9 @@ export default function ThreadTerminalDrawer({ nextGroups.push({ id: assignUniqueGroupId(baseGroupId), terminalIds: nextTerminalIds, + ...(terminalGroup.splitDirection === "vertical" + ? { splitDirection: "vertical" as const } + : {}), }); } @@ -974,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 = @@ -987,6 +1060,17 @@ export default function ThreadTerminalDrawer({ } return next; }, [normalizedTerminalIds, terminalLabelsById]); + const discoveredPorts = useDiscoveredPorts(threadRef.environmentId); + const discoveredPortByTerminalId = useMemo(() => { + const next = new Map(); + 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 ( @@ -1000,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"; @@ -1014,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]); @@ -1123,16 +1216,22 @@ export default function ThreadTerminalDrawer({ if (normalizedTerminalIds.length === 0) { return (