From 17fb0e4a9a068f3ba6c55871dd44ed3c5d70aefe Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Sun, 3 May 2026 19:56:51 -0700 Subject: [PATCH] feat(preview): element-pick attachments + sandboxed picker preload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an in-page element picker to the preview browser. Clicking the crosshair button in the chrome row activates a blue-highlight picker inside the guest webview; clicking an element captures its component name (via react-grab), source location, html/css preview, and selector, then attaches it to the chat composer as a chip that serializes into an `` block in the outgoing message. Architecture: - Per-`` preload bundle (`preview-pick-preload.cjs`) renders the overlay, hosts the picker event loop, and bubbles the picked payload back to main via the per-WebContents `wc.ipc` channel (not `sendToHost`, which only fires on the host renderer's element and never reaches main). - Main coordinates via `PreviewViewManager.pickElement(tabId)`, which cancels any in-flight session, force-focuses the guest (so the first click on a remote page actually reaches the preload), then awaits the payload. User-initiated cancels (Escape, beforeunload) echo `null` back to main; main-initiated cancels and supersession tear down silently to avoid the new-pick-resolves-with-stale-null race. - Renderer fetches partition + webPreferences + preload URL in a single `getPreviewConfig()` IPC call, snapshots the previously-focused host element before triggering a pick, and restores focus when the pick resolves so the user's textarea cursor isn't lost. Security posture for the guest webview: - `webpreferences="contextIsolation=false,sandbox=true,nodeIntegration=false"` centralized in `preview-webview-preferences.ts`. contextIsolation off is required so react-grab's `getElementContext` can reach the page's React DevTools hook on `globalThis`. sandbox stays on so the page cannot reach Node APIs even with shared globals (without it, the preload's `require` would land on the page's `globalThis` and any third-party site could send arbitrary IPC to main). - Defense in depth: a `will-attach-webview` handler in main, gated on the preview partition, force-pins `sandbox: true`, all `nodeIntegration*: false`, and the absolute preload PATH (not URL — that field rejects file:// URLs with "preload script must have absolute path" and silently disables the picker). Composer + transcript integration: - New `elementContexts` slice in `composerDraftStore` (mirrors the terminal-context slice: dedup by selector+tag+component+url, persist via partializer, restore on send-failure retry). - `ComposerPendingElementContexts` chip row above the editor. - `deriveDisplayedUserMessageState` now strips both `` AND `` blocks (element first, since it's appended last) and exposes element entries to `MessagesTimeline`, which renders them as compact chips beneath the message body. - Pick button is disabled with explanatory tooltip when the page failed to load (the React `` overlay covers the webview, so picks would silently dangle otherwise). Tests added: - `preview-webview-preferences.test.ts` locks down the security flags (contextIsolation=false, sandbox=true, nodeIntegration=false, no whitespace, only true/false literal values). - `preview-pick-label-position.test.ts` covers the floating-label clamp/flip math (no off-screen overflow, flip-below when no room above, etc.). - `picked-element-payload.test.ts` validator coverage. - `elementContext.test.ts` for the serialization round-trip, normalization, dedup, and label formatting. - `composerDraftStore.test.ts` element-contexts slice (add, dedup, remove, set, clear, persistence round-trip). - `ChatView.logic.test.ts` sendable-content-with-element-only. Build: new `tsdown` entry inlines react-grab + bippy into the picker preload bundle (~59KB / 19KB gzipped). --- apps/desktop/package.json | 3 +- apps/desktop/src/ipc/channels.ts | 4 +- apps/desktop/src/ipc/methods/preview.ts | 19 +- apps/desktop/src/main.ts | 2 + .../src/picked-element-payload.test.ts | 135 ++++++ apps/desktop/src/picked-element-payload.ts | 49 ++ apps/desktop/src/preload.ts | 11 +- .../src/preview-pick-label-position.ts | 46 ++ apps/desktop/src/preview-pick-preload.test.ts | 86 ++++ apps/desktop/src/preview-pick-preload.ts | 419 ++++++++++++++++++ apps/desktop/src/preview-view-manager.ts | 124 ++++++ .../src/preview-webview-preferences.test.ts | 78 ++++ .../src/preview-webview-preferences.ts | 42 ++ apps/desktop/src/window/DesktopWindow.ts | 10 + apps/desktop/vite.config.ts | 10 + .../web/src/components/ChatView.logic.test.ts | 24 + apps/web/src/components/ChatView.logic.ts | 12 +- apps/web/src/components/ChatView.tsx | 30 +- apps/web/src/components/chat/ChatComposer.tsx | 45 +- .../CompactComposerControlsMenu.browser.tsx | 12 +- .../chat/ComposerPendingElementContexts.tsx | 95 ++++ .../src/components/chat/MessagesTimeline.tsx | 51 ++- .../components/preview/PreviewChromeRow.tsx | 49 +- .../src/components/preview/PreviewView.tsx | 87 +++- .../src/components/preview/PreviewWebview.tsx | 46 +- apps/web/src/composerDraftStore.test.ts | 93 ++++ apps/web/src/composerDraftStore.ts | 245 +++++++++- apps/web/src/lib/elementContext.test.ts | 294 ++++++++++++ apps/web/src/lib/elementContext.ts | 245 ++++++++++ apps/web/src/lib/terminalContext.test.ts | 1 + apps/web/src/lib/terminalContext.ts | 23 +- packages/contracts/src/ipc.ts | 80 +++- 32 files changed, 2416 insertions(+), 54 deletions(-) create mode 100644 apps/desktop/src/picked-element-payload.test.ts create mode 100644 apps/desktop/src/picked-element-payload.ts create mode 100644 apps/desktop/src/preview-pick-label-position.ts create mode 100644 apps/desktop/src/preview-pick-preload.test.ts create mode 100644 apps/desktop/src/preview-pick-preload.ts create mode 100644 apps/desktop/src/preview-webview-preferences.test.ts create mode 100644 apps/desktop/src/preview-webview-preferences.ts create mode 100644 apps/web/src/components/chat/ComposerPendingElementContexts.tsx create mode 100644 apps/web/src/lib/elementContext.test.ts create mode 100644 apps/web/src/lib/elementContext.ts diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 339f79637022a6294b37dc2c34eaa7d069b34a8e..f26f2cd1ae4e8c05fec81a63ebfc650444ffd173 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -20,7 +20,8 @@ "@t3tools/tailscale": "workspace:*", "effect": "catalog:", "electron": "41.5.0", - "electron-updater": "^6.6.2" + "electron-updater": "^6.6.2", + "react-grab": "^0.1.32" }, "devDependencies": { "@effect/vitest": "catalog:", diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 7502831c07a06e5bb7f41925d4a3572a63f80807..9c11859f236e03d20c50bde3250f235575f130e0 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -53,5 +53,7 @@ export const PREVIEW_HARD_RELOAD_CHANNEL = "desktop:preview-hard-reload"; export const PREVIEW_OPEN_DEVTOOLS_CHANNEL = "desktop:preview-open-devtools"; export const PREVIEW_CLEAR_COOKIES_CHANNEL = "desktop:preview-clear-cookies"; export const PREVIEW_CLEAR_CACHE_CHANNEL = "desktop:preview-clear-cache"; -export const PREVIEW_GET_BROWSER_PARTITION_CHANNEL = "desktop:preview-get-browser-partition"; +export const PREVIEW_GET_CONFIG_CHANNEL = "desktop:preview-get-config"; +export const PREVIEW_PICK_ELEMENT_CHANNEL = "desktop:preview-pick-element"; +export const PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL = "desktop:preview-cancel-pick-element"; export const PREVIEW_STATE_CHANGE_CHANNEL = "desktop:preview-state-change"; diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index 1b00eacdf8683e1bedd3366b36e105d49b777de5..9b80339a2e2c9a2b9fb951b7057f27382348a85a 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -1,7 +1,11 @@ import * as Effect from "effect/Effect"; import { BrowserWindow } from "electron"; +import * as NodeFileSystem from "node:fs"; +import * as NodePath from "node:path"; +import { pathToFileURL } from "node:url"; import { previewViewManager } from "../../preview-view-manager.ts"; +import { PREVIEW_WEBVIEW_PREFERENCES } from "../../preview-webview-preferences.ts"; import * as IpcChannels from "../channels.ts"; import type { DesktopIpcMethod } from "../DesktopIpc.ts"; @@ -57,5 +61,18 @@ export const previewMethods = [ method(IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, (raw) => previewViewManager.openDevTools(tabIdFrom(raw))), method(IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL, () => previewViewManager.clearCookies()), method(IpcChannels.PREVIEW_CLEAR_CACHE_CHANNEL, () => previewViewManager.clearCache()), - method(IpcChannels.PREVIEW_GET_BROWSER_PARTITION_CHANNEL, () => previewViewManager.getBrowserPartition()), + method(IpcChannels.PREVIEW_GET_CONFIG_CHANNEL, () => { + const preloadPath = NodePath.join(__dirname, "preview-pick-preload.cjs"); + return { + partition: previewViewManager.getBrowserPartition(), + webPreferences: PREVIEW_WEBVIEW_PREFERENCES, + preloadUrl: NodeFileSystem.existsSync(preloadPath) ? pathToFileURL(preloadPath).href : null, + }; + }), + method(IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL, (raw) => + previewViewManager.pickElement(tabIdFrom(raw)), + ), + method(IpcChannels.PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL, (raw) => + previewViewManager.cancelPickElement(tabIdFrom(raw)), + ), ] as const; diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 9356eef441b642ae97be47447025cf9292cc382b..74ffcba1a165fc677e8774209b3ce8de2d0b1725 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -28,6 +28,7 @@ import * as DesktopApp from "./app/DesktopApp.ts"; import * as DesktopAppIdentity from "./app/DesktopAppIdentity.ts"; import * as DesktopCloudAuth from "./app/DesktopCloudAuth.ts"; import * as DesktopCloudAuthTokenStore from "./app/DesktopCloudAuthTokenStore.ts"; +import * as DesktopConnectionCatalogStore from "./app/DesktopConnectionCatalogStore.ts"; import * as DesktopApplicationMenu from "./window/DesktopApplicationMenu.ts"; import * as DesktopAssets from "./app/DesktopAssets.ts"; import * as DesktopBackendConfiguration from "./backend/DesktopBackendConfiguration.ts"; @@ -114,6 +115,7 @@ const desktopFoundationLayer = Layer.mergeAll( DesktopClientSettings.layer, DesktopSavedEnvironments.layer, DesktopCloudAuthTokenStore.layer, + DesktopConnectionCatalogStore.layer, DesktopAssets.layer, DesktopObservability.layer, ).pipe(Layer.provideMerge(desktopEnvironmentLayer)); diff --git a/apps/desktop/src/picked-element-payload.test.ts b/apps/desktop/src/picked-element-payload.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..0b6d23b6acd7a03278a03e751b12006b62fec675 --- /dev/null +++ b/apps/desktop/src/picked-element-payload.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from "vitest"; + +import { isPickedElementPayload } from "./picked-element-payload.ts"; + +function validPayload(overrides?: Record): Record { + return { + pageUrl: "https://example.com/", + pageTitle: "Example", + tagName: "button", + selector: "button.submit", + htmlPreview: "", + componentName: "SubmitButton", + source: { + functionName: "SubmitButton", + fileName: "/repo/src/Button.tsx", + lineNumber: 12, + columnNumber: 5, + }, + stack: [ + { + functionName: "SubmitButton", + fileName: "/repo/src/Button.tsx", + lineNumber: 12, + columnNumber: 5, + }, + ], + styles: ".submit { color: white; }", + pickedAt: "2026-05-03T18:00:00.000Z", + ...overrides, + }; +} + +describe("isPickedElementPayload", () => { + it("accepts a complete, well-typed payload", () => { + expect(isPickedElementPayload(validPayload())).toBe(true); + }); + + it("accepts nullable string fields when null", () => { + expect( + isPickedElementPayload( + validPayload({ pageTitle: null, selector: null, componentName: null, source: null }), + ), + ).toBe(true); + }); + + it("accepts an empty stack array", () => { + expect(isPickedElementPayload(validPayload({ stack: [] }))).toBe(true); + }); + + it("accepts stack frames with null fields", () => { + expect( + isPickedElementPayload( + validPayload({ + stack: [ + { + functionName: null, + fileName: null, + lineNumber: null, + columnNumber: null, + }, + ], + }), + ), + ).toBe(true); + }); + + it("rejects null and primitive inputs", () => { + expect(isPickedElementPayload(null)).toBe(false); + expect(isPickedElementPayload(undefined)).toBe(false); + expect(isPickedElementPayload("string")).toBe(false); + expect(isPickedElementPayload(42)).toBe(false); + expect(isPickedElementPayload([])).toBe(false); + }); + + it.each<[string, Record]>([ + ["missing pageUrl", validPayload({ pageUrl: undefined })], + ["wrong-type pageUrl", validPayload({ pageUrl: 123 })], + ["missing tagName", validPayload({ tagName: undefined })], + ["missing htmlPreview", validPayload({ htmlPreview: undefined })], + ["missing styles", validPayload({ styles: undefined })], + ["missing pickedAt", validPayload({ pickedAt: undefined })], + ["wrong-type pageTitle", validPayload({ pageTitle: 99 })], + ["wrong-type selector", validPayload({ selector: 99 })], + ["wrong-type componentName", validPayload({ componentName: 99 })], + ])("rejects payloads with %s", (_label, value) => { + expect(isPickedElementPayload(value)).toBe(false); + }); + + it("rejects malformed source frames", () => { + expect( + isPickedElementPayload( + validPayload({ + source: { + functionName: 0, + fileName: null, + lineNumber: null, + columnNumber: null, + }, + }), + ), + ).toBe(false); + }); + + it("rejects non-finite numeric line/column numbers", () => { + expect( + isPickedElementPayload( + validPayload({ + source: { + functionName: null, + fileName: null, + lineNumber: Number.POSITIVE_INFINITY, + columnNumber: null, + }, + }), + ), + ).toBe(false); + expect( + isPickedElementPayload( + validPayload({ + source: { + functionName: null, + fileName: null, + lineNumber: Number.NaN, + columnNumber: null, + }, + }), + ), + ).toBe(false); + }); + + it("rejects malformed stack arrays", () => { + expect(isPickedElementPayload(validPayload({ stack: "not-an-array" }))).toBe(false); + expect(isPickedElementPayload(validPayload({ stack: [{ bogus: true }] }))).toBe(false); + }); +}); diff --git a/apps/desktop/src/picked-element-payload.ts b/apps/desktop/src/picked-element-payload.ts new file mode 100644 index 0000000000000000000000000000000000000000..1b5d4df9f5be835aaa075dc2475851cc06efdfe1 --- /dev/null +++ b/apps/desktop/src/picked-element-payload.ts @@ -0,0 +1,49 @@ +/** + * Strict structural validator for `PickedElementPayload` messages received + * from the in-page picker preload (`apps/desktop/src/preview-pick-preload.ts`) + * via `wc.ipc`. Lives in its own electron-free module so the validator is + * trivially unit-testable. + * + * Validation must be tight: downstream `normalizeElementContextSelection` + * calls `.trim()` on incoming strings, so a malformed payload (preload bug, + * future schema mismatch, malicious page that intercepts the preload's IPC + * channel via prototype pollution) would otherwise throw deep in the + * renderer and the chip silently never appears. + */ +import type { PickedElementPayload } from "@t3tools/contracts"; + +function isStringOrNull(value: unknown): value is string | null { + return value === null || typeof value === "string"; +} + +function isFiniteNumberOrNull(value: unknown): value is number | null { + return value === null || (typeof value === "number" && Number.isFinite(value)); +} + +function isPickedStackFrame(value: unknown): boolean { + if (typeof value !== "object" || value === null) return false; + const frame = value as Record; + return ( + isStringOrNull(frame["functionName"]) && + isStringOrNull(frame["fileName"]) && + isFiniteNumberOrNull(frame["lineNumber"]) && + isFiniteNumberOrNull(frame["columnNumber"]) + ); +} + +export function isPickedElementPayload(value: unknown): value is PickedElementPayload { + if (typeof value !== "object" || value === null) return false; + const c = value as Record; + if (typeof c["pageUrl"] !== "string") return false; + if (typeof c["tagName"] !== "string") return false; + if (typeof c["htmlPreview"] !== "string") return false; + if (typeof c["styles"] !== "string") return false; + if (typeof c["pickedAt"] !== "string") return false; + if (!isStringOrNull(c["pageTitle"])) return false; + if (!isStringOrNull(c["selector"])) return false; + if (!isStringOrNull(c["componentName"])) return false; + if (c["source"] !== null && !isPickedStackFrame(c["source"])) return false; + if (!Array.isArray(c["stack"])) return false; + if (!c["stack"].every(isPickedStackFrame)) return false; + return true; +} diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 86a0ed576a9d53d17b308682d212402bbd4298a8..b9deced7ffb4b007d1d2ade620fee763537056f8 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -47,6 +47,10 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.invoke(IpcChannels.SET_SAVED_ENVIRONMENT_SECRET_CHANNEL, { environmentId, secret }), removeSavedEnvironmentSecret: (environmentId) => ipcRenderer.invoke(IpcChannels.REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId), + getConnectionCatalog: () => ipcRenderer.invoke(IpcChannels.GET_CONNECTION_CATALOG_CHANNEL), + setConnectionCatalog: (catalog) => + ipcRenderer.invoke(IpcChannels.SET_CONNECTION_CATALOG_CHANNEL, catalog), + clearConnectionCatalog: () => ipcRenderer.invoke(IpcChannels.CLEAR_CONNECTION_CATALOG_CHANNEL), discoverSshHosts: () => ipcRenderer.invoke(IpcChannels.DISCOVER_SSH_HOSTS_CHANNEL), ensureSshEnvironment: async (target, options) => unwrapEnsureSshEnvironmentResult( @@ -159,8 +163,11 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.invoke(IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, { tabId }), clearCookies: () => ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL), clearCache: () => ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_CACHE_CHANNEL), - getBrowserPartition: () => - ipcRenderer.invoke(IpcChannels.PREVIEW_GET_BROWSER_PARTITION_CHANNEL), + getPreviewConfig: () => ipcRenderer.invoke(IpcChannels.PREVIEW_GET_CONFIG_CHANNEL), + pickElement: (tabId) => + ipcRenderer.invoke(IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL, { tabId }), + cancelPickElement: (tabId) => + ipcRenderer.invoke(IpcChannels.PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL, { tabId }), onStateChange: (listener) => { const wrappedListener = ( _event: Electron.IpcRendererEvent, diff --git a/apps/desktop/src/preview-pick-label-position.ts b/apps/desktop/src/preview-pick-label-position.ts new file mode 100644 index 0000000000000000000000000000000000000000..e07f64f13d5cb371d0132c32f3838de2a4ed4ec6 --- /dev/null +++ b/apps/desktop/src/preview-pick-label-position.ts @@ -0,0 +1,46 @@ +/** + * Pure clamp/flip math for the floating label that follows the cursor while + * the user is picking an element in the in-app browser. Lives in its own + * electron-free module so the geometry can be unit-tested without spinning + * up an Electron preload context (`preview-pick-preload.ts` itself imports + * `electron` and `react-grab/primitives`, which can't load under vitest). + * + * - Horizontally pins the label to `targetLeft`, clamped into + * `[VIEWPORT_MARGIN, viewportWidth - labelWidth - VIEWPORT_MARGIN]`. + * - Vertically prefers above the target. If the label would overflow the + * top, flips below; if THAT also overflows the bottom, pins to the + * bottom margin (better to overlap the highlight than disappear). + */ + +/** Distance in CSS pixels between the highlight and the floating label. */ +export const LABEL_GAP = 4; +/** Minimum padding the label keeps from any viewport edge. */ +export const VIEWPORT_MARGIN = 4; + +export function computeLabelPosition(input: { + targetLeft: number; + targetTop: number; + targetBottom: number; + labelWidth: number; + labelHeight: number; + viewportWidth: number; + viewportHeight: number; +}): { x: number; y: number } { + const { targetLeft, targetTop, targetBottom, labelWidth, labelHeight } = input; + const { viewportWidth, viewportHeight } = input; + + let x = targetLeft; + const maxX = viewportWidth - labelWidth - VIEWPORT_MARGIN; + if (x > maxX) x = maxX; + if (x < VIEWPORT_MARGIN) x = VIEWPORT_MARGIN; + + let y = targetTop - labelHeight - LABEL_GAP; + if (y < VIEWPORT_MARGIN) { + y = targetBottom + LABEL_GAP; + if (y + labelHeight > viewportHeight - VIEWPORT_MARGIN) { + y = Math.max(VIEWPORT_MARGIN, viewportHeight - labelHeight - VIEWPORT_MARGIN); + } + } + + return { x, y }; +} diff --git a/apps/desktop/src/preview-pick-preload.test.ts b/apps/desktop/src/preview-pick-preload.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..dbb6eb964aa922eeccfac19ff70fc9178e427cab --- /dev/null +++ b/apps/desktop/src/preview-pick-preload.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; + +import { computeLabelPosition } from "./preview-pick-label-position.ts"; + +const VIEWPORT = { viewportWidth: 1280, viewportHeight: 800 }; + +describe("computeLabelPosition", () => { + it("anchors to the element's top-left when there's room above and to the right", () => { + const { x, y } = computeLabelPosition({ + ...VIEWPORT, + targetLeft: 200, + targetTop: 200, + targetBottom: 240, + labelWidth: 120, + labelHeight: 18, + }); + expect(x).toBe(200); + // 200 (top) - 18 (height) - 4 (gap) + expect(y).toBe(200 - 18 - 4); + }); + + it("clamps left edge so the label stays inside the viewport", () => { + const { x } = computeLabelPosition({ + ...VIEWPORT, + targetLeft: -50, + targetTop: 200, + targetBottom: 240, + labelWidth: 120, + labelHeight: 18, + }); + expect(x).toBe(4); + }); + + it("clamps right edge when the label would overflow the viewport (the bug we shipped)", () => { + const { x } = computeLabelPosition({ + ...VIEWPORT, + targetLeft: 1240, + targetTop: 200, + targetBottom: 240, + labelWidth: 200, + labelHeight: 18, + }); + // viewportWidth (1280) - labelWidth (200) - margin (4) = 1076 + expect(x).toBe(1076); + }); + + it("flips the label below the element when there's no room above", () => { + const { y } = computeLabelPosition({ + ...VIEWPORT, + targetLeft: 200, + targetTop: 4, + targetBottom: 44, + labelWidth: 120, + labelHeight: 18, + }); + // labelY = 4 - 18 - 4 = -18 → flip → 44 + 4 = 48 + expect(y).toBe(48); + }); + + it("pins to the bottom margin when the element fills the viewport (no room above OR below)", () => { + const { y } = computeLabelPosition({ + ...VIEWPORT, + targetLeft: 200, + targetTop: 0, + targetBottom: 800, + labelWidth: 120, + labelHeight: 18, + }); + // Above overflows top → flip below = 800 + 4 = 804 → also overflows + // bottom → pin to viewportHeight - labelHeight - margin = 778. + expect(y).toBe(800 - 18 - 4); + }); + + it("never returns a negative coordinate", () => { + const { x, y } = computeLabelPosition({ + ...VIEWPORT, + targetLeft: -1000, + targetTop: -1000, + targetBottom: -900, + labelWidth: 5000, + labelHeight: 5000, + }); + expect(x).toBeGreaterThanOrEqual(0); + expect(y).toBeGreaterThanOrEqual(0); + }); +}); diff --git a/apps/desktop/src/preview-pick-preload.ts b/apps/desktop/src/preview-pick-preload.ts new file mode 100644 index 0000000000000000000000000000000000000000..9f1fee9da15020a5bea023b440c06ab8bf102ce6 --- /dev/null +++ b/apps/desktop/src/preview-pick-preload.ts @@ -0,0 +1,419 @@ +/** + * Preview pick preload — runs in the isolated world of the Chromium + * `` that hosts the in-app browser. Loaded via the + * `` attribute set by the renderer. + * + * Responsibilities: + * + * 1. Listen for `preview:start-pick` from main (sent through + * `WebContents.send`, received here via `ipcRenderer.on`). + * 2. Mount a minimal blue-highlight element picker on the page. + * 3. On click → call `react-grab/primitives.getElementContext` and bubble + * a `PickedElementPayload` to main via `ipcRenderer.send`. Main resolves + * the in-flight `pickElement(tabId)` promise via its per-WebContents + * `wc.ipc.on(...)` listener. + * 4. Tear down the picker on Escape, blur, navigation, or explicit cancel. + * + * Design notes: + * + * - We never modify the page's DOM tree — the highlight + crosshair cursor + * live on a single fixed-position overlay layer that we own. This keeps + * us safe against pages that reset DOM or do MutationObserver tracking. + * - The overlay uses `pointer-events: none` so clicks fall through to the + * real element behind it; we do hit-testing with `elementFromPoint`. + * - Only a single pick session is ever active per webview; re-activating + * silently replaces the previous session. + * - Cancellations triggered BY MAIN (CANCEL_PICK_CHANNEL, or a follow-up + * START_PICK_CHANNEL that supersedes the current session) tear down + * silently — they do NOT echo a `null` ELEMENT_PICKED back, because main + * already knows it cancelled and would otherwise resolve the freshly- + * registered next-pick listener with that stale `null`. Cancellations + * triggered by the USER (Escape, beforeunload, click on empty) DO send + * `null` back so main can resolve the in-flight pick promise. + */ +import { ipcRenderer } from "electron"; +import { getElementContext } from "react-grab/primitives"; +import type { PickedElementPayload, PickedElementStackFrame } from "@t3tools/contracts"; + +import { computeLabelPosition } from "./preview-pick-label-position.ts"; + +const START_PICK_CHANNEL = "preview:start-pick"; +const CANCEL_PICK_CHANNEL = "preview:cancel-pick"; +const ELEMENT_PICKED_CHANNEL = "preview:element-picked"; + +const HIGHLIGHT_COLOR = "rgba(37, 99, 235, 0.9)"; // blue-600 +const HIGHLIGHT_FILL = "rgba(37, 99, 235, 0.16)"; +const LABEL_BG = "rgba(37, 99, 235, 0.96)"; +const Z_INDEX_OVERLAY = 2147483646; + +interface PickSession { + readonly overlay: HTMLDivElement; + readonly outline: HTMLDivElement; + readonly label: HTMLDivElement; + /** + * Tear down listeners + DOM WITHOUT notifying main. Used when main itself + * initiated the cancel (CANCEL_PICK_CHANNEL) or when a follow-up startPick + * supersedes us — main is already waiting on a fresh listener and would + * otherwise resolve it with the stale `null` we'd send. + */ + readonly teardownSilent: () => void; +} + +let activeSession: PickSession | null = null; + +function endActiveSession(): void { + if (!activeSession) return; + const session = activeSession; + activeSession = null; + // Supersession from a new startPick is a main-initiated transition: tear + // down silently so the new pick's main-side listener doesn't receive a + // ghost `null` from the old session. + session.teardownSilent(); +} + +interface OverlayHandles { + readonly overlay: HTMLDivElement; + readonly outline: HTMLDivElement; + readonly label: HTMLDivElement; + readonly destroyDom: () => void; +} + +function createOverlay(): OverlayHandles { + const overlay = document.createElement("div"); + overlay.setAttribute("data-t3code-pick-overlay", ""); + overlay.style.cssText = [ + "position:fixed", + "inset:0", + "z-index:" + String(Z_INDEX_OVERLAY), + "pointer-events:none", + "cursor:crosshair", + // Some apps register `pointer-events: auto !important` on body — using a + // dedicated overlay element ensures we don't fight with that. + ].join(";"); + + const outline = document.createElement("div"); + outline.setAttribute("data-t3code-pick-outline", ""); + outline.style.cssText = [ + "position:absolute", + "left:0", + "top:0", + "width:0", + "height:0", + "border:2px solid " + HIGHLIGHT_COLOR, + "background:" + HIGHLIGHT_FILL, + "border-radius:2px", + "box-shadow:0 0 0 1px rgba(255,255,255,0.6) inset", + "transition:none", + "display:none", + "pointer-events:none", + ].join(";"); + + const label = document.createElement("div"); + label.setAttribute("data-t3code-pick-label", ""); + // `top:0; left:0` so transform translate() positions are absolute viewport + // coordinates (we re-anchor the label every paint via a single transform). + // `max-width: calc(100vw - 8px)` + ellipsis prevents an overly long + // tag#id.class string from overflowing the viewport horizontally before we + // even get to the clamp logic. + label.style.cssText = [ + "position:absolute", + "left:0", + "top:0", + "padding:2px 6px", + "background:" + LABEL_BG, + "color:white", + "font:600 11px/1.2 ui-sans-serif,system-ui,-apple-system,sans-serif", + "border-radius:3px", + "pointer-events:none", + "white-space:nowrap", + "max-width:calc(100vw - 8px)", + "overflow:hidden", + "text-overflow:ellipsis", + "box-shadow:0 1px 2px rgba(0,0,0,0.25)", + "display:none", + ].join(";"); + + overlay.appendChild(outline); + overlay.appendChild(label); + document.documentElement.appendChild(overlay); + + // Force the crosshair cursor across the whole page even though we set it + // on the overlay (because pointer-events: none means it's never the + // cursor target). We add a stylesheet rule and revert on cleanup. + const styleEl = document.createElement("style"); + styleEl.textContent = `html[data-t3code-picking="1"], html[data-t3code-picking="1"] *, html[data-t3code-picking="1"] *::before, html[data-t3code-picking="1"] *::after { cursor: crosshair !important; }`; + document.documentElement.appendChild(styleEl); + document.documentElement.setAttribute("data-t3code-picking", "1"); + + const destroyDom = (): void => { + overlay.remove(); + styleEl.remove(); + document.documentElement.removeAttribute("data-t3code-picking"); + }; + + return { overlay, outline, label, destroyDom }; +} + +/** + * Resolve the element under the cursor while ignoring our own overlay. + * We render at z-index 2147483646 with `pointer-events: none`, which means + * `elementFromPoint` already skips the overlay — but we double-guard against + * pages that mutate `pointer-events` via MutationObservers. + */ +function pickFromPoint(clientX: number, clientY: number): Element | null { + const candidates = document.elementsFromPoint(clientX, clientY); + for (const candidate of candidates) { + if (!(candidate instanceof Element)) continue; + if (candidate.hasAttribute("data-t3code-pick-overlay")) continue; + if (candidate.hasAttribute("data-t3code-pick-outline")) continue; + if (candidate.hasAttribute("data-t3code-pick-label")) continue; + if (candidate === document.documentElement) continue; + if (candidate === document.body) continue; + return candidate; + } + return null; +} + +function describeRawElement(element: Element): string { + const tag = element.tagName.toLowerCase(); + const id = element.id ? `#${element.id}` : ""; + const className = + element instanceof HTMLElement && typeof element.className === "string" + ? element.className + .trim() + .split(/\s+/) + .filter((token) => token.length > 0) + .slice(0, 2) + .map((token) => `.${token}`) + .join("") + : ""; + return `${tag}${id}${className}`; +} + +/** + * Per-session cache of the resolved React component name for elements we've + * already inspected. Stores `null` when react-grab couldn't find a component + * (raw HTML / unmounted) so repeat hovers don't re-pay the async lookup. + */ +const componentNameCache = new WeakMap(); +const componentNameInFlight = new WeakSet(); + +function describeElement(element: Element): string { + const cached = componentNameCache.get(element); + if (cached) return `<${cached}> ${describeRawElement(element)}`; + return describeRawElement(element); +} + +function paintOutline(handles: OverlayHandles, element: Element): void { + const rect = element.getBoundingClientRect(); + if (rect.width === 0 && rect.height === 0) { + handles.outline.style.display = "none"; + handles.label.style.display = "none"; + return; + } + handles.outline.style.display = "block"; + handles.outline.style.transform = `translate(${rect.left}px, ${rect.top}px)`; + handles.outline.style.width = `${rect.width}px`; + handles.outline.style.height = `${rect.height}px`; + + // Two-pass label paint: first apply the new text and force-block display + // so we can measure the rendered size, then clamp to the viewport and + // flip below the element when there isn't room above. + const text = describeElement(element); + if (handles.label.textContent !== text) { + handles.label.textContent = text; + } + handles.label.style.display = "block"; + + const labelRect = handles.label.getBoundingClientRect(); + const { x, y } = computeLabelPosition({ + targetLeft: rect.left, + targetTop: rect.top, + targetBottom: rect.bottom, + labelWidth: labelRect.width, + labelHeight: labelRect.height, + viewportWidth: document.documentElement.clientWidth || window.innerWidth || labelRect.width, + viewportHeight: document.documentElement.clientHeight || window.innerHeight || labelRect.height, + }); + handles.label.style.transform = `translate(${x}px, ${y}px)`; +} + +/** + * Kick off (at most once per element) an async react-grab lookup for the + * component name. When the answer arrives, repaint the label iff the element + * is still under the cursor — otherwise the user has moved on and a stale + * paint would clobber the next element's label. + */ +function ensureComponentName(element: Element, onResolve: (resolvedFor: Element) => void): void { + if (componentNameCache.has(element)) return; + if (componentNameInFlight.has(element)) return; + componentNameInFlight.add(element); + void getElementContext(element) + .then((context) => { + const trimmed = context.componentName?.trim(); + componentNameCache.set(element, trimmed && trimmed.length > 0 ? trimmed : null); + }) + .catch(() => { + componentNameCache.set(element, null); + }) + .finally(() => { + componentNameInFlight.delete(element); + onResolve(element); + }); +} + +function clearOutline(handles: OverlayHandles): void { + handles.outline.style.display = "none"; + handles.label.style.display = "none"; +} + +function toStackFrame(frame: { + functionName?: string; + fileName?: string; + lineNumber?: number; + columnNumber?: number; +}): PickedElementStackFrame { + return { + functionName: frame.functionName ?? null, + fileName: frame.fileName ?? null, + lineNumber: frame.lineNumber ?? null, + columnNumber: frame.columnNumber ?? null, + }; +} + +async function captureElement(element: Element): Promise { + try { + const context = await getElementContext(element); + const stack = (context.stack ?? []).map((frame) => toStackFrame(frame)); + return { + pageUrl: location.href, + pageTitle: document.title?.trim() ? document.title.trim() : null, + tagName: element.tagName.toLowerCase(), + selector: context.selector, + htmlPreview: context.htmlPreview ?? "", + componentName: context.componentName, + source: stack[0] ?? null, + stack, + styles: context.styles ?? "", + pickedAt: new Date().toISOString(), + }; + } catch { + return null; + } +} + +function startPick(): void { + endActiveSession(); + if (typeof document === "undefined" || !document.body) { + ipcRenderer.send(ELEMENT_PICKED_CHANNEL, null); + return; + } + + const handles = createOverlay(); + let lastTarget: Element | null = null; + let resolved = false; + + // Tear down listeners + DOM. Does NOT notify main. `notifyMain` controls + // whether we additionally bubble a `null` ELEMENT_PICKED back so main can + // resolve the in-flight pick promise (only true for USER-initiated cancels; + // main already knows about its own cancellations). + const teardown = (notifyMain: boolean, payload: PickedElementPayload | null): void => { + if (resolved) return; + resolved = true; + window.removeEventListener("mousemove", onMove, { capture: true } as EventListenerOptions); + window.removeEventListener("click", onClick, { capture: true } as EventListenerOptions); + window.removeEventListener("keydown", onKey, { capture: true } as EventListenerOptions); + window.removeEventListener("scroll", onScrollOrResize, { + capture: true, + } as EventListenerOptions); + window.removeEventListener("resize", onScrollOrResize); + window.removeEventListener("beforeunload", onBeforeUnload); + ipcRenderer.off(CANCEL_PICK_CHANNEL, onMainCancel); + handles.destroyDom(); + if (notifyMain) { + // `ipcRenderer.send` (NOT `sendToHost`) reaches main, where the + // PreviewViewManager's per-WebContents `wc.ipc.on(...)` listener + // resolves the in-flight pick promise. + ipcRenderer.send(ELEMENT_PICKED_CHANNEL, payload); + } + }; + + // Re-paint when an in-flight component-name lookup resolves, but only if + // the same element is still under the cursor — otherwise the user moved on + // and we'd clobber the next element's label. + const onComponentNameResolved = (resolvedFor: Element): void => { + if (lastTarget !== resolvedFor) return; + paintOutline(handles, resolvedFor); + }; + + const onMove = (event: MouseEvent): void => { + const target = pickFromPoint(event.clientX, event.clientY); + if (target === lastTarget) return; + lastTarget = target; + if (target) { + paintOutline(handles, target); + ensureComponentName(target, onComponentNameResolved); + } else { + clearOutline(handles); + } + }; + + const onScrollOrResize = (): void => { + if (lastTarget) paintOutline(handles, lastTarget); + }; + + const onClick = (event: MouseEvent): void => { + event.preventDefault(); + event.stopPropagation(); + const target = pickFromPoint(event.clientX, event.clientY); + if (!target) { + teardown(true, null); + return; + } + void captureElement(target).then((payload) => teardown(true, payload)); + }; + + const onKey = (event: KeyboardEvent): void => { + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + teardown(true, null); + } + }; + + const onBeforeUnload = (): void => { + teardown(true, null); + }; + + // Cancellation initiated by main (CANCEL_PICK_CHANNEL). Tear down silently + // — main already knows it cancelled and is either done waiting or about to + // register a fresh listener for a new pick. If we sent `null` here, that + // fresh listener would receive it and resolve the new pick instantly (the + // C1 race we previously hit). + const onMainCancel = (): void => { + teardown(false, null); + }; + + // Capture-phase listeners on `window` to outrun page handlers that + // `stopPropagation()` early. `passive: false` because we need preventDefault. + window.addEventListener("mousemove", onMove, { capture: true, passive: true }); + window.addEventListener("click", onClick, { capture: true }); + window.addEventListener("keydown", onKey, { capture: true }); + window.addEventListener("scroll", onScrollOrResize, { capture: true, passive: true }); + window.addEventListener("resize", onScrollOrResize, { passive: true }); + window.addEventListener("beforeunload", onBeforeUnload); + ipcRenderer.on(CANCEL_PICK_CHANNEL, onMainCancel); + + // Hand a "silent teardown" to `activeSession` so that a follow-up + // `startPick()` can supersede us without echoing `null` back to main. + activeSession = { + overlay: handles.overlay, + outline: handles.outline, + label: handles.label, + teardownSilent: () => teardown(false, null), + }; +} + +ipcRenderer.on(START_PICK_CHANNEL, () => { + startPick(); +}); diff --git a/apps/desktop/src/preview-view-manager.ts b/apps/desktop/src/preview-view-manager.ts index 6e27850bd6f4dc61c5589476ae734e928e40bf3e..127fb216b0055d432630e9de03f2e13f560adc0b 100644 --- a/apps/desktop/src/preview-view-manager.ts +++ b/apps/desktop/src/preview-view-manager.ts @@ -5,10 +5,22 @@ * elements live in the renderer; we only attach listeners and forward state * here). Single layer-scoped browser session partition. */ +import type { PickedElementPayload } from "@t3tools/contracts"; import { normalizePreviewUrl } from "@t3tools/shared/preview"; import { type BrowserWindow, type Session, session, webContents } from "electron"; +import { isPickedElementPayload } from "./picked-element-payload.ts"; + const PREVIEW_PARTITION = "persist:t3code-preview"; +const START_PICK_CHANNEL = "preview:start-pick"; +const CANCEL_PICK_CHANNEL = "preview:cancel-pick"; +const ELEMENT_PICKED_CHANNEL = "preview:element-picked"; + +// Re-export the guest webview security posture from its dedicated module so +// the constant is unit-testable in isolation. See +// `preview-webview-preferences.ts` for the full security rationale. +export { PREVIEW_WEBVIEW_PREFERENCES } from "./preview-webview-preferences.ts"; +import { PREVIEW_WEBVIEW_PREFERENCES } from "./preview-webview-preferences.ts"; export type PreviewNavStatus = | { kind: "Idle" } @@ -63,6 +75,11 @@ interface ManagedListeners { failed: (event: Event, code: number, description: string) => void; } +interface PickSession { + readonly resolve: (payload: PickedElementPayload | null) => void; + readonly cleanup: () => void; +} + const APP_FORWARDED_SHORTCUTS: ReadonlyArray<{ key: string; meta: boolean; @@ -85,6 +102,8 @@ export class PreviewViewManager { private readonly attached = new Map(); private browserSession: Session | null = null; private readonly listeners = new Set(); + /** In-flight element-pick sessions, keyed by tabId (one pick per tab). */ + private readonly pickSessions = new Map(); setMainWindow(window: BrowserWindow): void { this.mainWindow = window; @@ -94,6 +113,16 @@ export class PreviewViewManager { return PREVIEW_PARTITION; } + /** + * Returns the canonical `` string. Renderer + * fetches this via the desktop bridge so the security posture for guest + * surfaces lives in exactly one place (here) and any future guest webview + * (docs panel, OAuth popup, etc.) can opt in by calling the same getter. + */ + getWebviewPreferences(): string { + return PREVIEW_WEBVIEW_PREFERENCES; + } + getBrowserSession(): Session { if (this.browserSession) return this.browserSession; const sess = session.fromPartition(PREVIEW_PARTITION); @@ -130,6 +159,7 @@ export class PreviewViewManager { closeTab(tabId: string): void { const tab = this.tabs.get(tabId); if (!tab) return; + this.cancelPickElement(tabId); if (tab.webContentsId != null) { this.detachListeners(tab.webContentsId); } @@ -169,6 +199,10 @@ export class PreviewViewManager { } if (tab.webContentsId != null && tab.webContentsId !== webContentsId) { this.detachListeners(tab.webContentsId); + // Any in-flight pick is bound to the OLD WebContents via `wc.ipc.on`. + // Cancel it so the toggle button doesn't get stuck pressed waiting + // forever for a click on a webview that no longer hosts the listener. + this.cancelPickElement(tabId); } this.attachListeners(tabId, wc); // Restore the persisted zoom factor onto the freshly-attached WebContents @@ -254,6 +288,96 @@ export class PreviewViewManager { await sess.clearCache(); } + /** + * Activate the in-page element picker for `tabId`. Resolves with the + * `PickedElementPayload` the preload script bubbles back via `ipc-message`, + * or `null` when the user cancels (Escape, navigation, manual cancel). + * + * Exactly one pick session may be active per tab — re-invoking while a + * pick is in flight cleanly resolves the old session with `null` first. + */ + async pickElement(tabId: string): Promise { + const wc = this.requireWebContents(tabId); + this.cancelPickElement(tabId); + return new Promise((resolve) => { + // `wc.ipc` is the per-WebContents IpcMain that receives messages the + // webview's preload sends with `ipcRenderer.send(...)`. We use that + // (not the global `wc.on("ipc-message", ...)`, which is for + // `sendToHost` and only fires on the host renderer's + // element) so the main process actually observes the picked payload. + const cleanup = () => { + wc.ipc.removeListener(ELEMENT_PICKED_CHANNEL, onMessage); + wc.off("destroyed", onDestroyed); + wc.off("did-start-navigation", onNavigated); + this.pickSessions.delete(tabId); + }; + const session: PickSession = { resolve, cleanup }; + const settle = (payload: PickedElementPayload | null) => { + if (this.pickSessions.get(tabId) !== session) return; + cleanup(); + resolve(payload); + }; + const onMessage = (_event: Electron.IpcMainEvent, ...args: unknown[]): void => { + const payload = args[0]; + if (payload == null) { + settle(null); + return; + } + if (!isPickedElementPayload(payload)) { + settle(null); + return; + } + settle(payload); + }; + const onDestroyed = () => settle(null); + const onNavigated = () => settle(null); + wc.ipc.on(ELEMENT_PICKED_CHANNEL, onMessage); + wc.once("destroyed", onDestroyed); + // A page navigation (incl. SPA → same-document) tears down the + // preload's listeners, so we cancel proactively to avoid hanging. + wc.once("did-start-navigation", onNavigated); + this.pickSessions.set(tabId, session); + // Force-focus the guest webContents BEFORE sending start-pick. Without + // this, Electron's input router will deliver the user's first + // mousemove/click to the host renderer (where the pick button lives) + // instead of to the guest's window listeners — manifest as "the + // picker overlay never appears on remote pages I haven't clicked + // into yet". The renderer-side handler in `PreviewView` is responsible + // for restoring focus to the previously-active host element when the + // pick promise resolves so the user's textarea cursor isn't lost. + try { + if (!wc.isFocused()) wc.focus(); + } catch { + // wc may be torn down; the next try/catch settles. + } + try { + wc.send(START_PICK_CHANNEL); + } catch { + settle(null); + } + }); + } + + cancelPickElement(tabId: string): void { + const session = this.pickSessions.get(tabId); + if (!session) return; + session.cleanup(); + // Best-effort: tell the page to dismiss the overlay even if it's still + // alive — keeps the next invoke fresh. + const tab = this.tabs.get(tabId); + if (tab?.webContentsId != null) { + const wc = webContents.fromId(tab.webContentsId); + if (wc && !wc.isDestroyed()) { + try { + wc.send(CANCEL_PICK_CHANNEL); + } catch { + // wc may have been torn down; nothing to clean up. + } + } + } + session.resolve(null); + } + zoomIn(tabId: string): void { this.applyZoom(tabId, (current) => nextZoomLevel(current, "in")); } diff --git a/apps/desktop/src/preview-webview-preferences.test.ts b/apps/desktop/src/preview-webview-preferences.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..82e8a88ce025d8e8f95b45c1a05942388eada90c --- /dev/null +++ b/apps/desktop/src/preview-webview-preferences.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; + +import { PREVIEW_WEBVIEW_PREFERENCES } from "./preview-webview-preferences.ts"; + +/** + * Mirrors Electron's webview attribute parser closely enough to catch the + * regressions we've already hit: + * + * - whitespace inside the comma-separated list silently drops keys (so + * `" sandbox=true"` becomes an unknown key and Electron falls back to + * defaults — re-opening the Node-leak window we closed), + * - non-`true`/`false` values (`"yes"`, `"no"`, etc.) are kept as truthy + * strings and assigned to a boolean preference, which silently flips + * `contextIsolation=no` to ENABLED (then react-grab can't see the React + * DevTools hook and componentName resolution always returns null). + * + * The actual Electron parser does roughly: + * + * for (const pair of webpreferences.split(',')) { + * const [key, value] = pair.split('='); + * prefs[key] = value; // value left as a string + * } + * + * then later coerces booleans via `Boolean(value)`. Replicating that here + * keeps the test independent of Electron internals while still failing if + * we accidentally ship `"contextIsolation=no"` again. + */ +function parseWebPreferences(input: string): Record { + const out: Record = {}; + for (const pair of input.split(",")) { + if (pair !== pair.trim()) { + // Electron's parser doesn't trim; surface the bug as undefined-key. + out[pair] = pair.split("=")[1]; + continue; + } + const [key, value] = pair.split("="); + if (!key) continue; + out[key] = value; + } + return out; +} + +describe("PREVIEW_WEBVIEW_PREFERENCES", () => { + const parsed = parseWebPreferences(PREVIEW_WEBVIEW_PREFERENCES); + + it("contains exactly the three security-critical keys", () => { + expect(Object.keys(parsed).toSorted()).toEqual( + ["contextIsolation", "nodeIntegration", "sandbox"].toSorted(), + ); + }); + + it("uses canonical JS-boolean string literals (not yes/no, on/off, 1/0)", () => { + // `value="no"` is a TRUTHY string when assigned to webPreferences.X — so + // `contextIsolation="no"` would silently leave isolation ENABLED. Lock + // the values to `"true"` / `"false"` so the parser does the right thing. + for (const value of Object.values(parsed)) { + expect(value).toMatch(/^(true|false)$/); + } + }); + + it("disables context isolation (so react-grab can see the page's React DevTools hook)", () => { + expect(parsed["contextIsolation"]).toBe("false"); + }); + + it("keeps the renderer sandbox enabled (so the page cannot reach Node APIs)", () => { + expect(parsed["sandbox"]).toBe("true"); + }); + + it("disables nodeIntegration (defense in depth — page never gets Node)", () => { + expect(parsed["nodeIntegration"]).toBe("false"); + }); + + it("contains no whitespace (Electron's parser does not trim)", () => { + // Electron splits on `,` without trimming, so any whitespace would turn + // a key into an unknown one and silently drop the security flag. + expect(PREVIEW_WEBVIEW_PREFERENCES).not.toMatch(/\s/); + }); +}); diff --git a/apps/desktop/src/preview-webview-preferences.ts b/apps/desktop/src/preview-webview-preferences.ts new file mode 100644 index 0000000000000000000000000000000000000000..2d96bff456c526a0696810474ccfa01678a6e415 --- /dev/null +++ b/apps/desktop/src/preview-webview-preferences.ts @@ -0,0 +1,42 @@ +/** + * webPreferences override applied to every preview `` element via + * its `webpreferences="..."` attribute. Single source of truth so all guest + * surfaces inherit the same security posture. + * + * Lives in its own electron-free module so the value is unit-testable + * without importing `preview-view-manager.ts` (which transitively imports + * `electron` and blows up under vitest). + * + * - `contextIsolation=false`: the picker preload needs to share `globalThis` + * with the page so react-grab/bippy can read the React DevTools hook + * (`__REACT_DEVTOOLS_GLOBAL_HOOK__`) and resolve component names. Without + * this every pick comes back with `componentName: null` even on dev React + * apps. + * - `sandbox=true`: keeps the OS-level renderer sandbox enabled. Critical + * when paired with `contextIsolation=false` — without sandbox, the preload + * has full Node access (`require`, `fs`, `child_process`, ...) and that + * `require` would land on the page's shared `globalThis`, giving any + * third-party page in the preview full Node + IPC access to the host. + * In sandboxed mode Electron still synthesizes the `electron` module for + * the preload's `import { ipcRenderer }` line, but no Node globals leak. + * - `nodeIntegration=false`: pinned for clarity (the page itself never gets + * Node access). + * + * Format notes (locked down by `preview-webview-preferences.test.ts`): + * - Whitespace-free. Electron's webpreferences parser splits on `,` and + * does not trim, so a leading space would turn a key into an unknown one + * and silently drop it. + * - Values are JS-boolean strings (`true`/`false`) — `yes`/`no` are not + * special-cased by the parser; `value="no"` becomes the truthy STRING + * `"no"` when assigned to a boolean webPreferences key. Most critically, + * `contextIsolation="no"` is truthy → contextIsolation stays ENABLED → + * react-grab can't see the React DevTools hook. + * + * Defense in depth: `apps/desktop/src/main.ts` also runs a + * `will-attach-webview` handler that force-sets `sandbox: true` and + * `nodeIntegration*: false` on the actual webPreferences object, gated on + * the preview partition, so even if this string is ever wrong, the + * security-critical flags can't regress on preview tabs. + */ +export const PREVIEW_WEBVIEW_PREFERENCES = + "contextIsolation=false,sandbox=true,nodeIntegration=false"; diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 6a498136749f5b76d6ad32ecfc09cc116adf0922..a19eb0ce2639c08430c44c7e41423166ecef520b 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -198,6 +198,16 @@ const make = Effect.gen(function* () { }); previewViewManager.setMainWindow(window); + window.webContents.on("will-attach-webview", (event, webPreferences, params) => { + if (params.partition !== previewViewManager.getBrowserPartition()) { + event.preventDefault(); + return; + } + webPreferences.sandbox = true; + webPreferences.nodeIntegration = false; + webPreferences.nodeIntegrationInSubFrames = false; + webPreferences.contextIsolation = false; + }); window.webContents.on("context-menu", (event, params) => { event.preventDefault(); diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index d42d22309465c0f0147ff1a6437e6e91b7fa3496..4c1e9f8c1530715adbda5488c0bb6dae9a497eea 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -56,5 +56,15 @@ export default defineConfig({ define: publicConfigDefine, entry: ["src/preload.ts"], }, + { + format: "cjs", + outDir: "dist-electron", + sourcemap: true, + outExtensions: () => ({ js: ".cjs" }), + entry: ["src/preview-pick-preload.ts"], + deps: { + alwaysBundle: (id) => id === "react-grab" || id.startsWith("react-grab/"), + }, + }, ], }); diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 13bd175e0c99644f36ee7840ac2738e2518f261f..e08563b234b6473791c8e59ab2721fc6f55daf7c 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -73,6 +73,30 @@ describe("deriveComposerSendState", () => { expect(state.expiredTerminalContextCount).toBe(1); expect(state.hasSendableContent).toBe(true); }); + + it("treats element contexts as sendable content (no text, no images, no terminals)", () => { + const state = deriveComposerSendState({ + prompt: "", + imageCount: 0, + terminalContexts: [], + elementContextCount: 1, + }); + + expect(state.trimmedPrompt).toBe(""); + expect(state.expiredTerminalContextCount).toBe(0); + expect(state.hasSendableContent).toBe(true); + }); + + it("does NOT treat zero element contexts as sendable", () => { + expect( + deriveComposerSendState({ + prompt: "", + imageCount: 0, + terminalContexts: [], + elementContextCount: 0, + }).hasSendableContent, + ).toBe(false); + }); }); describe("buildExpiredTerminalContextToastCopy", () => { diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index de69c5730469dc0694f96cc226743ae35843bf07..e1417773c32e041d9ce9521330a11130372e5a87 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -185,6 +185,12 @@ export function deriveComposerSendState(options: { prompt: string; imageCount: number; terminalContexts: ReadonlyArray; + /** + * Optional element-pick attachment count. Element contexts contribute to + * "sendable content" exactly like images and (text-bearing) terminal + * contexts do: a prompt of just element chips is still a valid send. + */ + elementContextCount?: number; }): { trimmedPrompt: string; sendableTerminalContexts: TerminalContextDraft[]; @@ -195,12 +201,16 @@ export function deriveComposerSendState(options: { const sendableTerminalContexts = filterTerminalContextsWithText(options.terminalContexts); const expiredTerminalContextCount = options.terminalContexts.length - sendableTerminalContexts.length; + const elementContextCount = options.elementContextCount ?? 0; return { trimmedPrompt, sendableTerminalContexts, expiredTerminalContextCount, hasSendableContent: - trimmedPrompt.length > 0 || options.imageCount > 0 || sendableTerminalContexts.length > 0, + trimmedPrompt.length > 0 || + options.imageCount > 0 || + sendableTerminalContexts.length > 0 || + elementContextCount > 0, }; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c417f7fd3149f6517cf6a0b6a778368f25b531c5..adc40747f74dbbedac4d724831193a4208ab0f0c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -149,6 +149,11 @@ import { } from "../lib/terminalContext"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../terminalSessionState"; +import { + appendElementContextsToPrompt, + type ElementContextDraft, + formatElementContextLabel, +} from "../lib/elementContext"; import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; @@ -873,6 +878,9 @@ export default function ChatView(props: ChatViewProps) { const setComposerDraftTerminalContexts = useComposerDraftStore( (store) => store.setTerminalContexts, ); + const setComposerDraftElementContexts = useComposerDraftStore( + (store) => store.setElementContexts, + ); const setComposerDraftModelSelection = useComposerDraftStore((store) => store.setModelSelection); const setComposerDraftRuntimeMode = useComposerDraftStore((store) => store.setRuntimeMode); const setComposerDraftInteractionMode = useComposerDraftStore( @@ -897,6 +905,7 @@ export default function ChatView(props: ChatViewProps) { const promptRef = useRef(""); const composerImagesRef = useRef([]); const composerTerminalContextsRef = useRef([]); + const composerElementContextsRef = useRef([]); const localComposerRef = useRef(null); const composerRef = useComposerHandleContext() ?? localComposerRef; const [showScrollToBottom, setShowScrollToBottom] = useState(false); @@ -2982,6 +2991,7 @@ export default function ChatView(props: ChatViewProps) { const { images: composerImages, terminalContexts: composerTerminalContexts, + elementContexts: composerElementContexts, selectedProvider: ctxSelectedProvider, selectedModel: ctxSelectedModel, selectedProviderModels: ctxSelectedProviderModels, @@ -2998,6 +3008,7 @@ export default function ChatView(props: ChatViewProps) { prompt: promptForSend, imageCount: composerImages.length, terminalContexts: composerTerminalContexts, + elementContextCount: composerElementContexts.length, }); if (showPlanFollowUpPrompt && activeProposedPlan) { const followUp = resolvePlanFollowUpSubmission({ @@ -3014,7 +3025,9 @@ export default function ChatView(props: ChatViewProps) { return; } const standaloneSlashCommand = - composerImages.length === 0 && sendableComposerTerminalContexts.length === 0 + composerImages.length === 0 && + sendableComposerTerminalContexts.length === 0 && + composerElementContexts.length === 0 ? parseStandaloneComposerSlashCommand(trimmed) : null; if (standaloneSlashCommand) { @@ -3062,9 +3075,10 @@ export default function ChatView(props: ChatViewProps) { const composerImagesSnapshot = [...composerImages]; const composerTerminalContextsSnapshot = [...sendableComposerTerminalContexts]; - const messageTextForSend = appendTerminalContextsToPrompt( - promptForSend, - composerTerminalContextsSnapshot, + const composerElementContextsSnapshot = [...composerElementContexts]; + const messageTextForSend = appendElementContextsToPrompt( + appendTerminalContextsToPrompt(promptForSend, composerTerminalContextsSnapshot), + composerElementContextsSnapshot, ); const messageIdForSend = newMessageId(); const messageCreatedAt = new Date().toISOString(); @@ -3145,6 +3159,8 @@ export default function ChatView(props: ChatViewProps) { titleSeed = `Image: ${firstComposerImageName}`; } else if (composerTerminalContextsSnapshot.length > 0) { titleSeed = formatTerminalContextLabel(composerTerminalContextsSnapshot[0]!); + } else if (composerElementContextsSnapshot.length > 0) { + titleSeed = formatElementContextLabel(composerElementContextsSnapshot[0]!); } else { titleSeed = "New thread"; } @@ -3230,7 +3246,8 @@ export default function ChatView(props: ChatViewProps) { !turnStartSucceeded && promptRef.current.length === 0 && composerImagesRef.current.length === 0 && - composerTerminalContextsRef.current.length === 0 + composerTerminalContextsRef.current.length === 0 && + composerElementContextsRef.current.length === 0 ) { setOptimisticUserMessages((existing) => { const removed = existing.filter((message) => message.id === messageIdForSend); @@ -3244,9 +3261,11 @@ export default function ChatView(props: ChatViewProps) { const retryComposerImages = composerImagesSnapshot.map(cloneComposerImageForRetry); composerImagesRef.current = retryComposerImages; composerTerminalContextsRef.current = composerTerminalContextsSnapshot; + composerElementContextsRef.current = composerElementContextsSnapshot; setComposerDraftPrompt(composerDraftTarget, promptForSend); addComposerDraftImages(composerDraftTarget, retryComposerImages); setComposerDraftTerminalContexts(composerDraftTarget, composerTerminalContextsSnapshot); + setComposerDraftElementContexts(composerDraftTarget, composerElementContextsSnapshot); composerRef.current?.resetCursorState({ cursor: collapseExpandedComposerCursor(promptForSend, promptForSend.length), prompt: promptForSend, @@ -4045,6 +4064,7 @@ export default function ChatView(props: ChatViewProps) { promptRef={promptRef} composerImagesRef={composerImagesRef} composerTerminalContextsRef={composerTerminalContextsRef} + composerElementContextsRef={composerElementContextsRef} shouldAutoScrollRef={isAtEndRef} scheduleStickToBottom={scrollToEnd} onSend={onSend} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 8d89ccdd396ef69dae23b5e60f61f614fa664c12..5696c6f069c96e11941fed575a4aec2f0ea50131 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -17,6 +17,10 @@ import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, } from "@t3tools/contracts"; +import { + connectionStatusText, + type EnvironmentConnectionPresentation, +} from "@t3tools/client-runtime/connection"; import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; import { @@ -53,6 +57,8 @@ import { removeInlineTerminalContextPlaceholder, } from "../../lib/terminalContext"; import { useComposerPathSearch } from "../../lib/composerPathSearchState"; +import { type ElementContextDraft } from "../../lib/elementContext"; +import { ComposerPendingElementContexts } from "./ComposerPendingElementContexts"; import { shouldUseCompactComposerPrimaryActions, shouldUseCompactComposerFooter, @@ -400,6 +406,7 @@ export interface ChatComposerHandle { prompt: string; images: ComposerImageAttachment[]; terminalContexts: TerminalContextDraft[]; + elementContexts: ElementContextDraft[]; selectedPromptEffort: string | null; selectedModelOptionsForDispatch: unknown; selectedModelSelection: ModelSelection; @@ -434,7 +441,7 @@ export interface ChatComposerProps { isPreparingWorktree: boolean; environmentUnavailable: { readonly label: string; - readonly connectionState: "connecting" | "disconnected" | "error"; + readonly connection: EnvironmentConnectionPresentation; } | null; // Pending approvals / inputs @@ -486,6 +493,7 @@ export interface ChatComposerProps { promptRef: React.RefObject; composerImagesRef: React.RefObject; composerTerminalContextsRef: React.RefObject; + composerElementContextsRef: React.RefObject; composerRef: React.RefObject; // Scroll @@ -576,6 +584,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerRef, composerImagesRef, composerTerminalContextsRef, + composerElementContextsRef, shouldAutoScrollRef, scheduleStickToBottom, onSend, @@ -605,6 +614,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const prompt = composerDraft.prompt; const composerImages = composerDraft.images; const composerTerminalContexts = composerDraft.terminalContexts; + const composerElementContexts = composerDraft.elementContexts; const nonPersistedComposerImageIds = composerDraft.nonPersistedImageIds; const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); @@ -620,6 +630,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const setComposerDraftTerminalContexts = useComposerDraftStore( (store) => store.setTerminalContexts, ); + const removeComposerDraftElementContext = useComposerDraftStore( + (store) => store.removeElementContext, + ); const clearComposerDraftPersistedAttachments = useComposerDraftStore( (store) => store.clearPersistedAttachments, ); @@ -880,8 +893,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) prompt, imageCount: composerImages.length, terminalContexts: composerTerminalContexts, + elementContextCount: composerElementContexts.length, }), - [composerImages.length, composerTerminalContexts, prompt], + [composerElementContexts.length, composerImages.length, composerTerminalContexts, prompt], ); // ------------------------------------------------------------------ @@ -1170,6 +1184,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerTerminalContextsRef.current = composerTerminalContexts; }, [composerTerminalContexts, composerTerminalContextsRef]); + useEffect(() => { + composerElementContextsRef.current = composerElementContexts; + }, [composerElementContexts, composerElementContextsRef]); + // ------------------------------------------------------------------ // Composer menu highlight sync // ------------------------------------------------------------------ @@ -1969,6 +1987,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) prompt: promptRef.current, images: composerImagesRef.current, terminalContexts: composerTerminalContextsRef.current, + elementContexts: composerElementContextsRef.current, selectedPromptEffort, selectedModelOptionsForDispatch, selectedModelSelection, @@ -1986,6 +2005,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) promptRef, composerImagesRef, composerTerminalContextsRef, + composerElementContextsRef, isConnecting, isComposerApprovalState, pendingUserInputs.length, @@ -2223,6 +2243,19 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) )} + {!isComposerCollapsedMobile && + !isComposerApprovalState && + pendingUserInputs.length === 0 && + composerElementContexts.length > 0 && ( + + removeComposerDraftElementContext(composerDraftTarget, contextId) + } + className="mb-3" + /> + )} + {!isComposerCollapsedMobile && !isComposerApprovalState && pendingUserInputs.length === 0 && @@ -2321,11 +2354,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) : showPlanFollowUpPrompt && activeProposedPlan ? "Add feedback to refine the plan, or leave this blank to implement it" : environmentUnavailable - ? `${environmentUnavailable.label} is ${ - environmentUnavailable.connectionState === "connecting" - ? "connecting" - : "disconnected" - }` + ? `${environmentUnavailable.label}: ${connectionStatusText( + environmentUnavailable.connection, + )}` : phase === "disconnected" ? "Ask for follow-up changes or attach images" : "Ask anything, @tag files/folders, $use skills, or / for commands" diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx index 27a42aa54671a5b5e905e6f9fecca27ebdb43265..d9bb28f696a0588c3175b355127f17ffde3c4097 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx @@ -17,7 +17,7 @@ import { createModelCapabilities, createModelSelection } from "@t3tools/shared/m import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu"; import { TraitsMenuContent } from "./TraitsPicker"; -import { useComposerDraftStore } from "../../composerDraftStore"; +import { createEmptyThreadDraft, useComposerDraftStore } from "../../composerDraftStore"; const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); @@ -60,18 +60,16 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str useComposerDraftStore.setState({ draftsByThreadKey: { + // Compose from the canonical empty-draft factory so adding a new + // ComposerThreadDraftState slice (e.g. a future attachment kind) doesn't + // silently break this stub via `Property X is missing in type ...`. [threadKey]: { + ...createEmptyThreadDraft(), prompt: props?.prompt ?? "", - images: [], - nonPersistedImageIds: [], - persistedAttachments: [], - terminalContexts: [], modelSelectionByProvider: { [instanceId]: createModelSelection(instanceId, model, props?.modelSelection?.options), }, activeProvider: instanceId, - runtimeMode: null, - interactionMode: null, }, }, draftThreadsByThreadKey: {}, diff --git a/apps/web/src/components/chat/ComposerPendingElementContexts.tsx b/apps/web/src/components/chat/ComposerPendingElementContexts.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7373403a7c393f1008b5b753baf3d7d78582b92a --- /dev/null +++ b/apps/web/src/components/chat/ComposerPendingElementContexts.tsx @@ -0,0 +1,95 @@ +import { MousePointerClick, X } from "lucide-react"; + +import { + COMPOSER_INLINE_CHIP_CLASS_NAME, + COMPOSER_INLINE_CHIP_DISMISS_BUTTON_CLASS_NAME, + COMPOSER_INLINE_CHIP_ICON_CLASS_NAME, + COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME, +} from "../composerInlineChip"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { cn } from "~/lib/utils"; +import { + type ElementContextDraft, + formatElementContextLabel, + formatElementContextSourceLabel, +} from "~/lib/elementContext"; + +interface ComposerPendingElementContextsProps { + contexts: ReadonlyArray; + onRemove: (contextId: string) => void; + className?: string; +} + +interface ComposerPendingElementContextChipProps { + context: ElementContextDraft; + onRemove: (contextId: string) => void; +} + +function buildTooltipContent(context: ElementContextDraft): string { + const lines: string[] = []; + lines.push(formatElementContextLabel(context)); + const source = formatElementContextSourceLabel(context); + if (source) lines.push(source); + if (context.pageUrl) lines.push(context.pageUrl); + if (context.selector) lines.push(context.selector); + if (context.htmlPreview.trim().length > 0) { + lines.push(""); + lines.push(context.htmlPreview.trim().slice(0, 600)); + } + return lines.join("\n"); +} + +export function ComposerPendingElementContextChip({ + context, + onRemove, +}: ComposerPendingElementContextChipProps) { + const label = formatElementContextLabel(context); + const sourceLabel = formatElementContextSourceLabel(context); + return ( + + + + {label} + {sourceLabel ? ( + + {sourceLabel} + + ) : null} + + + } + /> + + {buildTooltipContent(context)} + + + ); +} + +export function ComposerPendingElementContexts({ + contexts, + onRemove, + className, +}: ComposerPendingElementContextsProps) { + if (contexts.length === 0) return null; + return ( +
+ {contexts.map((context) => ( + + ))} +
+ ); +} diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 627213ef4f220b4a935436d6690f37dac599d219..1267e935bb2fd838ec56f8464357be2c8d43092f 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -46,6 +46,7 @@ import { GlobeIcon, HammerIcon, MessageCircleIcon, + MousePointerClickIcon, MinusIcon, SquarePenIcon, TerminalIcon, @@ -76,6 +77,10 @@ import { deriveDisplayedUserMessageState, type ParsedTerminalContextEntry, } from "~/lib/terminalContext"; +import { + extractTrailingElementContexts, + type ParsedElementContextEntry, +} from "~/lib/elementContext"; import { cn } from "~/lib/utils"; import { useUiStateStore } from "~/uiStateStore"; import { type TimestampFormat } from "@t3tools/contracts/settings"; @@ -424,6 +429,7 @@ function UserTimelineRow({ row }: { row: Extract )} + {elementContextState.contexts.length > 0 ? ( +
+ {elementContextState.contexts.map((context, index) => ( + + ))} +
+ ) : null} } > - {formatShortTimestamp( - row.message.completedAt ?? row.message.createdAt, - ctx.timestampFormat, - )} + {formatShortTimestamp(row.message.updatedAt, ctx.timestampFormat)} - {formatChatTimestampTooltip( - row.message.completedAt ?? row.message.createdAt, - ctx.timestampFormat, - )} + {formatChatTimestampTooltip(row.message.updatedAt, ctx.timestampFormat)} )} @@ -899,6 +909,29 @@ const UserMessageTerminalContextInlineLabel = memo( }, ); +const UserMessageElementContextChip = memo(function UserMessageElementContextChip(props: { + context: ParsedElementContextEntry; +}) { + const tooltipText = props.context.body + ? `${props.context.header}\n${props.context.body}` + : props.context.header; + return ( + + + + {props.context.header} + + } + /> + + {tooltipText} + + + ); +}); + const MAX_COLLAPSED_USER_MESSAGE_LINES = 8; const MAX_COLLAPSED_USER_MESSAGE_LENGTH = 600; const COLLAPSED_USER_MESSAGE_FADE_HEIGHT_REM = 1.75; diff --git a/apps/web/src/components/preview/PreviewChromeRow.tsx b/apps/web/src/components/preview/PreviewChromeRow.tsx index 2780d6bf409311aa47e0a6d4d7e6a45d3139b0a1..cdb46501de0551268b83042e913758cd70a121df 100644 --- a/apps/web/src/components/preview/PreviewChromeRow.tsx +++ b/apps/web/src/components/preview/PreviewChromeRow.tsx @@ -1,4 +1,11 @@ -import { ArrowLeft, ArrowRight, ExternalLink, Globe, RotateCw } from "lucide-react"; +import { + ArrowLeft, + ArrowRight, + ExternalLink, + Globe, + MousePointerClick, + RotateCw, +} from "lucide-react"; import { type FormEvent, type KeyboardEvent, @@ -29,6 +36,16 @@ interface Props { onSubmit: (url: string) => void; /** When provided, renders an "Open in browser" affordance to the right. */ onOpenInBrowser?: (() => void) | undefined; + /** + * When provided, renders a "Select element" toggle button to the right of + * the URL input. Pressed while a pick is active (button shows in `pressed` + * state). Disabled in `pickDisabled` mode. + */ + onPickElement?: (() => void) | undefined; + pickActive?: boolean | undefined; + pickDisabled?: boolean | undefined; + /** Optional reason string surfaced in the disabled tooltip. */ + pickDisabledReason?: string | undefined; /** * Trailing slot rendered after the URL input. Used by the preview view * to mount the three-dot menu (hard reload, devtools, zoom, clear data). @@ -52,6 +69,10 @@ export function PreviewChromeRow({ onRefresh, onSubmit, onOpenInBrowser, + onPickElement, + pickActive, + pickDisabled, + pickDisabledReason, trailingActions, }: Props) { const inputRef = useRef(null); @@ -162,6 +183,32 @@ export function PreviewChromeRow({ /> + {onPickElement ? ( + + + } + > + + + + {pickDisabled && pickDisabledReason + ? pickDisabledReason + : pickActive + ? "Cancel pick (Esc)" + : "Select element to attach"} + + + ) : null} {onOpenInBrowser ? ( selectThreadPreviewState(state.byThreadKey, threadRef), ); const rememberUrl = usePreviewStateStore((state) => state.rememberUrl); + const addElementContext = useComposerDraftStore((store) => store.addElementContext); usePreviewSession(threadRef); + useEffect(() => { + isMountedRef.current = true; + return () => { + isMountedRef.current = false; + }; + }, []); + const { snapshot, desktopOverlay } = previewState; const tabId = snapshot?.tabId ?? null; const navStatus = snapshot?.navStatus ?? { _tag: "Idle" as const }; @@ -101,6 +114,67 @@ export function PreviewView({ threadRef, configuredUrls, visible }: Props) { void localApi.shell.openExternal(url).catch(() => undefined); }, [url]); + const handlePickElement = useCallback(() => { + if (!previewBridge || !tabId) return; + if (pickActiveRef.current) { + void previewBridge.cancelPickElement(tabId).catch(() => undefined); + return; + } + // Snapshot whatever the user was focused on (typically the chat + // composer textarea or the chrome-row pick button) BEFORE main steals + // focus into the guest webContents. We restore it when the pick + // resolves so the user's typing context isn't lost — otherwise after + // every pick they'd have to click back into the textarea. + const previouslyFocused = + typeof document !== "undefined" ? (document.activeElement as HTMLElement | null) : null; + pickActiveRef.current = true; + setPickActive(true); + void (async () => { + try { + const payload = await previewBridge.pickElement(tabId); + if (!payload) return; + const selection = normalizeElementContextSelection(payload); + if (!selection) return; + addElementContext(threadRef, selection); + } catch { + // Picker failed (e.g. webview navigated). Treat as silent cancel. + } finally { + pickActiveRef.current = false; + // Avoid `setState on unmounted component` if the panel/thread closed + // while the pick was in flight. + if (isMountedRef.current) setPickActive(false); + // Best-effort: restore focus to whatever the user had before the + // pick stole it into the guest webContents. Skip if the previously- + // focused element was unmounted or is no longer focusable. + if ( + previouslyFocused && + previouslyFocused.isConnected && + typeof previouslyFocused.focus === "function" + ) { + try { + previouslyFocused.focus({ preventScroll: true }); + } catch { + // Some elements throw on .focus() (detached iframes, etc.). + } + } + } + })(); + }, [addElementContext, tabId, threadRef]); + + // If the active tab changes mid-pick (close, thread switch, hot restart), + // tell main to tear down the in-flight session AND reset our local toggle + // state so the button doesn't get stuck pressed against a stale tab id. + useEffect(() => { + return () => { + if (!pickActiveRef.current) return; + pickActiveRef.current = false; + if (previewBridge && tabId) { + void previewBridge.cancelPickElement(tabId).catch(() => undefined); + } + if (isMountedRef.current) setPickActive(false); + }; + }, [tabId]); + // Subscribe only while visible; `toggle-panel` is owned by ChatView's // URL-aware handler regardless of whether the panel is currently mounted. useEffect(() => { @@ -146,6 +220,15 @@ export function PreviewView({ threadRef, configuredUrls, visible }: Props) { onRefresh={handleRefresh} onSubmit={(next) => void handleSubmitUrl(next)} onOpenInBrowser={tabId ? handleOpenInBrowser : undefined} + onPickElement={previewBridge && tabId ? handlePickElement : undefined} + pickActive={pickActive} + // Disable when there's no tab (nothing to pick on) OR the page + // failed to load (a React overlay covers the webview, so the + // user wouldn't be able to actually click anything underneath). + pickDisabled={!tabId || isUnreachable} + pickDisabledReason={ + isUnreachable ? "Page didn't load — pick unavailable until the page renders" : undefined + } trailingActions={ previewBridge ? ( ) : null} - {isUnreachable && navStatus._tag === "LoadFailed" ? ( + {navStatus._tag === "LoadFailed" ? (
void; getWebContentsId: () => number; } @@ -44,7 +50,7 @@ declare global { * after mount throws. */ export function PreviewWebview({ threadRef, tabId, initialUrl, className }: Props) { - const [config, setConfig] = useState<{ partition: string } | null>(null); + const [config, setConfig] = useState(null); const webviewRef = useRef(null); // Capture once at mount; never re-derived from `initialUrl` props later. const initialSrcRef = useRef(initialUrl ?? "about:blank"); @@ -57,11 +63,11 @@ export function PreviewWebview({ threadRef, tabId, initialUrl, className }: Prop useEffect(() => { if (!bridge) return; let cancelled = false; - void bridge - .getBrowserPartition() - .then((partition) => { + bridge + .getPreviewConfig() + .then((next) => { if (cancelled) return; - setConfig({ partition }); + setConfig(next); }) .catch(() => undefined); return () => { @@ -69,6 +75,21 @@ export function PreviewWebview({ threadRef, tabId, initialUrl, className }: Prop }; }, [bridge]); + // Stable callback ref so React doesn't re-invoke the closure on every + // commit (otherwise we'd be re-asserting `allowpopups` and re-storing the + // ref pointer per render, which is harmless but wasteful). + const setWebviewRef = useCallback((node: HTMLElement | null) => { + webviewRef.current = node as ElectronWebview | null; + if (node && !node.hasAttribute("allowpopups")) { + // React's @types declare `allowpopups` as `boolean`, but the runtime + // doesn't list in its boolean-attribute allowlist, so + // passing it via JSX triggers a "received true for a non-boolean + // attribute" warning. Setting it imperatively bypasses React's prop + // normalization entirely. + node.setAttribute("allowpopups", "true"); + } + }, []); + useEffect(() => { if (!bridge || !config) return; const webview = webviewRef.current; @@ -103,12 +124,17 @@ export function PreviewWebview({ threadRef, tabId, initialUrl, className }: Prop if (!isElectron || !bridge || !config) return null; + // `preload` and `webpreferences` are HTML attributes Electron only reads + // at element-attach time — a state update later in the lifecycle would + // have no effect. The renderer-attached values are also enforced by the + // main-process `will-attach-webview` validator (defense in depth). return ( diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index e6caedaa2f945ddf427c45757405760309379a83..37470af4a6bab960645894d8ad5b8f1608df4c47 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -522,6 +522,99 @@ describe("composerDraftStore terminal contexts", () => { }); }); +describe("composerDraftStore element contexts", () => { + const threadId = ThreadId.make("thread-element"); + const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); + const baseSelection = { + pageUrl: "https://example.com/dashboard", + pageTitle: "Dashboard", + tagName: "button", + selector: "button.submit", + htmlPreview: "", + componentName: "SubmitButton", + source: { + functionName: "SubmitButton", + fileName: "/repo/Button.tsx", + lineNumber: 12, + columnNumber: 5, + }, + styles: ".submit { color: white; }", + } as const; + + beforeEach(() => { + resetComposerDraftStore(); + }); + + it("adds an element context and stamps id + threadId + pickedAt", () => { + const accepted = useComposerDraftStore.getState().addElementContext(threadRef, baseSelection); + expect(accepted).toBe(true); + const draft = draftFor(threadId, TEST_ENVIRONMENT_ID); + expect(draft?.elementContexts).toHaveLength(1); + const entry = draft?.elementContexts[0]!; + expect(entry.id.startsWith("el_")).toBe(true); + expect(entry.threadId).toBe(threadId); + expect(entry.pickedAt.length).toBeGreaterThan(0); + expect(entry.componentName).toBe("SubmitButton"); + }); + + it("dedupes by selector + tag + componentName + pageUrl signature", () => { + const store = useComposerDraftStore.getState(); + expect(store.addElementContext(threadRef, baseSelection)).toBe(true); + const second = store.addElementContext(threadRef, { + ...baseSelection, + htmlPreview: "", + }); + expect(second).toBe(false); + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.elementContexts).toHaveLength(1); + }); + + it("removeElementContext drops by id + leaves siblings intact", () => { + const store = useComposerDraftStore.getState(); + store.addElementContext(threadRef, baseSelection); + store.addElementContext(threadRef, { ...baseSelection, selector: "button.cancel" }); + const ids = draftFor(threadId, TEST_ENVIRONMENT_ID)!.elementContexts.map((c) => c.id); + store.removeElementContext(threadRef, ids[0]!); + const remaining = draftFor(threadId, TEST_ENVIRONMENT_ID)?.elementContexts; + expect(remaining?.map((c) => c.id)).toEqual([ids[1]]); + }); + + it("setElementContexts replaces the slice and clearComposerContent wipes it", () => { + const store = useComposerDraftStore.getState(); + store.addElementContext(threadRef, baseSelection); + store.setElementContexts(threadRef, []); + // Fully empty draft should be removed via shouldRemoveDraft. + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)).toBeUndefined(); + + store.addElementContext(threadRef, baseSelection); + store.clearComposerContent(threadRef); + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)).toBeUndefined(); + }); + + it("persists element contexts via the partializer (round-trippable)", () => { + useComposerDraftStore.getState().addElementContext(threadRef, baseSelection); + const persistApi = useComposerDraftStore.persist as unknown as { + getOptions: () => { + partialize: (state: ReturnType) => unknown; + }; + }; + const persisted = persistApi.getOptions().partialize(useComposerDraftStore.getState()) as { + draftsByThreadKey?: Record> }>; + }; + const entry = + persisted.draftsByThreadKey?.[threadKeyFor(threadId, TEST_ENVIRONMENT_ID)] + ?.elementContexts?.[0]; + expect(entry).toMatchObject({ + pageUrl: baseSelection.pageUrl, + tagName: baseSelection.tagName, + selector: baseSelection.selector, + componentName: baseSelection.componentName, + }); + // Persistence does NOT include htmlPreview / styles oversize-clamping — + // that happens at normalization time, before the value reaches the store. + expect(typeof entry?.htmlPreview).toBe("string"); + }); +}); + describe("composerDraftStore project draft thread mapping", () => { const projectId = ProjectId.make("project-a"); const otherProjectId = ProjectId.make("project-b"); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 3de3b5d706dd0c56a8e5e5a68193118f55cec166..d7f4e2d20493ff1eb2d7cda87845891e6efe6332 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -36,6 +36,12 @@ import { ensureInlineTerminalContextPlaceholders, normalizeTerminalContextText, } from "./lib/terminalContext"; +import { + type ElementContextDraft, + type ElementContextSelection, + elementContextDedupKey, + newElementContextId, +} from "./lib/elementContext"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; import { useShallow } from "zustand/react/shallow"; @@ -92,10 +98,33 @@ const PersistedTerminalContextDraft = Schema.Struct({ }); type PersistedTerminalContextDraft = typeof PersistedTerminalContextDraft.Type; +const PersistedElementContextStackFrame = Schema.Struct({ + functionName: Schema.NullOr(Schema.String), + fileName: Schema.NullOr(Schema.String), + lineNumber: Schema.NullOr(Schema.Number), + columnNumber: Schema.NullOr(Schema.Number), +}); + +const PersistedElementContextDraft = Schema.Struct({ + id: Schema.String, + threadId: ThreadId, + pickedAt: Schema.String, + pageUrl: Schema.String, + pageTitle: Schema.NullOr(Schema.String), + tagName: Schema.String, + selector: Schema.NullOr(Schema.String), + htmlPreview: Schema.String, + componentName: Schema.NullOr(Schema.String), + source: Schema.NullOr(PersistedElementContextStackFrame), + styles: Schema.String, +}); +type PersistedElementContextDraft = typeof PersistedElementContextDraft.Type; + const PersistedComposerThreadDraftState = Schema.Struct({ prompt: Schema.String, attachments: Schema.Array(PersistedComposerImageAttachment), terminalContexts: Schema.optionalKey(Schema.Array(PersistedTerminalContextDraft)), + elementContexts: Schema.optionalKey(Schema.Array(PersistedElementContextDraft)), // Keyed by `ProviderInstanceId` (open branded slug) so custom provider // instances (e.g. `codex_personal`) round-trip alongside the built-in // `codex` / `claudeAgent` / ... entries. Every prior `ProviderDriverKind` @@ -216,6 +245,13 @@ export interface ComposerThreadDraftState { nonPersistedImageIds: string[]; persistedAttachments: PersistedComposerImageAttachment[]; terminalContexts: TerminalContextDraft[]; + /** + * Element-pick attachments captured from the in-app preview browser. The + * full payload (selector / html / styles / source frame) is persisted + * inline because — unlike terminal contexts — there's no live session to + * re-derive the snapshot from on reload. + */ + elementContexts: ElementContextDraft[]; /** * Per-instance model selection. Keyed by `ProviderInstanceId` (open * branded slug) so a default `codex` instance and a user-authored @@ -397,6 +433,24 @@ interface ComposerDraftStoreState { addTerminalContexts: (threadRef: ComposerThreadTarget, contexts: TerminalContextDraft[]) => void; removeTerminalContext: (threadRef: ComposerThreadTarget, contextId: string) => void; clearTerminalContexts: (threadRef: ComposerThreadTarget) => void; + /** + * Append a fresh element pick to the draft. Returns true when accepted, + * false when deduped against an existing pick of the same element. + */ + addElementContext: ( + threadRef: ComposerThreadTarget, + selection: ElementContextSelection, + ) => boolean; + /** + * Replace the entire element-contexts list (used by send-failure retry to + * restore the pre-send snapshot). + */ + setElementContexts: ( + threadRef: ComposerThreadTarget, + contexts: ReadonlyArray, + ) => void; + removeElementContext: (threadRef: ComposerThreadTarget, contextId: string) => void; + clearElementContexts: (threadRef: ComposerThreadTarget) => void; clearPersistedAttachments: (threadRef: ComposerThreadTarget) => void; syncPersistedAttachments: ( threadRef: ComposerThreadTarget, @@ -472,9 +526,11 @@ const EMPTY_IMAGES: ComposerImageAttachment[] = []; const EMPTY_IDS: string[] = []; const EMPTY_PERSISTED_ATTACHMENTS: PersistedComposerImageAttachment[] = []; const EMPTY_TERMINAL_CONTEXTS: TerminalContextDraft[] = []; +const EMPTY_ELEMENT_CONTEXTS: ElementContextDraft[] = []; Object.freeze(EMPTY_IMAGES); Object.freeze(EMPTY_IDS); Object.freeze(EMPTY_PERSISTED_ATTACHMENTS); +Object.freeze(EMPTY_ELEMENT_CONTEXTS); const EMPTY_MODEL_SELECTION_BY_PROVIDER: Partial> = Object.freeze({}); const EMPTY_COMPOSER_DRAFT_MODEL_STATE = Object.freeze({ @@ -488,19 +544,27 @@ const EMPTY_THREAD_DRAFT = Object.freeze({ nonPersistedImageIds: EMPTY_IDS, persistedAttachments: EMPTY_PERSISTED_ATTACHMENTS, terminalContexts: EMPTY_TERMINAL_CONTEXTS, + elementContexts: EMPTY_ELEMENT_CONTEXTS, modelSelectionByProvider: EMPTY_MODEL_SELECTION_BY_PROVIDER, activeProvider: null, runtimeMode: null, interactionMode: null, }); -function createEmptyThreadDraft(): ComposerThreadDraftState { +/** + * Canonical factory for a blank `ComposerThreadDraftState`. Exported so tests + * (and any other call sites) can build a draft without re-declaring every + * slice — adding a new field to the interface (e.g. `elementContexts`) only + * has to be reflected here, not in every stub. + */ +export function createEmptyThreadDraft(): ComposerThreadDraftState { return { prompt: "", images: [], nonPersistedImageIds: [], persistedAttachments: [], terminalContexts: [], + elementContexts: [], modelSelectionByProvider: {}, activeProvider: null, runtimeMode: null, @@ -571,6 +635,7 @@ function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean { draft.images.length === 0 && draft.persistedAttachments.length === 0 && draft.terminalContexts.length === 0 && + draft.elementContexts.length === 0 && Object.keys(draft.modelSelectionByProvider).length === 0 && draft.activeProvider === null && draft.runtimeMode === null && @@ -964,6 +1029,63 @@ function normalizePersistedAttachment(value: unknown): PersistedComposerImageAtt }; } +function normalizePersistedElementContextDraft( + value: unknown, +): PersistedElementContextDraft | null { + if (!value || typeof value !== "object") return null; + const candidate = value as Record; + const id = candidate.id; + const threadId = candidate.threadId; + const pickedAt = candidate.pickedAt; + const pageUrl = candidate.pageUrl; + const tagName = candidate.tagName; + if ( + typeof id !== "string" || + id.length === 0 || + typeof threadId !== "string" || + threadId.length === 0 || + typeof pickedAt !== "string" || + pickedAt.length === 0 || + typeof pageUrl !== "string" || + pageUrl.length === 0 || + typeof tagName !== "string" || + tagName.length === 0 + ) { + return null; + } + const sourceCandidate = candidate.source; + let source: PersistedElementContextDraft["source"] = null; + if (sourceCandidate && typeof sourceCandidate === "object") { + const sourceRecord = sourceCandidate as Record; + source = { + functionName: + typeof sourceRecord.functionName === "string" ? sourceRecord.functionName : null, + fileName: typeof sourceRecord.fileName === "string" ? sourceRecord.fileName : null, + lineNumber: + typeof sourceRecord.lineNumber === "number" && Number.isFinite(sourceRecord.lineNumber) + ? sourceRecord.lineNumber + : null, + columnNumber: + typeof sourceRecord.columnNumber === "number" && Number.isFinite(sourceRecord.columnNumber) + ? sourceRecord.columnNumber + : null, + }; + } + return { + id, + threadId: threadId as ThreadId, + pickedAt, + pageUrl, + pageTitle: typeof candidate.pageTitle === "string" ? candidate.pageTitle : null, + tagName, + selector: typeof candidate.selector === "string" ? candidate.selector : null, + htmlPreview: typeof candidate.htmlPreview === "string" ? candidate.htmlPreview : "", + componentName: typeof candidate.componentName === "string" ? candidate.componentName : null, + source, + styles: typeof candidate.styles === "string" ? candidate.styles : "", + }; +} + function normalizePersistedTerminalContextDraft( value: unknown, ): PersistedTerminalContextDraft | null { @@ -1478,6 +1600,12 @@ function normalizePersistedDraftsByThreadId( return normalized ? [normalized] : []; }) : []; + const elementContexts = Array.isArray(draftCandidate.elementContexts) + ? draftCandidate.elementContexts.flatMap((entry) => { + const normalized = normalizePersistedElementContextDraft(entry); + return normalized ? [normalized] : []; + }) + : []; const runtimeMode = isRuntimeMode(draftCandidate.runtimeMode) ? draftCandidate.runtimeMode : null; @@ -1541,6 +1669,7 @@ function normalizePersistedDraftsByThreadId( promptCandidate.length === 0 && attachments.length === 0 && terminalContexts.length === 0 && + elementContexts.length === 0 && !hasModelData && !runtimeMode && !interactionMode @@ -1563,6 +1692,7 @@ function normalizePersistedDraftsByThreadId( prompt, attachments, ...(terminalContexts.length > 0 ? { terminalContexts } : {}), + ...(elementContexts.length > 0 ? { elementContexts } : {}), ...(hasModelData ? { modelSelectionByProvider: compactModelSelectionByProvider(modelSelectionByProvider), @@ -1645,6 +1775,7 @@ function partializeComposerDraftStoreState( draft.prompt.length === 0 && draft.persistedAttachments.length === 0 && draft.terminalContexts.length === 0 && + draft.elementContexts.length === 0 && !hasModelData && draft.runtimeMode === null && draft.interactionMode === null @@ -1667,6 +1798,23 @@ function partializeComposerDraftStoreState( })), } : {}), + ...(draft.elementContexts.length > 0 + ? { + elementContexts: draft.elementContexts.map((context) => ({ + id: context.id, + threadId: context.threadId, + pickedAt: context.pickedAt, + pageUrl: context.pageUrl, + pageTitle: context.pageTitle, + tagName: context.tagName, + selector: context.selector, + htmlPreview: context.htmlPreview, + componentName: context.componentName, + source: context.source, + styles: context.styles, + })), + } + : {}), ...(hasModelData ? { modelSelectionByProvider: compactModelSelectionByProvider( @@ -1903,6 +2051,10 @@ function toHydratedThreadDraft( ...context, text: "", })) ?? [], + elementContexts: + persistedDraft.elementContexts?.map((context) => ({ + ...context, + })) ?? [], modelSelectionByProvider, activeProvider, runtimeMode: persistedDraft.runtimeMode ?? null, @@ -2815,6 +2967,96 @@ const composerDraftStore = create()( return { draftsByThreadKey: nextDraftsByThreadKey }; }); }, + addElementContext: (threadRef, selection) => { + const threadKey = resolveComposerDraftKey(get(), threadRef); + const threadId = resolveComposerThreadId(get(), threadRef); + if (!threadKey || !threadId) return false; + let accepted = false; + set((state) => { + const existing = state.draftsByThreadKey[threadKey] ?? createEmptyThreadDraft(); + const dedupKey = elementContextDedupKey(selection); + if ( + existing.elementContexts.some((entry) => elementContextDedupKey(entry) === dedupKey) + ) { + return state; + } + accepted = true; + const draft: ElementContextDraft = { + ...selection, + id: newElementContextId(), + threadId, + pickedAt: new Date().toISOString(), + }; + return { + draftsByThreadKey: { + ...state.draftsByThreadKey, + [threadKey]: { + ...existing, + elementContexts: [...existing.elementContexts, draft], + }, + }, + }; + }); + return accepted; + }, + setElementContexts: (threadRef, contexts) => { + const threadKey = resolveComposerDraftKey(get(), threadRef); + if (!threadKey) return; + set((state) => { + const existing = state.draftsByThreadKey[threadKey] ?? createEmptyThreadDraft(); + const nextDraft: ComposerThreadDraftState = { + ...existing, + elementContexts: [...contexts], + }; + const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; + if (shouldRemoveDraft(nextDraft)) { + delete nextDraftsByThreadKey[threadKey]; + } else { + nextDraftsByThreadKey[threadKey] = nextDraft; + } + return { draftsByThreadKey: nextDraftsByThreadKey }; + }); + }, + removeElementContext: (threadRef, contextId) => { + const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; + if (threadKey.length === 0 || contextId.length === 0) return; + set((state) => { + const current = state.draftsByThreadKey[threadKey]; + if (!current) return state; + const filtered = current.elementContexts.filter((entry) => entry.id !== contextId); + if (filtered.length === current.elementContexts.length) return state; + const nextDraft: ComposerThreadDraftState = { + ...current, + elementContexts: filtered, + }; + const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; + if (shouldRemoveDraft(nextDraft)) { + delete nextDraftsByThreadKey[threadKey]; + } else { + nextDraftsByThreadKey[threadKey] = nextDraft; + } + return { draftsByThreadKey: nextDraftsByThreadKey }; + }); + }, + clearElementContexts: (threadRef) => { + const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; + if (threadKey.length === 0) return; + set((state) => { + const current = state.draftsByThreadKey[threadKey]; + if (!current || current.elementContexts.length === 0) return state; + const nextDraft: ComposerThreadDraftState = { + ...current, + elementContexts: [], + }; + const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; + if (shouldRemoveDraft(nextDraft)) { + delete nextDraftsByThreadKey[threadKey]; + } else { + nextDraftsByThreadKey[threadKey] = nextDraft; + } + return { draftsByThreadKey: nextDraftsByThreadKey }; + }); + }, clearPersistedAttachments: (threadRef) => { const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; if (threadKey.length === 0) { @@ -2887,6 +3129,7 @@ const composerDraftStore = create()( nonPersistedImageIds: [], persistedAttachments: [], terminalContexts: [], + elementContexts: [], }; const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; if (shouldRemoveDraft(nextDraft)) { diff --git a/apps/web/src/lib/elementContext.test.ts b/apps/web/src/lib/elementContext.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..d3fa3f2215d8d48093b31be052dbcf5952ff8217 --- /dev/null +++ b/apps/web/src/lib/elementContext.test.ts @@ -0,0 +1,294 @@ +import type { PickedElementPayload } from "@t3tools/contracts"; +import { describe, expect, it } from "vitest"; + +import { + appendElementContextsToPrompt, + buildElementContextBlock, + type ElementContextSelection, + elementContextDedupKey, + extractTrailingElementContexts, + formatElementContextLabel, + formatElementContextSourceLabel, + newElementContextId, + normalizeElementContextSelection, +} from "./elementContext"; + +function makePayload(overrides?: Partial): PickedElementPayload { + return { + pageUrl: "https://example.com/dashboard", + pageTitle: "Dashboard", + tagName: "BUTTON", + selector: "button.submit", + htmlPreview: '', + componentName: "SubmitButton", + source: { + functionName: "SubmitButton", + fileName: "/repo/src/Button.tsx", + lineNumber: 12, + columnNumber: 5, + }, + stack: [ + { + functionName: "SubmitButton", + fileName: "/repo/src/Button.tsx", + lineNumber: 12, + columnNumber: 5, + }, + ], + styles: ".submit { color: white; }", + pickedAt: "2026-05-03T18:00:00.000Z", + ...overrides, + }; +} + +function makeSelection(overrides?: Partial): ElementContextSelection { + return { + pageUrl: "https://example.com/dashboard", + pageTitle: "Dashboard", + tagName: "button", + selector: "button.submit", + htmlPreview: '', + componentName: "SubmitButton", + source: { + functionName: "SubmitButton", + fileName: "/repo/src/Button.tsx", + lineNumber: 12, + columnNumber: 5, + }, + styles: ".submit { color: white; }", + ...overrides, + }; +} + +describe("normalizeElementContextSelection", () => { + it("lowercases the tag, trims strings, and prefers `source` over `stack[0]`", () => { + const result = normalizeElementContextSelection( + makePayload({ + tagName: " Button ", + pageUrl: " https://example.com ", + pageTitle: " Dashboard ", + selector: " ", + componentName: " ", + source: { + functionName: " Outer ", + fileName: " /repo/Outer.tsx ", + lineNumber: 7, + columnNumber: 0, + }, + stack: [ + { + functionName: "Inner", + fileName: "/repo/Inner.tsx", + lineNumber: 99, + columnNumber: 9, + }, + ], + }), + ); + expect(result).not.toBeNull(); + expect(result?.tagName).toBe("button"); + expect(result?.pageUrl).toBe("https://example.com"); + expect(result?.pageTitle).toBe("Dashboard"); + expect(result?.selector).toBeNull(); + expect(result?.componentName).toBeNull(); + expect(result?.source).toEqual({ + functionName: "Outer", + fileName: "/repo/Outer.tsx", + lineNumber: 7, + columnNumber: 0, + }); + }); + + it("returns null when pageUrl or tagName is empty", () => { + expect(normalizeElementContextSelection(makePayload({ pageUrl: "" }))).toBeNull(); + expect(normalizeElementContextSelection(makePayload({ tagName: " " }))).toBeNull(); + }); + + it("clamps oversized htmlPreview / styles so we don't blow localStorage", () => { + const huge = "x".repeat(10_000); + const result = normalizeElementContextSelection( + makePayload({ htmlPreview: huge, styles: huge }), + ); + expect(result).not.toBeNull(); + expect(result!.htmlPreview.length).toBeLessThanOrEqual(4000); + expect(result!.styles.length).toBeLessThanOrEqual(4000); + // Truncated values should end with the ellipsis sentinel + expect(result!.htmlPreview.endsWith("…")).toBe(true); + expect(result!.styles.endsWith("…")).toBe(true); + }); + + it("normalizes Windows line endings inside html/styles", () => { + const result = normalizeElementContextSelection( + makePayload({ htmlPreview: "\r\nhi\r\n", styles: ".a {\r\n color: red;\r\n}" }), + ); + expect(result?.htmlPreview).toBe("\nhi\n"); + expect(result?.styles).toBe(".a {\n color: red;\n}"); + }); + + it("falls back to stack[0] when payload.source is null", () => { + const result = normalizeElementContextSelection( + makePayload({ + source: null, + stack: [ + { + functionName: "FromStack", + fileName: "/repo/FromStack.tsx", + lineNumber: 3, + columnNumber: null, + }, + ], + }), + ); + expect(result?.source).toEqual({ + functionName: "FromStack", + fileName: "/repo/FromStack.tsx", + lineNumber: 3, + columnNumber: null, + }); + }); +}); + +describe("formatElementContextLabel", () => { + it("prefers component name over tag name", () => { + expect(formatElementContextLabel(makeSelection())).toBe(""); + }); + + it("falls back to tag name when no component name is present", () => { + expect(formatElementContextLabel(makeSelection({ componentName: null }))).toBe("', + " styles:", + " .submit { color: white; }", + "", + ].join("\n"), + ); + }); + + it("emits no leading blank when prompt is empty", () => { + expect( + appendElementContextsToPrompt("", [makeSelection()]).startsWith(""), + ).toBe(true); + }); +}); + +describe("extractTrailingElementContexts", () => { + it("round-trips appendElementContextsToPrompt and recovers prompt + entries", () => { + const prompt = appendElementContextsToPrompt("Investigate this", [ + makeSelection(), + makeSelection({ selector: "button.cancel", componentName: "CancelButton" }), + ]); + const result = extractTrailingElementContexts(prompt); + expect(result.promptText).toBe("Investigate this"); + expect(result.contextCount).toBe(2); + expect(result.contexts.map((c) => c.header)).toEqual([ + " (Button.tsx:12)", + " (Button.tsx:12)", + ]); + expect(result.contexts[0]?.body).toContain("url: https://example.com/dashboard"); + expect(result.contexts[0]?.body).toContain("selector: button.submit"); + }); + + it("returns the original prompt unchanged when no trailing block exists", () => { + expect(extractTrailingElementContexts("hi")).toEqual({ + promptText: "hi", + contextCount: 0, + contexts: [], + }); + }); +}); + +describe("newElementContextId", () => { + it("returns a non-empty string with the element prefix", () => { + const id = newElementContextId(); + expect(id.startsWith("el_")).toBe(true); + expect(id.length).toBeGreaterThan(3); + }); + + it("returns unique ids on repeated calls", () => { + const ids = new Set(Array.from({ length: 10 }, () => newElementContextId())); + expect(ids.size).toBe(10); + }); +}); diff --git a/apps/web/src/lib/elementContext.ts b/apps/web/src/lib/elementContext.ts new file mode 100644 index 0000000000000000000000000000000000000000..b5f7d9342f7859beaa0fb47a044ceafc01c4859a --- /dev/null +++ b/apps/web/src/lib/elementContext.ts @@ -0,0 +1,245 @@ +import { type ThreadId } from "@t3tools/contracts"; +import type { PickedElementPayload, PickedElementStackFrame } from "@t3tools/contracts"; + +const ELEMENT_CONTEXT_HTML_PREVIEW_LIMIT = 4000; +const ELEMENT_CONTEXT_STYLES_LIMIT = 4000; +const ELEMENT_CONTEXT_LABEL_TAG_MAX = 24; + +const TRAILING_ELEMENT_CONTEXT_BLOCK_PATTERN = + /\n*\n([\s\S]*?)\n<\/element_context>\s*$/; + +/** + * Stable, persistable element selection captured from the in-app preview + * browser. We deliberately keep the shape JSON-serializable so it can ride + * through `localStorage` persistence, draft restoration, and transcript + * snapshots without bespoke marshalling. + */ +export interface ElementContextSelection { + /** Page URL where the element was picked. */ + pageUrl: string; + /** Best-effort ``. */ + pageTitle: string | null; + /** Lowercase tag, e.g. `"button"`. */ + tagName: string; + /** CSS selector — may be null when react-grab can't compute one. */ + selector: string | null; + /** Truncated outer-HTML preview. */ + htmlPreview: string; + /** Nearest React component display name, or null. */ + componentName: string | null; + /** Source frame (file + line) — null when unavailable. */ + source: PickedElementStackFrame | null; + /** Author CSS (no UA defaults). May be empty. */ + styles: string; +} + +export interface ElementContextDraft extends ElementContextSelection { + /** Stable composer-side id used for keyed rendering + dedupe. */ + id: string; + threadId: ThreadId; + /** ISO-8601 wall clock pick time. */ + pickedAt: string; +} + +export interface ParsedElementContextEntry { + header: string; + body: string; +} + +export interface ExtractedElementContexts { + promptText: string; + contextCount: number; + contexts: ParsedElementContextEntry[]; +} + +function truncateString(value: string, limit: number): string { + if (value.length <= limit) return value; + return `${value.slice(0, Math.max(0, limit - 1))}…`; +} + +function normalizeText(value: string): string { + return value.replace(/\r\n/g, "\n").replace(/^\n+|\n+$/g, ""); +} + +/** + * Sanitize a payload coming back from the desktop bridge before it lands in + * the composer draft. Trims/clamps every string field so we never persist a + * 5MB outerHTML blob and silently break `localStorage`. + */ +export function normalizeElementContextSelection( + raw: PickedElementPayload, +): ElementContextSelection | null { + const pageUrl = raw.pageUrl.trim(); + const tagName = raw.tagName.trim().toLowerCase(); + if (pageUrl.length === 0 || tagName.length === 0) { + return null; + } + const stackFrame = raw.source ?? raw.stack[0] ?? null; + return { + pageUrl, + pageTitle: raw.pageTitle?.trim() ?? null, + tagName, + selector: raw.selector?.trim() || null, + htmlPreview: truncateString(normalizeText(raw.htmlPreview), ELEMENT_CONTEXT_HTML_PREVIEW_LIMIT), + componentName: raw.componentName?.trim() || null, + source: stackFrame + ? { + functionName: stackFrame.functionName?.trim() || null, + fileName: stackFrame.fileName?.trim() || null, + lineNumber: stackFrame.lineNumber ?? null, + columnNumber: stackFrame.columnNumber ?? null, + } + : null, + styles: truncateString(normalizeText(raw.styles), ELEMENT_CONTEXT_STYLES_LIMIT), + }; +} + +/** + * Stable dedupe key. Two picks of the same element on the same page produce + * the same key, so we don't end up with a runaway chip row from spam-clicks. + */ +export function elementContextDedupKey(context: ElementContextSelection): string { + return [context.pageUrl, context.selector ?? "", context.tagName, context.componentName ?? ""] + .join("|") + .toLowerCase(); +} + +function shortenTagLabel(tagName: string): string { + if (tagName.length <= ELEMENT_CONTEXT_LABEL_TAG_MAX) return tagName; + return `${tagName.slice(0, ELEMENT_CONTEXT_LABEL_TAG_MAX - 1)}…`; +} + +/** + * Compact chip label — `<Button>` for component picks, `<button>` otherwise. + * Component name takes priority because it's higher-signal for the agent. + */ +export function formatElementContextLabel(context: ElementContextSelection): string { + if (context.componentName) return `<${context.componentName}>`; + return `<${shortenTagLabel(context.tagName)}>`; +} + +function basenameFromPath(filePath: string): string { + const parts = filePath.split(/[\\/]/); + return parts[parts.length - 1] ?? filePath; +} + +export function formatElementContextSourceLabel(context: ElementContextSelection): string | null { + const source = context.source; + if (!source?.fileName) return null; + const base = basenameFromPath(source.fileName); + if (source.lineNumber == null) return base; + return `${base}:${source.lineNumber}`; +} + +function buildContextHeader(context: ElementContextSelection): string { + const label = formatElementContextLabel(context); + const source = formatElementContextSourceLabel(context); + return source ? `${label} (${source})` : label; +} + +function indentLines(value: string): string[] { + return value.split("\n").map((line) => ` ${line}`); +} + +function buildSingleContextLines(context: ElementContextSelection): string[] { + const lines: string[] = []; + lines.push(`- ${buildContextHeader(context)}:`); + if (context.pageUrl.length > 0) { + lines.push(` url: ${context.pageUrl}`); + } + if (context.selector) { + lines.push(` selector: ${context.selector}`); + } + if (context.source?.fileName) { + const { fileName, lineNumber, columnNumber } = context.source; + const location = + lineNumber != null + ? `${fileName}:${lineNumber}${columnNumber != null ? `:${columnNumber}` : ""}` + : fileName; + lines.push(` source: ${location}`); + } + const html = context.htmlPreview.trim(); + if (html.length > 0) { + lines.push(" html:"); + lines.push(...indentLines(html)); + } + const styles = context.styles.trim(); + if (styles.length > 0) { + lines.push(" styles:"); + lines.push(...indentLines(styles)); + } + return lines; +} + +/** + * Serialize element-context drafts into the `<element_context>` block we + * append to the user's outgoing message text. Mirrors the `<terminal_context>` + * block format so it composes cleanly when both are present. + */ +export function buildElementContextBlock(contexts: ReadonlyArray<ElementContextSelection>): string { + if (contexts.length === 0) return ""; + const lines: string[] = []; + for (let index = 0; index < contexts.length; index += 1) { + const context = contexts[index]!; + lines.push(...buildSingleContextLines(context)); + if (index < contexts.length - 1) lines.push(""); + } + return ["<element_context>", ...lines, "</element_context>"].join("\n"); +} + +export function appendElementContextsToPrompt( + prompt: string, + contexts: ReadonlyArray<ElementContextSelection>, +): string { + const block = buildElementContextBlock(contexts); + if (block.length === 0) return prompt; + const trimmed = prompt.trim(); + return trimmed.length > 0 ? `${trimmed}\n\n${block}` : block; +} + +const ELEMENT_CONTEXT_ID_PREFIX = "el_"; + +export function newElementContextId(): string { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return `${ELEMENT_CONTEXT_ID_PREFIX}${crypto.randomUUID()}`; + } + return `${ELEMENT_CONTEXT_ID_PREFIX}${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`; +} + +/** + * Mirror image of `appendElementContextsToPrompt` for transcript display: + * detects (and strips) a trailing `<element_context>` block so we can render + * the original prompt body and chips separately in user-message bubbles. + */ +export function extractTrailingElementContexts(prompt: string): ExtractedElementContexts { + const match = TRAILING_ELEMENT_CONTEXT_BLOCK_PATTERN.exec(prompt); + if (!match) { + return { promptText: prompt, contextCount: 0, contexts: [] }; + } + const promptText = prompt.slice(0, match.index).replace(/\n+$/, ""); + const contexts = parseElementContextEntries(match[1] ?? ""); + return { promptText, contextCount: contexts.length, contexts }; +} + +function parseElementContextEntries(block: string): ParsedElementContextEntry[] { + const entries: ParsedElementContextEntry[] = []; + let current: { header: string; bodyLines: string[] } | null = null; + const commit = () => { + if (!current) return; + entries.push({ header: current.header, body: current.bodyLines.join("\n").trimEnd() }); + current = null; + }; + for (const line of block.split("\n")) { + const headerMatch = /^- (.+):$/.exec(line); + if (headerMatch) { + commit(); + current = { header: headerMatch[1]!, bodyLines: [] }; + continue; + } + if (!current) continue; + if (line.startsWith(" ")) current.bodyLines.push(line.slice(2)); + else if (line.length === 0) current.bodyLines.push(""); + } + commit(); + return entries; +} diff --git a/apps/web/src/lib/terminalContext.test.ts b/apps/web/src/lib/terminalContext.test.ts index 3215ffc86158bba54d3032dbdecda0245d4a89bc..4b520c9bef4a883b1ebc9e1667bab1dfe114d65b 100644 --- a/apps/web/src/lib/terminalContext.test.ts +++ b/apps/web/src/lib/terminalContext.test.ts @@ -122,6 +122,7 @@ describe("terminalContext", () => { body: "12 | git status\n13 | On branch main", }, ], + elementContexts: [], }); }); diff --git a/apps/web/src/lib/terminalContext.ts b/apps/web/src/lib/terminalContext.ts index ba63fd5a02b9d23e494b8e83c5ffd6f8b6f26ff3..72f49a2f22d2da1dc975402734a9308927ceb997 100644 --- a/apps/web/src/lib/terminalContext.ts +++ b/apps/web/src/lib/terminalContext.ts @@ -1,5 +1,7 @@ import { type ThreadId } from "@t3tools/contracts"; +import { extractTrailingElementContexts, type ParsedElementContextEntry } from "./elementContext"; + export interface TerminalContextSelection { terminalId: string; terminalLabel: string; @@ -27,6 +29,12 @@ export interface DisplayedUserMessageState { contextCount: number; previewTitle: string | null; contexts: ParsedTerminalContextEntry[]; + /** + * Element-context entries extracted from the trailing `<element_context>` + * block (if any). Stripped from `visibleText` so the raw block doesn't + * leak into the user's bubble. + */ + elementContexts: ParsedElementContextEntry[]; } export interface ParsedTerminalContextEntry { @@ -238,13 +246,18 @@ export function extractTrailingTerminalContexts(prompt: string): ExtractedTermin } export function deriveDisplayedUserMessageState(prompt: string): DisplayedUserMessageState { - const extractedContexts = extractTrailingTerminalContexts(prompt); + // Order matters: send-time appends `<terminal_context>` first, then + // `<element_context>` last. Strip element first so the (now-trailing) + // terminal block can be matched by `extractTrailingTerminalContexts`. + const extractedElement = extractTrailingElementContexts(prompt); + const extractedTerminal = extractTrailingTerminalContexts(extractedElement.promptText); return { - visibleText: extractedContexts.promptText, + visibleText: extractedTerminal.promptText, copyText: prompt, - contextCount: extractedContexts.contextCount, - previewTitle: extractedContexts.previewTitle, - contexts: extractedContexts.contexts, + contextCount: extractedTerminal.contextCount, + previewTitle: extractedTerminal.previewTitle, + contexts: extractedTerminal.contexts, + elementContexts: extractedElement.contexts, }; } diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index d003e868de4f5b9a732bbb71e9f6eed2233fc13b..5bb68c6575aa5a428ee173cc2fab5a12fe0d175a 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -441,6 +441,70 @@ export interface DesktopPreviewTabState { updatedAt: string; } +/** + * Static config a renderer needs to mount a preview `<webview>`. Returned + * atomically by `DesktopPreviewBridge.getPreviewConfig()` so the renderer + * doesn't have to wait on three separate IPC round-trips before the webview + * can attach. + */ +export interface DesktopPreviewWebviewConfig { + /** `persist:t3code-preview` (or whatever the desktop chose). */ + partition: string; + /** + * Canonical `<webview webpreferences="...">` string. Encodes the security + * posture (sandboxed but contextIsolation off so the picker preload can + * read the page's React DevTools hook). Always present. + */ + webPreferences: string; + /** + * Absolute `file://`-style URL to the picker preload bundle. Set to null + * when the bundle isn't present (older builds, broken install) — the + * renderer must then disable element-pick affordances. + */ + preloadUrl: string | null; +} + +/** + * Single stack frame captured by react-grab's `getElementContext`. We surface + * the source file/line so coding agents can jump straight to the JSX that + * produced the picked DOM node. + */ +export interface PickedElementStackFrame { + functionName: string | null; + fileName: string | null; + lineNumber: number | null; + columnNumber: number | null; +} + +/** + * A successful element pick from the preview webview. All fields are + * best-effort — pages that don't ship a React fiber tree (or aren't running + * in dev) will still produce a usable payload (selector + html preview), + * just without component / source attribution. + */ +export interface PickedElementPayload { + /** URL of the page the element was picked on. */ + pageUrl: string; + /** Optional `<title>` of that page (best-effort). */ + pageTitle: string | null; + /** Lowercase tag name, e.g. `"button"`. */ + tagName: string; + /** CSS selector resolving back to the element on a re-render. */ + selector: string | null; + /** Truncated outer-HTML preview (matches react-grab's `htmlPreview`). */ + htmlPreview: string; + /** Nearest React component display name, or null when unavailable. */ + componentName: string | null; + /** First source-mapped stack frame (file + line of the JSX source). */ + source: PickedElementStackFrame | null; + /** Full owner-stack frames; can be empty. Useful for richer context. */ + stack: ReadonlyArray<PickedElementStackFrame>; + /** Author CSS only (UA defaults stripped) — react-grab's `styles`. */ + styles: string; + /** Wall-clock pick time as ISO-8601 string. */ + pickedAt: string; +} + export interface DesktopBridge { getAppBranding: () => DesktopAppBranding | null; getLocalEnvironmentBootstrap: () => DesktopEnvironmentBootstrap | null; @@ -525,7 +589,21 @@ export interface DesktopPreviewBridge { clearCookies: () => Promise<void>; /** Drop the HTTP cache for the preview partition (all tabs). */ clearCache: () => Promise<void>; - getBrowserPartition: () => Promise<string>; + /** + * One-shot config for mounting a preview `<webview>`. Replaces three + * earlier round-trip calls (`getBrowserPartition`, `getWebviewPreferences`, + * `getPickPreloadPath`) so adding a new field here only requires touching + * the contract + main, not the renderer's mount logic. + */ + getPreviewConfig: () => Promise<DesktopPreviewWebviewConfig>; + /** + * Activate the in-page element picker for the given tab. Resolves with + * the picked payload, or `null` when the user cancels (Escape / nav). The + * promise rejects if the picker can't be activated (no webview, etc.). + */ + pickElement: (tabId: string) => Promise<PickedElementPayload | null>; + /** Cancel an in-flight `pickElement(tabId)` (renderer-side teardown). */ + cancelPickElement: (tabId: string) => Promise<void>; onStateChange: (listener: (tabId: string, state: DesktopPreviewTabState) => void) => () => void; } -- 2.54.0