authorgravatar for aiden.bai05@gmail.comAiden Bai <aiden.bai05@gmail.com> 2026-05-03 19:56:51-07:00
committergravatar for me@theo.liTheo Browne <me@theo.li> 2026-06-13 22:18:37-07:00
log17fb0e4a9a068f3ba6c55871dd44ed3c5d70aefe
tree4331a3518814b843c2be3e1b43ba2c024fa49cf9
parent43e8bbd7aea2f73a0ea7b7a15af2903a471d5e4a

feat(preview): element-pick attachments + sandboxed picker preload

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 `<element_context>` block in the outgoing message. Architecture: - Per-`<webview>` 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 <webview> 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 `<element_context>` AND `<terminal_context>` 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 `<PreviewUnreachable>` 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).

32 files changed, 2416 insertions(+), 54 deletions(-)

apps/desktop/package.json+2-1
......@@ -20,7 +20,8 @@
2020 "@t3tools/tailscale": "workspace:*",
2121 "effect": "catalog:",
2222 "electron": "41.5.0",
23 "electron-updater": "^6.6.2"
23 "electron-updater": "^6.6.2",
24 "react-grab": "^0.1.32"
2425 },
2526 "devDependencies": {
2627 "@effect/vitest": "catalog:",
apps/desktop/src/ipc/channels.ts+3-1
......@@ -53,5 +53,7 @@ export const PREVIEW_HARD_RELOAD_CHANNEL = "desktop:preview-hard-reload";
5353export const PREVIEW_OPEN_DEVTOOLS_CHANNEL = "desktop:preview-open-devtools";
5454export const PREVIEW_CLEAR_COOKIES_CHANNEL = "desktop:preview-clear-cookies";
5555export const PREVIEW_CLEAR_CACHE_CHANNEL = "desktop:preview-clear-cache";
56export const PREVIEW_GET_BROWSER_PARTITION_CHANNEL = "desktop:preview-get-browser-partition";
56export const PREVIEW_GET_CONFIG_CHANNEL = "desktop:preview-get-config";
57export const PREVIEW_PICK_ELEMENT_CHANNEL = "desktop:preview-pick-element";
58export const PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL = "desktop:preview-cancel-pick-element";
5759export const PREVIEW_STATE_CHANGE_CHANNEL = "desktop:preview-state-change";
apps/desktop/src/ipc/methods/preview.ts+18-1
......@@ -1,7 +1,11 @@
11import * as Effect from "effect/Effect";
22import { BrowserWindow } from "electron";
3import * as NodeFileSystem from "node:fs";
4import * as NodePath from "node:path";
5import { pathToFileURL } from "node:url";
36
47import { previewViewManager } from "../../preview-view-manager.ts";
8import { PREVIEW_WEBVIEW_PREFERENCES } from "../../preview-webview-preferences.ts";
59import * as IpcChannels from "../channels.ts";
610import type { DesktopIpcMethod } from "../DesktopIpc.ts";
711
......@@ -57,5 +61,18 @@ export const previewMethods = [
5761 method(IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, (raw) => previewViewManager.openDevTools(tabIdFrom(raw))),
5862 method(IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL, () => previewViewManager.clearCookies()),
5963 method(IpcChannels.PREVIEW_CLEAR_CACHE_CHANNEL, () => previewViewManager.clearCache()),
60 method(IpcChannels.PREVIEW_GET_BROWSER_PARTITION_CHANNEL, () => previewViewManager.getBrowserPartition()),
64 method(IpcChannels.PREVIEW_GET_CONFIG_CHANNEL, () => {
65 const preloadPath = NodePath.join(__dirname, "preview-pick-preload.cjs");
66 return {
67 partition: previewViewManager.getBrowserPartition(),
68 webPreferences: PREVIEW_WEBVIEW_PREFERENCES,
69 preloadUrl: NodeFileSystem.existsSync(preloadPath) ? pathToFileURL(preloadPath).href : null,
70 };
71 }),
72 method(IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL, (raw) =>
73 previewViewManager.pickElement(tabIdFrom(raw)),
74 ),
75 method(IpcChannels.PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL, (raw) =>
76 previewViewManager.cancelPickElement(tabIdFrom(raw)),
77 ),
6178] as const;
apps/desktop/src/main.ts+2
......@@ -28,6 +28,7 @@ import * as DesktopApp from "./app/DesktopApp.ts";
2828import * as DesktopAppIdentity from "./app/DesktopAppIdentity.ts";
2929import * as DesktopCloudAuth from "./app/DesktopCloudAuth.ts";
3030import * as DesktopCloudAuthTokenStore from "./app/DesktopCloudAuthTokenStore.ts";
31import * as DesktopConnectionCatalogStore from "./app/DesktopConnectionCatalogStore.ts";
3132import * as DesktopApplicationMenu from "./window/DesktopApplicationMenu.ts";
3233import * as DesktopAssets from "./app/DesktopAssets.ts";
3334import * as DesktopBackendConfiguration from "./backend/DesktopBackendConfiguration.ts";
......@@ -114,6 +115,7 @@ const desktopFoundationLayer = Layer.mergeAll(
114115 DesktopClientSettings.layer,
115116 DesktopSavedEnvironments.layer,
116117 DesktopCloudAuthTokenStore.layer,
118 DesktopConnectionCatalogStore.layer,
117119 DesktopAssets.layer,
118120 DesktopObservability.layer,
119121).pipe(Layer.provideMerge(desktopEnvironmentLayer));
apps/desktop/src/picked-element-payload.test.ts created+135
......@@ -0,0 +1,135 @@
1import { describe, expect, it } from "vitest";
2
3import { isPickedElementPayload } from "./picked-element-payload.ts";
4
5function validPayload(overrides?: Record<string, unknown>): Record<string, unknown> {
6 return {
7 pageUrl: "https://example.com/",
8 pageTitle: "Example",
9 tagName: "button",
10 selector: "button.submit",
11 htmlPreview: "<button>Save</button>",
12 componentName: "SubmitButton",
13 source: {
14 functionName: "SubmitButton",
15 fileName: "/repo/src/Button.tsx",
16 lineNumber: 12,
17 columnNumber: 5,
18 },
19 stack: [
20 {
21 functionName: "SubmitButton",
22 fileName: "/repo/src/Button.tsx",
23 lineNumber: 12,
24 columnNumber: 5,
25 },
26 ],
27 styles: ".submit { color: white; }",
28 pickedAt: "2026-05-03T18:00:00.000Z",
29 ...overrides,
30 };
31}
32
33describe("isPickedElementPayload", () => {
34 it("accepts a complete, well-typed payload", () => {
35 expect(isPickedElementPayload(validPayload())).toBe(true);
36 });
37
38 it("accepts nullable string fields when null", () => {
39 expect(
40 isPickedElementPayload(
41 validPayload({ pageTitle: null, selector: null, componentName: null, source: null }),
42 ),
43 ).toBe(true);
44 });
45
46 it("accepts an empty stack array", () => {
47 expect(isPickedElementPayload(validPayload({ stack: [] }))).toBe(true);
48 });
49
50 it("accepts stack frames with null fields", () => {
51 expect(
52 isPickedElementPayload(
53 validPayload({
54 stack: [
55 {
56 functionName: null,
57 fileName: null,
58 lineNumber: null,
59 columnNumber: null,
60 },
61 ],
62 }),
63 ),
64 ).toBe(true);
65 });
66
67 it("rejects null and primitive inputs", () => {
68 expect(isPickedElementPayload(null)).toBe(false);
69 expect(isPickedElementPayload(undefined)).toBe(false);
70 expect(isPickedElementPayload("string")).toBe(false);
71 expect(isPickedElementPayload(42)).toBe(false);
72 expect(isPickedElementPayload([])).toBe(false);
73 });
74
75 it.each<[string, Record<string, unknown>]>([
76 ["missing pageUrl", validPayload({ pageUrl: undefined })],
77 ["wrong-type pageUrl", validPayload({ pageUrl: 123 })],
78 ["missing tagName", validPayload({ tagName: undefined })],
79 ["missing htmlPreview", validPayload({ htmlPreview: undefined })],
80 ["missing styles", validPayload({ styles: undefined })],
81 ["missing pickedAt", validPayload({ pickedAt: undefined })],
82 ["wrong-type pageTitle", validPayload({ pageTitle: 99 })],
83 ["wrong-type selector", validPayload({ selector: 99 })],
84 ["wrong-type componentName", validPayload({ componentName: 99 })],
85 ])("rejects payloads with %s", (_label, value) => {
86 expect(isPickedElementPayload(value)).toBe(false);
87 });
88
89 it("rejects malformed source frames", () => {
90 expect(
91 isPickedElementPayload(
92 validPayload({
93 source: {
94 functionName: 0,
95 fileName: null,
96 lineNumber: null,
97 columnNumber: null,
98 },
99 }),
100 ),
101 ).toBe(false);
102 });
103
104 it("rejects non-finite numeric line/column numbers", () => {
105 expect(
106 isPickedElementPayload(
107 validPayload({
108 source: {
109 functionName: null,
110 fileName: null,
111 lineNumber: Number.POSITIVE_INFINITY,
112 columnNumber: null,
113 },
114 }),
115 ),
116 ).toBe(false);
117 expect(
118 isPickedElementPayload(
119 validPayload({
120 source: {
121 functionName: null,
122 fileName: null,
123 lineNumber: Number.NaN,
124 columnNumber: null,
125 },
126 }),
127 ),
128 ).toBe(false);
129 });
130
131 it("rejects malformed stack arrays", () => {
132 expect(isPickedElementPayload(validPayload({ stack: "not-an-array" }))).toBe(false);
133 expect(isPickedElementPayload(validPayload({ stack: [{ bogus: true }] }))).toBe(false);
134 });
135});
apps/desktop/src/picked-element-payload.ts created+49
......@@ -0,0 +1,49 @@
1/**
2 * Strict structural validator for `PickedElementPayload` messages received
3 * from the in-page picker preload (`apps/desktop/src/preview-pick-preload.ts`)
4 * via `wc.ipc`. Lives in its own electron-free module so the validator is
5 * trivially unit-testable.
6 *
7 * Validation must be tight: downstream `normalizeElementContextSelection`
8 * calls `.trim()` on incoming strings, so a malformed payload (preload bug,
9 * future schema mismatch, malicious page that intercepts the preload's IPC
10 * channel via prototype pollution) would otherwise throw deep in the
11 * renderer and the chip silently never appears.
12 */
13import type { PickedElementPayload } from "@t3tools/contracts";
14
15function isStringOrNull(value: unknown): value is string | null {
16 return value === null || typeof value === "string";
17}
18
19function isFiniteNumberOrNull(value: unknown): value is number | null {
20 return value === null || (typeof value === "number" && Number.isFinite(value));
21}
22
23function isPickedStackFrame(value: unknown): boolean {
24 if (typeof value !== "object" || value === null) return false;
25 const frame = value as Record<string, unknown>;
26 return (
27 isStringOrNull(frame["functionName"]) &&
28 isStringOrNull(frame["fileName"]) &&
29 isFiniteNumberOrNull(frame["lineNumber"]) &&
30 isFiniteNumberOrNull(frame["columnNumber"])
31 );
32}
33
34export function isPickedElementPayload(value: unknown): value is PickedElementPayload {
35 if (typeof value !== "object" || value === null) return false;
36 const c = value as Record<string, unknown>;
37 if (typeof c["pageUrl"] !== "string") return false;
38 if (typeof c["tagName"] !== "string") return false;
39 if (typeof c["htmlPreview"] !== "string") return false;
40 if (typeof c["styles"] !== "string") return false;
41 if (typeof c["pickedAt"] !== "string") return false;
42 if (!isStringOrNull(c["pageTitle"])) return false;
43 if (!isStringOrNull(c["selector"])) return false;
44 if (!isStringOrNull(c["componentName"])) return false;
45 if (c["source"] !== null && !isPickedStackFrame(c["source"])) return false;
46 if (!Array.isArray(c["stack"])) return false;
47 if (!c["stack"].every(isPickedStackFrame)) return false;
48 return true;
49}
apps/desktop/src/preload.ts+9-2
......@@ -47,6 +47,10 @@ contextBridge.exposeInMainWorld("desktopBridge", {
4747 ipcRenderer.invoke(IpcChannels.SET_SAVED_ENVIRONMENT_SECRET_CHANNEL, { environmentId, secret }),
4848 removeSavedEnvironmentSecret: (environmentId) =>
4949 ipcRenderer.invoke(IpcChannels.REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId),
50 getConnectionCatalog: () => ipcRenderer.invoke(IpcChannels.GET_CONNECTION_CATALOG_CHANNEL),
51 setConnectionCatalog: (catalog) =>
52 ipcRenderer.invoke(IpcChannels.SET_CONNECTION_CATALOG_CHANNEL, catalog),
53 clearConnectionCatalog: () => ipcRenderer.invoke(IpcChannels.CLEAR_CONNECTION_CATALOG_CHANNEL),
5054 discoverSshHosts: () => ipcRenderer.invoke(IpcChannels.DISCOVER_SSH_HOSTS_CHANNEL),
5155 ensureSshEnvironment: async (target, options) =>
5256 unwrapEnsureSshEnvironmentResult(
......@@ -159,8 +163,11 @@ contextBridge.exposeInMainWorld("desktopBridge", {
159163 ipcRenderer.invoke(IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, { tabId }),
160164 clearCookies: () => ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL),
161165 clearCache: () => ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_CACHE_CHANNEL),
162 getBrowserPartition: () =>
163 ipcRenderer.invoke(IpcChannels.PREVIEW_GET_BROWSER_PARTITION_CHANNEL),
166 getPreviewConfig: () => ipcRenderer.invoke(IpcChannels.PREVIEW_GET_CONFIG_CHANNEL),
167 pickElement: (tabId) =>
168 ipcRenderer.invoke(IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL, { tabId }),
169 cancelPickElement: (tabId) =>
170 ipcRenderer.invoke(IpcChannels.PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL, { tabId }),
164171 onStateChange: (listener) => {
165172 const wrappedListener = (
166173 _event: Electron.IpcRendererEvent,
apps/desktop/src/preview-pick-label-position.ts created+46
......@@ -0,0 +1,46 @@
1/**
2 * Pure clamp/flip math for the floating label that follows the cursor while
3 * the user is picking an element in the in-app browser. Lives in its own
4 * electron-free module so the geometry can be unit-tested without spinning
5 * up an Electron preload context (`preview-pick-preload.ts` itself imports
6 * `electron` and `react-grab/primitives`, which can't load under vitest).
7 *
8 * - Horizontally pins the label to `targetLeft`, clamped into
9 * `[VIEWPORT_MARGIN, viewportWidth - labelWidth - VIEWPORT_MARGIN]`.
10 * - Vertically prefers above the target. If the label would overflow the
11 * top, flips below; if THAT also overflows the bottom, pins to the
12 * bottom margin (better to overlap the highlight than disappear).
13 */
14
15/** Distance in CSS pixels between the highlight and the floating label. */
16export const LABEL_GAP = 4;
17/** Minimum padding the label keeps from any viewport edge. */
18export const VIEWPORT_MARGIN = 4;
19
20export function computeLabelPosition(input: {
21 targetLeft: number;
22 targetTop: number;
23 targetBottom: number;
24 labelWidth: number;
25 labelHeight: number;
26 viewportWidth: number;
27 viewportHeight: number;
28}): { x: number; y: number } {
29 const { targetLeft, targetTop, targetBottom, labelWidth, labelHeight } = input;
30 const { viewportWidth, viewportHeight } = input;
31
32 let x = targetLeft;
33 const maxX = viewportWidth - labelWidth - VIEWPORT_MARGIN;
34 if (x > maxX) x = maxX;
35 if (x < VIEWPORT_MARGIN) x = VIEWPORT_MARGIN;
36
37 let y = targetTop - labelHeight - LABEL_GAP;
38 if (y < VIEWPORT_MARGIN) {
39 y = targetBottom + LABEL_GAP;
40 if (y + labelHeight > viewportHeight - VIEWPORT_MARGIN) {
41 y = Math.max(VIEWPORT_MARGIN, viewportHeight - labelHeight - VIEWPORT_MARGIN);
42 }
43 }
44
45 return { x, y };
46}
apps/desktop/src/preview-pick-preload.test.ts created+86
......@@ -0,0 +1,86 @@
1import { describe, expect, it } from "vitest";
2
3import { computeLabelPosition } from "./preview-pick-label-position.ts";
4
5const VIEWPORT = { viewportWidth: 1280, viewportHeight: 800 };
6
7describe("computeLabelPosition", () => {
8 it("anchors to the element's top-left when there's room above and to the right", () => {
9 const { x, y } = computeLabelPosition({
10 ...VIEWPORT,
11 targetLeft: 200,
12 targetTop: 200,
13 targetBottom: 240,
14 labelWidth: 120,
15 labelHeight: 18,
16 });
17 expect(x).toBe(200);
18 // 200 (top) - 18 (height) - 4 (gap)
19 expect(y).toBe(200 - 18 - 4);
20 });
21
22 it("clamps left edge so the label stays inside the viewport", () => {
23 const { x } = computeLabelPosition({
24 ...VIEWPORT,
25 targetLeft: -50,
26 targetTop: 200,
27 targetBottom: 240,
28 labelWidth: 120,
29 labelHeight: 18,
30 });
31 expect(x).toBe(4);
32 });
33
34 it("clamps right edge when the label would overflow the viewport (the bug we shipped)", () => {
35 const { x } = computeLabelPosition({
36 ...VIEWPORT,
37 targetLeft: 1240,
38 targetTop: 200,
39 targetBottom: 240,
40 labelWidth: 200,
41 labelHeight: 18,
42 });
43 // viewportWidth (1280) - labelWidth (200) - margin (4) = 1076
44 expect(x).toBe(1076);
45 });
46
47 it("flips the label below the element when there's no room above", () => {
48 const { y } = computeLabelPosition({
49 ...VIEWPORT,
50 targetLeft: 200,
51 targetTop: 4,
52 targetBottom: 44,
53 labelWidth: 120,
54 labelHeight: 18,
55 });
56 // labelY = 4 - 18 - 4 = -18 → flip → 44 + 4 = 48
57 expect(y).toBe(48);
58 });
59
60 it("pins to the bottom margin when the element fills the viewport (no room above OR below)", () => {
61 const { y } = computeLabelPosition({
62 ...VIEWPORT,
63 targetLeft: 200,
64 targetTop: 0,
65 targetBottom: 800,
66 labelWidth: 120,
67 labelHeight: 18,
68 });
69 // Above overflows top → flip below = 800 + 4 = 804 → also overflows
70 // bottom → pin to viewportHeight - labelHeight - margin = 778.
71 expect(y).toBe(800 - 18 - 4);
72 });
73
74 it("never returns a negative coordinate", () => {
75 const { x, y } = computeLabelPosition({
76 ...VIEWPORT,
77 targetLeft: -1000,
78 targetTop: -1000,
79 targetBottom: -900,
80 labelWidth: 5000,
81 labelHeight: 5000,
82 });
83 expect(x).toBeGreaterThanOrEqual(0);
84 expect(y).toBeGreaterThanOrEqual(0);
85 });
86});
apps/desktop/src/preview-pick-preload.ts created+419
......@@ -0,0 +1,419 @@
1/**
2 * Preview pick preload — runs in the isolated world of the Chromium
3 * `<webview>` that hosts the in-app browser. Loaded via the
4 * `<webview preload="...">` attribute set by the renderer.
5 *
6 * Responsibilities:
7 *
8 * 1. Listen for `preview:start-pick` from main (sent through
9 * `WebContents.send`, received here via `ipcRenderer.on`).
10 * 2. Mount a minimal blue-highlight element picker on the page.
11 * 3. On click → call `react-grab/primitives.getElementContext` and bubble
12 * a `PickedElementPayload` to main via `ipcRenderer.send`. Main resolves
13 * the in-flight `pickElement(tabId)` promise via its per-WebContents
14 * `wc.ipc.on(...)` listener.
15 * 4. Tear down the picker on Escape, blur, navigation, or explicit cancel.
16 *
17 * Design notes:
18 *
19 * - We never modify the page's DOM tree — the highlight + crosshair cursor
20 * live on a single fixed-position overlay layer that we own. This keeps
21 * us safe against pages that reset DOM or do MutationObserver tracking.
22 * - The overlay uses `pointer-events: none` so clicks fall through to the
23 * real element behind it; we do hit-testing with `elementFromPoint`.
24 * - Only a single pick session is ever active per webview; re-activating
25 * silently replaces the previous session.
26 * - Cancellations triggered BY MAIN (CANCEL_PICK_CHANNEL, or a follow-up
27 * START_PICK_CHANNEL that supersedes the current session) tear down
28 * silently — they do NOT echo a `null` ELEMENT_PICKED back, because main
29 * already knows it cancelled and would otherwise resolve the freshly-
30 * registered next-pick listener with that stale `null`. Cancellations
31 * triggered by the USER (Escape, beforeunload, click on empty) DO send
32 * `null` back so main can resolve the in-flight pick promise.
33 */
34import { ipcRenderer } from "electron";
35import { getElementContext } from "react-grab/primitives";
36import type { PickedElementPayload, PickedElementStackFrame } from "@t3tools/contracts";
37
38import { computeLabelPosition } from "./preview-pick-label-position.ts";
39
40const START_PICK_CHANNEL = "preview:start-pick";
41const CANCEL_PICK_CHANNEL = "preview:cancel-pick";
42const ELEMENT_PICKED_CHANNEL = "preview:element-picked";
43
44const HIGHLIGHT_COLOR = "rgba(37, 99, 235, 0.9)"; // blue-600
45const HIGHLIGHT_FILL = "rgba(37, 99, 235, 0.16)";
46const LABEL_BG = "rgba(37, 99, 235, 0.96)";
47const Z_INDEX_OVERLAY = 2147483646;
48
49interface PickSession {
50 readonly overlay: HTMLDivElement;
51 readonly outline: HTMLDivElement;
52 readonly label: HTMLDivElement;
53 /**
54 * Tear down listeners + DOM WITHOUT notifying main. Used when main itself
55 * initiated the cancel (CANCEL_PICK_CHANNEL) or when a follow-up startPick
56 * supersedes us — main is already waiting on a fresh listener and would
57 * otherwise resolve it with the stale `null` we'd send.
58 */
59 readonly teardownSilent: () => void;
60}
61
62let activeSession: PickSession | null = null;
63
64function endActiveSession(): void {
65 if (!activeSession) return;
66 const session = activeSession;
67 activeSession = null;
68 // Supersession from a new startPick is a main-initiated transition: tear
69 // down silently so the new pick's main-side listener doesn't receive a
70 // ghost `null` from the old session.
71 session.teardownSilent();
72}
73
74interface OverlayHandles {
75 readonly overlay: HTMLDivElement;
76 readonly outline: HTMLDivElement;
77 readonly label: HTMLDivElement;
78 readonly destroyDom: () => void;
79}
80
81function createOverlay(): OverlayHandles {
82 const overlay = document.createElement("div");
83 overlay.setAttribute("data-t3code-pick-overlay", "");
84 overlay.style.cssText = [
85 "position:fixed",
86 "inset:0",
87 "z-index:" + String(Z_INDEX_OVERLAY),
88 "pointer-events:none",
89 "cursor:crosshair",
90 // Some apps register `pointer-events: auto !important` on body — using a
91 // dedicated overlay element ensures we don't fight with that.
92 ].join(";");
93
94 const outline = document.createElement("div");
95 outline.setAttribute("data-t3code-pick-outline", "");
96 outline.style.cssText = [
97 "position:absolute",
98 "left:0",
99 "top:0",
100 "width:0",
101 "height:0",
102 "border:2px solid " + HIGHLIGHT_COLOR,
103 "background:" + HIGHLIGHT_FILL,
104 "border-radius:2px",
105 "box-shadow:0 0 0 1px rgba(255,255,255,0.6) inset",
106 "transition:none",
107 "display:none",
108 "pointer-events:none",
109 ].join(";");
110
111 const label = document.createElement("div");
112 label.setAttribute("data-t3code-pick-label", "");
113 // `top:0; left:0` so transform translate() positions are absolute viewport
114 // coordinates (we re-anchor the label every paint via a single transform).
115 // `max-width: calc(100vw - 8px)` + ellipsis prevents an overly long
116 // tag#id.class string from overflowing the viewport horizontally before we
117 // even get to the clamp logic.
118 label.style.cssText = [
119 "position:absolute",
120 "left:0",
121 "top:0",
122 "padding:2px 6px",
123 "background:" + LABEL_BG,
124 "color:white",
125 "font:600 11px/1.2 ui-sans-serif,system-ui,-apple-system,sans-serif",
126 "border-radius:3px",
127 "pointer-events:none",
128 "white-space:nowrap",
129 "max-width:calc(100vw - 8px)",
130 "overflow:hidden",
131 "text-overflow:ellipsis",
132 "box-shadow:0 1px 2px rgba(0,0,0,0.25)",
133 "display:none",
134 ].join(";");
135
136 overlay.appendChild(outline);
137 overlay.appendChild(label);
138 document.documentElement.appendChild(overlay);
139
140 // Force the crosshair cursor across the whole page even though we set it
141 // on the overlay (because pointer-events: none means it's never the
142 // cursor target). We add a stylesheet rule and revert on cleanup.
143 const styleEl = document.createElement("style");
144 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; }`;
145 document.documentElement.appendChild(styleEl);
146 document.documentElement.setAttribute("data-t3code-picking", "1");
147
148 const destroyDom = (): void => {
149 overlay.remove();
150 styleEl.remove();
151 document.documentElement.removeAttribute("data-t3code-picking");
152 };
153
154 return { overlay, outline, label, destroyDom };
155}
156
157/**
158 * Resolve the element under the cursor while ignoring our own overlay.
159 * We render at z-index 2147483646 with `pointer-events: none`, which means
160 * `elementFromPoint` already skips the overlay — but we double-guard against
161 * pages that mutate `pointer-events` via MutationObservers.
162 */
163function pickFromPoint(clientX: number, clientY: number): Element | null {
164 const candidates = document.elementsFromPoint(clientX, clientY);
165 for (const candidate of candidates) {
166 if (!(candidate instanceof Element)) continue;
167 if (candidate.hasAttribute("data-t3code-pick-overlay")) continue;
168 if (candidate.hasAttribute("data-t3code-pick-outline")) continue;
169 if (candidate.hasAttribute("data-t3code-pick-label")) continue;
170 if (candidate === document.documentElement) continue;
171 if (candidate === document.body) continue;
172 return candidate;
173 }
174 return null;
175}
176
177function describeRawElement(element: Element): string {
178 const tag = element.tagName.toLowerCase();
179 const id = element.id ? `#${element.id}` : "";
180 const className =
181 element instanceof HTMLElement && typeof element.className === "string"
182 ? element.className
183 .trim()
184 .split(/\s+/)
185 .filter((token) => token.length > 0)
186 .slice(0, 2)
187 .map((token) => `.${token}`)
188 .join("")
189 : "";
190 return `${tag}${id}${className}`;
191}
192
193/**
194 * Per-session cache of the resolved React component name for elements we've
195 * already inspected. Stores `null` when react-grab couldn't find a component
196 * (raw HTML / unmounted) so repeat hovers don't re-pay the async lookup.
197 */
198const componentNameCache = new WeakMap<Element, string | null>();
199const componentNameInFlight = new WeakSet<Element>();
200
201function describeElement(element: Element): string {
202 const cached = componentNameCache.get(element);
203 if (cached) return `<${cached}> ${describeRawElement(element)}`;
204 return describeRawElement(element);
205}
206
207function paintOutline(handles: OverlayHandles, element: Element): void {
208 const rect = element.getBoundingClientRect();
209 if (rect.width === 0 && rect.height === 0) {
210 handles.outline.style.display = "none";
211 handles.label.style.display = "none";
212 return;
213 }
214 handles.outline.style.display = "block";
215 handles.outline.style.transform = `translate(${rect.left}px, ${rect.top}px)`;
216 handles.outline.style.width = `${rect.width}px`;
217 handles.outline.style.height = `${rect.height}px`;
218
219 // Two-pass label paint: first apply the new text and force-block display
220 // so we can measure the rendered size, then clamp to the viewport and
221 // flip below the element when there isn't room above.
222 const text = describeElement(element);
223 if (handles.label.textContent !== text) {
224 handles.label.textContent = text;
225 }
226 handles.label.style.display = "block";
227
228 const labelRect = handles.label.getBoundingClientRect();
229 const { x, y } = computeLabelPosition({
230 targetLeft: rect.left,
231 targetTop: rect.top,
232 targetBottom: rect.bottom,
233 labelWidth: labelRect.width,
234 labelHeight: labelRect.height,
235 viewportWidth: document.documentElement.clientWidth || window.innerWidth || labelRect.width,
236 viewportHeight: document.documentElement.clientHeight || window.innerHeight || labelRect.height,
237 });
238 handles.label.style.transform = `translate(${x}px, ${y}px)`;
239}
240
241/**
242 * Kick off (at most once per element) an async react-grab lookup for the
243 * component name. When the answer arrives, repaint the label iff the element
244 * is still under the cursor — otherwise the user has moved on and a stale
245 * paint would clobber the next element's label.
246 */
247function ensureComponentName(element: Element, onResolve: (resolvedFor: Element) => void): void {
248 if (componentNameCache.has(element)) return;
249 if (componentNameInFlight.has(element)) return;
250 componentNameInFlight.add(element);
251 void getElementContext(element)
252 .then((context) => {
253 const trimmed = context.componentName?.trim();
254 componentNameCache.set(element, trimmed && trimmed.length > 0 ? trimmed : null);
255 })
256 .catch(() => {
257 componentNameCache.set(element, null);
258 })
259 .finally(() => {
260 componentNameInFlight.delete(element);
261 onResolve(element);
262 });
263}
264
265function clearOutline(handles: OverlayHandles): void {
266 handles.outline.style.display = "none";
267 handles.label.style.display = "none";
268}
269
270function toStackFrame(frame: {
271 functionName?: string;
272 fileName?: string;
273 lineNumber?: number;
274 columnNumber?: number;
275}): PickedElementStackFrame {
276 return {
277 functionName: frame.functionName ?? null,
278 fileName: frame.fileName ?? null,
279 lineNumber: frame.lineNumber ?? null,
280 columnNumber: frame.columnNumber ?? null,
281 };
282}
283
284async function captureElement(element: Element): Promise<PickedElementPayload | null> {
285 try {
286 const context = await getElementContext(element);
287 const stack = (context.stack ?? []).map((frame) => toStackFrame(frame));
288 return {
289 pageUrl: location.href,
290 pageTitle: document.title?.trim() ? document.title.trim() : null,
291 tagName: element.tagName.toLowerCase(),
292 selector: context.selector,
293 htmlPreview: context.htmlPreview ?? "",
294 componentName: context.componentName,
295 source: stack[0] ?? null,
296 stack,
297 styles: context.styles ?? "",
298 pickedAt: new Date().toISOString(),
299 };
300 } catch {
301 return null;
302 }
303}
304
305function startPick(): void {
306 endActiveSession();
307 if (typeof document === "undefined" || !document.body) {
308 ipcRenderer.send(ELEMENT_PICKED_CHANNEL, null);
309 return;
310 }
311
312 const handles = createOverlay();
313 let lastTarget: Element | null = null;
314 let resolved = false;
315
316 // Tear down listeners + DOM. Does NOT notify main. `notifyMain` controls
317 // whether we additionally bubble a `null` ELEMENT_PICKED back so main can
318 // resolve the in-flight pick promise (only true for USER-initiated cancels;
319 // main already knows about its own cancellations).
320 const teardown = (notifyMain: boolean, payload: PickedElementPayload | null): void => {
321 if (resolved) return;
322 resolved = true;
323 window.removeEventListener("mousemove", onMove, { capture: true } as EventListenerOptions);
324 window.removeEventListener("click", onClick, { capture: true } as EventListenerOptions);
325 window.removeEventListener("keydown", onKey, { capture: true } as EventListenerOptions);
326 window.removeEventListener("scroll", onScrollOrResize, {
327 capture: true,
328 } as EventListenerOptions);
329 window.removeEventListener("resize", onScrollOrResize);
330 window.removeEventListener("beforeunload", onBeforeUnload);
331 ipcRenderer.off(CANCEL_PICK_CHANNEL, onMainCancel);
332 handles.destroyDom();
333 if (notifyMain) {
334 // `ipcRenderer.send` (NOT `sendToHost`) reaches main, where the
335 // PreviewViewManager's per-WebContents `wc.ipc.on(...)` listener
336 // resolves the in-flight pick promise.
337 ipcRenderer.send(ELEMENT_PICKED_CHANNEL, payload);
338 }
339 };
340
341 // Re-paint when an in-flight component-name lookup resolves, but only if
342 // the same element is still under the cursor — otherwise the user moved on
343 // and we'd clobber the next element's label.
344 const onComponentNameResolved = (resolvedFor: Element): void => {
345 if (lastTarget !== resolvedFor) return;
346 paintOutline(handles, resolvedFor);
347 };
348
349 const onMove = (event: MouseEvent): void => {
350 const target = pickFromPoint(event.clientX, event.clientY);
351 if (target === lastTarget) return;
352 lastTarget = target;
353 if (target) {
354 paintOutline(handles, target);
355 ensureComponentName(target, onComponentNameResolved);
356 } else {
357 clearOutline(handles);
358 }
359 };
360
361 const onScrollOrResize = (): void => {
362 if (lastTarget) paintOutline(handles, lastTarget);
363 };
364
365 const onClick = (event: MouseEvent): void => {
366 event.preventDefault();
367 event.stopPropagation();
368 const target = pickFromPoint(event.clientX, event.clientY);
369 if (!target) {
370 teardown(true, null);
371 return;
372 }
373 void captureElement(target).then((payload) => teardown(true, payload));
374 };
375
376 const onKey = (event: KeyboardEvent): void => {
377 if (event.key === "Escape") {
378 event.preventDefault();
379 event.stopPropagation();
380 teardown(true, null);
381 }
382 };
383
384 const onBeforeUnload = (): void => {
385 teardown(true, null);
386 };
387
388 // Cancellation initiated by main (CANCEL_PICK_CHANNEL). Tear down silently
389 // — main already knows it cancelled and is either done waiting or about to
390 // register a fresh listener for a new pick. If we sent `null` here, that
391 // fresh listener would receive it and resolve the new pick instantly (the
392 // C1 race we previously hit).
393 const onMainCancel = (): void => {
394 teardown(false, null);
395 };
396
397 // Capture-phase listeners on `window` to outrun page handlers that
398 // `stopPropagation()` early. `passive: false` because we need preventDefault.
399 window.addEventListener("mousemove", onMove, { capture: true, passive: true });
400 window.addEventListener("click", onClick, { capture: true });
401 window.addEventListener("keydown", onKey, { capture: true });
402 window.addEventListener("scroll", onScrollOrResize, { capture: true, passive: true });
403 window.addEventListener("resize", onScrollOrResize, { passive: true });
404 window.addEventListener("beforeunload", onBeforeUnload);
405 ipcRenderer.on(CANCEL_PICK_CHANNEL, onMainCancel);
406
407 // Hand a "silent teardown" to `activeSession` so that a follow-up
408 // `startPick()` can supersede us without echoing `null` back to main.
409 activeSession = {
410 overlay: handles.overlay,
411 outline: handles.outline,
412 label: handles.label,
413 teardownSilent: () => teardown(false, null),
414 };
415}
416
417ipcRenderer.on(START_PICK_CHANNEL, () => {
418 startPick();
419});
apps/desktop/src/preview-view-manager.ts+124
......@@ -5,10 +5,22 @@
55 * elements live in the renderer; we only attach listeners and forward state
66 * here). Single layer-scoped browser session partition.
77 */
8import type { PickedElementPayload } from "@t3tools/contracts";
89import { normalizePreviewUrl } from "@t3tools/shared/preview";
910import { type BrowserWindow, type Session, session, webContents } from "electron";
1011
12import { isPickedElementPayload } from "./picked-element-payload.ts";
13
1114const PREVIEW_PARTITION = "persist:t3code-preview";
15const START_PICK_CHANNEL = "preview:start-pick";
16const CANCEL_PICK_CHANNEL = "preview:cancel-pick";
17const ELEMENT_PICKED_CHANNEL = "preview:element-picked";
18
19// Re-export the guest webview security posture from its dedicated module so
20// the constant is unit-testable in isolation. See
21// `preview-webview-preferences.ts` for the full security rationale.
22export { PREVIEW_WEBVIEW_PREFERENCES } from "./preview-webview-preferences.ts";
23import { PREVIEW_WEBVIEW_PREFERENCES } from "./preview-webview-preferences.ts";
1224
1325export type PreviewNavStatus =
1426 | { kind: "Idle" }
......@@ -63,6 +75,11 @@ interface ManagedListeners {
6375 failed: (event: Event, code: number, description: string) => void;
6476}
6577
78interface PickSession {
79 readonly resolve: (payload: PickedElementPayload | null) => void;
80 readonly cleanup: () => void;
81}
82
6683const APP_FORWARDED_SHORTCUTS: ReadonlyArray<{
6784 key: string;
6885 meta: boolean;
......@@ -85,6 +102,8 @@ export class PreviewViewManager {
85102 private readonly attached = new Map<number, ManagedListeners>();
86103 private browserSession: Session | null = null;
87104 private readonly listeners = new Set<Listener>();
105 /** In-flight element-pick sessions, keyed by tabId (one pick per tab). */
106 private readonly pickSessions = new Map<string, PickSession>();
88107
89108 setMainWindow(window: BrowserWindow): void {
90109 this.mainWindow = window;
......@@ -94,6 +113,16 @@ export class PreviewViewManager {
94113 return PREVIEW_PARTITION;
95114 }
96115
116 /**
117 * Returns the canonical `<webview webpreferences="...">` string. Renderer
118 * fetches this via the desktop bridge so the security posture for guest
119 * surfaces lives in exactly one place (here) and any future guest webview
120 * (docs panel, OAuth popup, etc.) can opt in by calling the same getter.
121 */
122 getWebviewPreferences(): string {
123 return PREVIEW_WEBVIEW_PREFERENCES;
124 }
125
97126 getBrowserSession(): Session {
98127 if (this.browserSession) return this.browserSession;
99128 const sess = session.fromPartition(PREVIEW_PARTITION);
......@@ -130,6 +159,7 @@ export class PreviewViewManager {
130159 closeTab(tabId: string): void {
131160 const tab = this.tabs.get(tabId);
132161 if (!tab) return;
162 this.cancelPickElement(tabId);
133163 if (tab.webContentsId != null) {
134164 this.detachListeners(tab.webContentsId);
135165 }
......@@ -169,6 +199,10 @@ export class PreviewViewManager {
169199 }
170200 if (tab.webContentsId != null && tab.webContentsId !== webContentsId) {
171201 this.detachListeners(tab.webContentsId);
202 // Any in-flight pick is bound to the OLD WebContents via `wc.ipc.on`.
203 // Cancel it so the toggle button doesn't get stuck pressed waiting
204 // forever for a click on a webview that no longer hosts the listener.
205 this.cancelPickElement(tabId);
172206 }
173207 this.attachListeners(tabId, wc);
174208 // Restore the persisted zoom factor onto the freshly-attached WebContents
......@@ -254,6 +288,96 @@ export class PreviewViewManager {
254288 await sess.clearCache();
255289 }
256290
291 /**
292 * Activate the in-page element picker for `tabId`. Resolves with the
293 * `PickedElementPayload` the preload script bubbles back via `ipc-message`,
294 * or `null` when the user cancels (Escape, navigation, manual cancel).
295 *
296 * Exactly one pick session may be active per tab — re-invoking while a
297 * pick is in flight cleanly resolves the old session with `null` first.
298 */
299 async pickElement(tabId: string): Promise<PickedElementPayload | null> {
300 const wc = this.requireWebContents(tabId);
301 this.cancelPickElement(tabId);
302 return new Promise<PickedElementPayload | null>((resolve) => {
303 // `wc.ipc` is the per-WebContents IpcMain that receives messages the
304 // webview's preload sends with `ipcRenderer.send(...)`. We use that
305 // (not the global `wc.on("ipc-message", ...)`, which is for
306 // `sendToHost` and only fires on the host renderer's <webview>
307 // element) so the main process actually observes the picked payload.
308 const cleanup = () => {
309 wc.ipc.removeListener(ELEMENT_PICKED_CHANNEL, onMessage);
310 wc.off("destroyed", onDestroyed);
311 wc.off("did-start-navigation", onNavigated);
312 this.pickSessions.delete(tabId);
313 };
314 const session: PickSession = { resolve, cleanup };
315 const settle = (payload: PickedElementPayload | null) => {
316 if (this.pickSessions.get(tabId) !== session) return;
317 cleanup();
318 resolve(payload);
319 };
320 const onMessage = (_event: Electron.IpcMainEvent, ...args: unknown[]): void => {
321 const payload = args[0];
322 if (payload == null) {
323 settle(null);
324 return;
325 }
326 if (!isPickedElementPayload(payload)) {
327 settle(null);
328 return;
329 }
330 settle(payload);
331 };
332 const onDestroyed = () => settle(null);
333 const onNavigated = () => settle(null);
334 wc.ipc.on(ELEMENT_PICKED_CHANNEL, onMessage);
335 wc.once("destroyed", onDestroyed);
336 // A page navigation (incl. SPA → same-document) tears down the
337 // preload's listeners, so we cancel proactively to avoid hanging.
338 wc.once("did-start-navigation", onNavigated);
339 this.pickSessions.set(tabId, session);
340 // Force-focus the guest webContents BEFORE sending start-pick. Without
341 // this, Electron's input router will deliver the user's first
342 // mousemove/click to the host renderer (where the pick button lives)
343 // instead of to the guest's window listeners — manifest as "the
344 // picker overlay never appears on remote pages I haven't clicked
345 // into yet". The renderer-side handler in `PreviewView` is responsible
346 // for restoring focus to the previously-active host element when the
347 // pick promise resolves so the user's textarea cursor isn't lost.
348 try {
349 if (!wc.isFocused()) wc.focus();
350 } catch {
351 // wc may be torn down; the next try/catch settles.
352 }
353 try {
354 wc.send(START_PICK_CHANNEL);
355 } catch {
356 settle(null);
357 }
358 });
359 }
360
361 cancelPickElement(tabId: string): void {
362 const session = this.pickSessions.get(tabId);
363 if (!session) return;
364 session.cleanup();
365 // Best-effort: tell the page to dismiss the overlay even if it's still
366 // alive — keeps the next invoke fresh.
367 const tab = this.tabs.get(tabId);
368 if (tab?.webContentsId != null) {
369 const wc = webContents.fromId(tab.webContentsId);
370 if (wc && !wc.isDestroyed()) {
371 try {
372 wc.send(CANCEL_PICK_CHANNEL);
373 } catch {
374 // wc may have been torn down; nothing to clean up.
375 }
376 }
377 }
378 session.resolve(null);
379 }
380
257381 zoomIn(tabId: string): void {
258382 this.applyZoom(tabId, (current) => nextZoomLevel(current, "in"));
259383 }
apps/desktop/src/preview-webview-preferences.test.ts created+78
......@@ -0,0 +1,78 @@
1import { describe, expect, it } from "vitest";
2
3import { PREVIEW_WEBVIEW_PREFERENCES } from "./preview-webview-preferences.ts";
4
5/**
6 * Mirrors Electron's webview attribute parser closely enough to catch the
7 * regressions we've already hit:
8 *
9 * - whitespace inside the comma-separated list silently drops keys (so
10 * `" sandbox=true"` becomes an unknown key and Electron falls back to
11 * defaults — re-opening the Node-leak window we closed),
12 * - non-`true`/`false` values (`"yes"`, `"no"`, etc.) are kept as truthy
13 * strings and assigned to a boolean preference, which silently flips
14 * `contextIsolation=no` to ENABLED (then react-grab can't see the React
15 * DevTools hook and componentName resolution always returns null).
16 *
17 * The actual Electron parser does roughly:
18 *
19 * for (const pair of webpreferences.split(',')) {
20 * const [key, value] = pair.split('=');
21 * prefs[key] = value; // value left as a string
22 * }
23 *
24 * then later coerces booleans via `Boolean(value)`. Replicating that here
25 * keeps the test independent of Electron internals while still failing if
26 * we accidentally ship `"contextIsolation=no"` again.
27 */
28function parseWebPreferences(input: string): Record<string, unknown> {
29 const out: Record<string, unknown> = {};
30 for (const pair of input.split(",")) {
31 if (pair !== pair.trim()) {
32 // Electron's parser doesn't trim; surface the bug as undefined-key.
33 out[pair] = pair.split("=")[1];
34 continue;
35 }
36 const [key, value] = pair.split("=");
37 if (!key) continue;
38 out[key] = value;
39 }
40 return out;
41}
42
43describe("PREVIEW_WEBVIEW_PREFERENCES", () => {
44 const parsed = parseWebPreferences(PREVIEW_WEBVIEW_PREFERENCES);
45
46 it("contains exactly the three security-critical keys", () => {
47 expect(Object.keys(parsed).toSorted()).toEqual(
48 ["contextIsolation", "nodeIntegration", "sandbox"].toSorted(),
49 );
50 });
51
52 it("uses canonical JS-boolean string literals (not yes/no, on/off, 1/0)", () => {
53 // `value="no"` is a TRUTHY string when assigned to webPreferences.X — so
54 // `contextIsolation="no"` would silently leave isolation ENABLED. Lock
55 // the values to `"true"` / `"false"` so the parser does the right thing.
56 for (const value of Object.values(parsed)) {
57 expect(value).toMatch(/^(true|false)$/);
58 }
59 });
60
61 it("disables context isolation (so react-grab can see the page's React DevTools hook)", () => {
62 expect(parsed["contextIsolation"]).toBe("false");
63 });
64
65 it("keeps the renderer sandbox enabled (so the page cannot reach Node APIs)", () => {
66 expect(parsed["sandbox"]).toBe("true");
67 });
68
69 it("disables nodeIntegration (defense in depth — page never gets Node)", () => {
70 expect(parsed["nodeIntegration"]).toBe("false");
71 });
72
73 it("contains no whitespace (Electron's parser does not trim)", () => {
74 // Electron splits on `,` without trimming, so any whitespace would turn
75 // a key into an unknown one and silently drop the security flag.
76 expect(PREVIEW_WEBVIEW_PREFERENCES).not.toMatch(/\s/);
77 });
78});
apps/desktop/src/preview-webview-preferences.ts created+42
......@@ -0,0 +1,42 @@
1/**
2 * webPreferences override applied to every preview `<webview>` element via
3 * its `webpreferences="..."` attribute. Single source of truth so all guest
4 * surfaces inherit the same security posture.
5 *
6 * Lives in its own electron-free module so the value is unit-testable
7 * without importing `preview-view-manager.ts` (which transitively imports
8 * `electron` and blows up under vitest).
9 *
10 * - `contextIsolation=false`: the picker preload needs to share `globalThis`
11 * with the page so react-grab/bippy can read the React DevTools hook
12 * (`__REACT_DEVTOOLS_GLOBAL_HOOK__`) and resolve component names. Without
13 * this every pick comes back with `componentName: null` even on dev React
14 * apps.
15 * - `sandbox=true`: keeps the OS-level renderer sandbox enabled. Critical
16 * when paired with `contextIsolation=false` — without sandbox, the preload
17 * has full Node access (`require`, `fs`, `child_process`, ...) and that
18 * `require` would land on the page's shared `globalThis`, giving any
19 * third-party page in the preview full Node + IPC access to the host.
20 * In sandboxed mode Electron still synthesizes the `electron` module for
21 * the preload's `import { ipcRenderer }` line, but no Node globals leak.
22 * - `nodeIntegration=false`: pinned for clarity (the page itself never gets
23 * Node access).
24 *
25 * Format notes (locked down by `preview-webview-preferences.test.ts`):
26 * - Whitespace-free. Electron's webpreferences parser splits on `,` and
27 * does not trim, so a leading space would turn a key into an unknown one
28 * and silently drop it.
29 * - Values are JS-boolean strings (`true`/`false`) — `yes`/`no` are not
30 * special-cased by the parser; `value="no"` becomes the truthy STRING
31 * `"no"` when assigned to a boolean webPreferences key. Most critically,
32 * `contextIsolation="no"` is truthy → contextIsolation stays ENABLED →
33 * react-grab can't see the React DevTools hook.
34 *
35 * Defense in depth: `apps/desktop/src/main.ts` also runs a
36 * `will-attach-webview` handler that force-sets `sandbox: true` and
37 * `nodeIntegration*: false` on the actual webPreferences object, gated on
38 * the preview partition, so even if this string is ever wrong, the
39 * security-critical flags can't regress on preview tabs.
40 */
41export const PREVIEW_WEBVIEW_PREFERENCES =
42 "contextIsolation=false,sandbox=true,nodeIntegration=false";
apps/desktop/src/window/DesktopWindow.ts+10
......@@ -198,6 +198,16 @@ const make = Effect.gen(function* () {
198198 });
199199
200200 previewViewManager.setMainWindow(window);
201 window.webContents.on("will-attach-webview", (event, webPreferences, params) => {
202 if (params.partition !== previewViewManager.getBrowserPartition()) {
203 event.preventDefault();
204 return;
205 }
206 webPreferences.sandbox = true;
207 webPreferences.nodeIntegration = false;
208 webPreferences.nodeIntegrationInSubFrames = false;
209 webPreferences.contextIsolation = false;
210 });
201211
202212 window.webContents.on("context-menu", (event, params) => {
203213 event.preventDefault();
apps/desktop/vite.config.ts+10
......@@ -56,5 +56,15 @@ export default defineConfig({
5656 define: publicConfigDefine,
5757 entry: ["src/preload.ts"],
5858 },
59 {
60 format: "cjs",
61 outDir: "dist-electron",
62 sourcemap: true,
63 outExtensions: () => ({ js: ".cjs" }),
64 entry: ["src/preview-pick-preload.ts"],
65 deps: {
66 alwaysBundle: (id) => id === "react-grab" || id.startsWith("react-grab/"),
67 },
68 },
5969 ],
6070});
apps/web/src/components/ChatView.logic.test.ts+24
......@@ -73,6 +73,30 @@ describe("deriveComposerSendState", () => {
7373 expect(state.expiredTerminalContextCount).toBe(1);
7474 expect(state.hasSendableContent).toBe(true);
7575 });
76
77 it("treats element contexts as sendable content (no text, no images, no terminals)", () => {
78 const state = deriveComposerSendState({
79 prompt: "",
80 imageCount: 0,
81 terminalContexts: [],
82 elementContextCount: 1,
83 });
84
85 expect(state.trimmedPrompt).toBe("");
86 expect(state.expiredTerminalContextCount).toBe(0);
87 expect(state.hasSendableContent).toBe(true);
88 });
89
90 it("does NOT treat zero element contexts as sendable", () => {
91 expect(
92 deriveComposerSendState({
93 prompt: "",
94 imageCount: 0,
95 terminalContexts: [],
96 elementContextCount: 0,
97 }).hasSendableContent,
98 ).toBe(false);
99 });
76100});
77101
78102describe("buildExpiredTerminalContextToastCopy", () => {
apps/web/src/components/ChatView.logic.ts+11-1
......@@ -185,6 +185,12 @@ export function deriveComposerSendState(options: {
185185 prompt: string;
186186 imageCount: number;
187187 terminalContexts: ReadonlyArray<TerminalContextDraft>;
188 /**
189 * Optional element-pick attachment count. Element contexts contribute to
190 * "sendable content" exactly like images and (text-bearing) terminal
191 * contexts do: a prompt of just element chips is still a valid send.
192 */
193 elementContextCount?: number;
188194}): {
189195 trimmedPrompt: string;
190196 sendableTerminalContexts: TerminalContextDraft[];
......@@ -195,12 +201,16 @@ export function deriveComposerSendState(options: {
195201 const sendableTerminalContexts = filterTerminalContextsWithText(options.terminalContexts);
196202 const expiredTerminalContextCount =
197203 options.terminalContexts.length - sendableTerminalContexts.length;
204 const elementContextCount = options.elementContextCount ?? 0;
198205 return {
199206 trimmedPrompt,
200207 sendableTerminalContexts,
201208 expiredTerminalContextCount,
202209 hasSendableContent:
203 trimmedPrompt.length > 0 || options.imageCount > 0 || sendableTerminalContexts.length > 0,
210 trimmedPrompt.length > 0 ||
211 options.imageCount > 0 ||
212 sendableTerminalContexts.length > 0 ||
213 elementContextCount > 0,
204214 };
205215}
206216
apps/web/src/components/ChatView.tsx+25-5
......@@ -149,6 +149,11 @@ import {
149149} from "../lib/terminalContext";
150150import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";
151151import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../terminalSessionState";
152import {
153 appendElementContextsToPrompt,
154 type ElementContextDraft,
155 formatElementContextLabel,
156} from "../lib/elementContext";
152157import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer";
153158import { ExpandedImageDialog } from "./chat/ExpandedImageDialog";
154159import { PullRequestThreadDialog } from "./PullRequestThreadDialog";
......@@ -873,6 +878,9 @@ export default function ChatView(props: ChatViewProps) {
873878 const setComposerDraftTerminalContexts = useComposerDraftStore(
874879 (store) => store.setTerminalContexts,
875880 );
881 const setComposerDraftElementContexts = useComposerDraftStore(
882 (store) => store.setElementContexts,
883 );
876884 const setComposerDraftModelSelection = useComposerDraftStore((store) => store.setModelSelection);
877885 const setComposerDraftRuntimeMode = useComposerDraftStore((store) => store.setRuntimeMode);
878886 const setComposerDraftInteractionMode = useComposerDraftStore(
......@@ -897,6 +905,7 @@ export default function ChatView(props: ChatViewProps) {
897905 const promptRef = useRef("");
898906 const composerImagesRef = useRef<ComposerImageAttachment[]>([]);
899907 const composerTerminalContextsRef = useRef<TerminalContextDraft[]>([]);
908 const composerElementContextsRef = useRef<ElementContextDraft[]>([]);
900909 const localComposerRef = useRef<ChatComposerHandle | null>(null);
901910 const composerRef = useComposerHandleContext() ?? localComposerRef;
902911 const [showScrollToBottom, setShowScrollToBottom] = useState(false);
......@@ -2982,6 +2991,7 @@ export default function ChatView(props: ChatViewProps) {
29822991 const {
29832992 images: composerImages,
29842993 terminalContexts: composerTerminalContexts,
2994 elementContexts: composerElementContexts,
29852995 selectedProvider: ctxSelectedProvider,
29862996 selectedModel: ctxSelectedModel,
29872997 selectedProviderModels: ctxSelectedProviderModels,
......@@ -2998,6 +3008,7 @@ export default function ChatView(props: ChatViewProps) {
29983008 prompt: promptForSend,
29993009 imageCount: composerImages.length,
30003010 terminalContexts: composerTerminalContexts,
3011 elementContextCount: composerElementContexts.length,
30013012 });
30023013 if (showPlanFollowUpPrompt && activeProposedPlan) {
30033014 const followUp = resolvePlanFollowUpSubmission({
......@@ -3014,7 +3025,9 @@ export default function ChatView(props: ChatViewProps) {
30143025 return;
30153026 }
30163027 const standaloneSlashCommand =
3017 composerImages.length === 0 && sendableComposerTerminalContexts.length === 0
3028 composerImages.length === 0 &&
3029 sendableComposerTerminalContexts.length === 0 &&
3030 composerElementContexts.length === 0
30183031 ? parseStandaloneComposerSlashCommand(trimmed)
30193032 : null;
30203033 if (standaloneSlashCommand) {
......@@ -3062,9 +3075,10 @@ export default function ChatView(props: ChatViewProps) {
30623075
30633076 const composerImagesSnapshot = [...composerImages];
30643077 const composerTerminalContextsSnapshot = [...sendableComposerTerminalContexts];
3065 const messageTextForSend = appendTerminalContextsToPrompt(
3066 promptForSend,
3067 composerTerminalContextsSnapshot,
3078 const composerElementContextsSnapshot = [...composerElementContexts];
3079 const messageTextForSend = appendElementContextsToPrompt(
3080 appendTerminalContextsToPrompt(promptForSend, composerTerminalContextsSnapshot),
3081 composerElementContextsSnapshot,
30683082 );
30693083 const messageIdForSend = newMessageId();
30703084 const messageCreatedAt = new Date().toISOString();
......@@ -3145,6 +3159,8 @@ export default function ChatView(props: ChatViewProps) {
31453159 titleSeed = `Image: ${firstComposerImageName}`;
31463160 } else if (composerTerminalContextsSnapshot.length > 0) {
31473161 titleSeed = formatTerminalContextLabel(composerTerminalContextsSnapshot[0]!);
3162 } else if (composerElementContextsSnapshot.length > 0) {
3163 titleSeed = formatElementContextLabel(composerElementContextsSnapshot[0]!);
31483164 } else {
31493165 titleSeed = "New thread";
31503166 }
......@@ -3230,7 +3246,8 @@ export default function ChatView(props: ChatViewProps) {
32303246 !turnStartSucceeded &&
32313247 promptRef.current.length === 0 &&
32323248 composerImagesRef.current.length === 0 &&
3233 composerTerminalContextsRef.current.length === 0
3249 composerTerminalContextsRef.current.length === 0 &&
3250 composerElementContextsRef.current.length === 0
32343251 ) {
32353252 setOptimisticUserMessages((existing) => {
32363253 const removed = existing.filter((message) => message.id === messageIdForSend);
......@@ -3244,9 +3261,11 @@ export default function ChatView(props: ChatViewProps) {
32443261 const retryComposerImages = composerImagesSnapshot.map(cloneComposerImageForRetry);
32453262 composerImagesRef.current = retryComposerImages;
32463263 composerTerminalContextsRef.current = composerTerminalContextsSnapshot;
3264 composerElementContextsRef.current = composerElementContextsSnapshot;
32473265 setComposerDraftPrompt(composerDraftTarget, promptForSend);
32483266 addComposerDraftImages(composerDraftTarget, retryComposerImages);
32493267 setComposerDraftTerminalContexts(composerDraftTarget, composerTerminalContextsSnapshot);
3268 setComposerDraftElementContexts(composerDraftTarget, composerElementContextsSnapshot);
32503269 composerRef.current?.resetCursorState({
32513270 cursor: collapseExpandedComposerCursor(promptForSend, promptForSend.length),
32523271 prompt: promptForSend,
......@@ -4045,6 +4064,7 @@ export default function ChatView(props: ChatViewProps) {
40454064 promptRef={promptRef}
40464065 composerImagesRef={composerImagesRef}
40474066 composerTerminalContextsRef={composerTerminalContextsRef}
4067 composerElementContextsRef={composerElementContextsRef}
40484068 shouldAutoScrollRef={isAtEndRef}
40494069 scheduleStickToBottom={scrollToEnd}
40504070 onSend={onSend}
apps/web/src/components/chat/ChatComposer.tsx+38-7
......@@ -17,6 +17,10 @@ import {
1717 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
1818 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
1919} from "@t3tools/contracts";
20import {
21 connectionStatusText,
22 type EnvironmentConnectionPresentation,
23} from "@t3tools/client-runtime/connection";
2024import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger";
2125import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model";
2226import {
......@@ -53,6 +57,8 @@ import {
5357 removeInlineTerminalContextPlaceholder,
5458} from "../../lib/terminalContext";
5559import { useComposerPathSearch } from "../../lib/composerPathSearchState";
60import { type ElementContextDraft } from "../../lib/elementContext";
61import { ComposerPendingElementContexts } from "./ComposerPendingElementContexts";
5662import {
5763 shouldUseCompactComposerPrimaryActions,
5864 shouldUseCompactComposerFooter,
......@@ -400,6 +406,7 @@ export interface ChatComposerHandle {
400406 prompt: string;
401407 images: ComposerImageAttachment[];
402408 terminalContexts: TerminalContextDraft[];
409 elementContexts: ElementContextDraft[];
403410 selectedPromptEffort: string | null;
404411 selectedModelOptionsForDispatch: unknown;
405412 selectedModelSelection: ModelSelection;
......@@ -434,7 +441,7 @@ export interface ChatComposerProps {
434441 isPreparingWorktree: boolean;
435442 environmentUnavailable: {
436443 readonly label: string;
437 readonly connectionState: "connecting" | "disconnected" | "error";
444 readonly connection: EnvironmentConnectionPresentation;
438445 } | null;
439446
440447 // Pending approvals / inputs
......@@ -486,6 +493,7 @@ export interface ChatComposerProps {
486493 promptRef: React.RefObject<string>;
487494 composerImagesRef: React.RefObject<ComposerImageAttachment[]>;
488495 composerTerminalContextsRef: React.RefObject<TerminalContextDraft[]>;
496 composerElementContextsRef: React.RefObject<ElementContextDraft[]>;
489497 composerRef: React.RefObject<ChatComposerHandle | null>;
490498
491499 // Scroll
......@@ -576,6 +584,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
576584 composerRef,
577585 composerImagesRef,
578586 composerTerminalContextsRef,
587 composerElementContextsRef,
579588 shouldAutoScrollRef,
580589 scheduleStickToBottom,
581590 onSend,
......@@ -605,6 +614,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
605614 const prompt = composerDraft.prompt;
606615 const composerImages = composerDraft.images;
607616 const composerTerminalContexts = composerDraft.terminalContexts;
617 const composerElementContexts = composerDraft.elementContexts;
608618 const nonPersistedComposerImageIds = composerDraft.nonPersistedImageIds;
609619
610620 const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt);
......@@ -620,6 +630,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
620630 const setComposerDraftTerminalContexts = useComposerDraftStore(
621631 (store) => store.setTerminalContexts,
622632 );
633 const removeComposerDraftElementContext = useComposerDraftStore(
634 (store) => store.removeElementContext,
635 );
623636 const clearComposerDraftPersistedAttachments = useComposerDraftStore(
624637 (store) => store.clearPersistedAttachments,
625638 );
......@@ -880,8 +893,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
880893 prompt,
881894 imageCount: composerImages.length,
882895 terminalContexts: composerTerminalContexts,
896 elementContextCount: composerElementContexts.length,
883897 }),
884 [composerImages.length, composerTerminalContexts, prompt],
898 [composerElementContexts.length, composerImages.length, composerTerminalContexts, prompt],
885899 );
886900
887901 // ------------------------------------------------------------------
......@@ -1170,6 +1184,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
11701184 composerTerminalContextsRef.current = composerTerminalContexts;
11711185 }, [composerTerminalContexts, composerTerminalContextsRef]);
11721186
1187 useEffect(() => {
1188 composerElementContextsRef.current = composerElementContexts;
1189 }, [composerElementContexts, composerElementContextsRef]);
1190
11731191 // ------------------------------------------------------------------
11741192 // Composer menu highlight sync
11751193 // ------------------------------------------------------------------
......@@ -1969,6 +1987,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
19691987 prompt: promptRef.current,
19701988 images: composerImagesRef.current,
19711989 terminalContexts: composerTerminalContextsRef.current,
1990 elementContexts: composerElementContextsRef.current,
19721991 selectedPromptEffort,
19731992 selectedModelOptionsForDispatch,
19741993 selectedModelSelection,
......@@ -1986,6 +2005,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
19862005 promptRef,
19872006 composerImagesRef,
19882007 composerTerminalContextsRef,
2008 composerElementContextsRef,
19892009 isConnecting,
19902010 isComposerApprovalState,
19912011 pendingUserInputs.length,
......@@ -2223,6 +2243,19 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
22232243 </div>
22242244 )}
22252245
2246 {!isComposerCollapsedMobile &&
2247 !isComposerApprovalState &&
2248 pendingUserInputs.length === 0 &&
2249 composerElementContexts.length > 0 && (
2250 <ComposerPendingElementContexts
2251 contexts={composerElementContexts}
2252 onRemove={(contextId) =>
2253 removeComposerDraftElementContext(composerDraftTarget, contextId)
2254 }
2255 className="mb-3"
2256 />
2257 )}
2258
22262259 {!isComposerCollapsedMobile &&
22272260 !isComposerApprovalState &&
22282261 pendingUserInputs.length === 0 &&
......@@ -2321,11 +2354,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
23212354 : showPlanFollowUpPrompt && activeProposedPlan
23222355 ? "Add feedback to refine the plan, or leave this blank to implement it"
23232356 : environmentUnavailable
2324 ? `${environmentUnavailable.label} is ${
2325 environmentUnavailable.connectionState === "connecting"
2326 ? "connecting"
2327 : "disconnected"
2328 }`
2357 ? `${environmentUnavailable.label}: ${connectionStatusText(
2358 environmentUnavailable.connection,
2359 )}`
23292360 : phase === "disconnected"
23302361 ? "Ask for follow-up changes or attach images"
23312362 : "Ask anything, @tag files/folders, $use skills, or / for commands"
apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx+5-7
......@@ -17,7 +17,7 @@ import { createModelCapabilities, createModelSelection } from "@t3tools/shared/m
1717
1818import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu";
1919import { TraitsMenuContent } from "./TraitsPicker";
20import { useComposerDraftStore } from "../../composerDraftStore";
20import { createEmptyThreadDraft, useComposerDraftStore } from "../../composerDraftStore";
2121
2222const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("environment-local");
2323
......@@ -60,18 +60,16 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str
6060
6161 useComposerDraftStore.setState({
6262 draftsByThreadKey: {
63 // Compose from the canonical empty-draft factory so adding a new
64 // ComposerThreadDraftState slice (e.g. a future attachment kind) doesn't
65 // silently break this stub via `Property X is missing in type ...`.
6366 [threadKey]: {
67 ...createEmptyThreadDraft(),
6468 prompt: props?.prompt ?? "",
65 images: [],
66 nonPersistedImageIds: [],
67 persistedAttachments: [],
68 terminalContexts: [],
6969 modelSelectionByProvider: {
7070 [instanceId]: createModelSelection(instanceId, model, props?.modelSelection?.options),
7171 },
7272 activeProvider: instanceId,
73 runtimeMode: null,
74 interactionMode: null,
7573 },
7674 },
7775 draftThreadsByThreadKey: {},
apps/web/src/components/chat/ComposerPendingElementContexts.tsx created+95
......@@ -0,0 +1,95 @@
1import { MousePointerClick, X } from "lucide-react";
2
3import {
4 COMPOSER_INLINE_CHIP_CLASS_NAME,
5 COMPOSER_INLINE_CHIP_DISMISS_BUTTON_CLASS_NAME,
6 COMPOSER_INLINE_CHIP_ICON_CLASS_NAME,
7 COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME,
8} from "../composerInlineChip";
9import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
10import { cn } from "~/lib/utils";
11import {
12 type ElementContextDraft,
13 formatElementContextLabel,
14 formatElementContextSourceLabel,
15} from "~/lib/elementContext";
16
17interface ComposerPendingElementContextsProps {
18 contexts: ReadonlyArray<ElementContextDraft>;
19 onRemove: (contextId: string) => void;
20 className?: string;
21}
22
23interface ComposerPendingElementContextChipProps {
24 context: ElementContextDraft;
25 onRemove: (contextId: string) => void;
26}
27
28function buildTooltipContent(context: ElementContextDraft): string {
29 const lines: string[] = [];
30 lines.push(formatElementContextLabel(context));
31 const source = formatElementContextSourceLabel(context);
32 if (source) lines.push(source);
33 if (context.pageUrl) lines.push(context.pageUrl);
34 if (context.selector) lines.push(context.selector);
35 if (context.htmlPreview.trim().length > 0) {
36 lines.push("");
37 lines.push(context.htmlPreview.trim().slice(0, 600));
38 }
39 return lines.join("\n");
40}
41
42export function ComposerPendingElementContextChip({
43 context,
44 onRemove,
45}: ComposerPendingElementContextChipProps) {
46 const label = formatElementContextLabel(context);
47 const sourceLabel = formatElementContextSourceLabel(context);
48 return (
49 <Tooltip>
50 <TooltipTrigger
51 render={
52 <span className={cn(COMPOSER_INLINE_CHIP_CLASS_NAME, "pr-1")}>
53 <MousePointerClick className={cn(COMPOSER_INLINE_CHIP_ICON_CLASS_NAME, "size-3.5")} />
54 <span className={COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME}>{label}</span>
55 {sourceLabel ? (
56 <span className="select-none text-[10px] font-normal leading-tight text-muted-foreground/85">
57 {sourceLabel}
58 </span>
59 ) : null}
60 <button
61 type="button"
62 aria-label={`Remove ${label}`}
63 className={COMPOSER_INLINE_CHIP_DISMISS_BUTTON_CLASS_NAME}
64 onClick={(event) => {
65 event.preventDefault();
66 event.stopPropagation();
67 onRemove(context.id);
68 }}
69 >
70 <X className="size-3" aria-hidden />
71 </button>
72 </span>
73 }
74 />
75 <TooltipPopup side="top" className="max-w-96 whitespace-pre-wrap leading-tight">
76 {buildTooltipContent(context)}
77 </TooltipPopup>
78 </Tooltip>
79 );
80}
81
82export function ComposerPendingElementContexts({
83 contexts,
84 onRemove,
85 className,
86}: ComposerPendingElementContextsProps) {
87 if (contexts.length === 0) return null;
88 return (
89 <div className={cn("flex flex-wrap gap-1.5", className)}>
90 {contexts.map((context) => (
91 <ComposerPendingElementContextChip key={context.id} context={context} onRemove={onRemove} />
92 ))}
93 </div>
94 );
95}
apps/web/src/components/chat/MessagesTimeline.tsx+42-9
......@@ -46,6 +46,7 @@ import {
4646 GlobeIcon,
4747 HammerIcon,
4848 MessageCircleIcon,
49 MousePointerClickIcon,
4950 MinusIcon,
5051 SquarePenIcon,
5152 TerminalIcon,
......@@ -76,6 +77,10 @@ import {
7677 deriveDisplayedUserMessageState,
7778 type ParsedTerminalContextEntry,
7879} from "~/lib/terminalContext";
80import {
81 extractTrailingElementContexts,
82 type ParsedElementContextEntry,
83} from "~/lib/elementContext";
7984import { cn } from "~/lib/utils";
8085import { useUiStateStore } from "~/uiStateStore";
8186import { type TimestampFormat } from "@t3tools/contracts/settings";
......@@ -424,6 +429,7 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"
424429 const userImages = row.message.attachments ?? [];
425430 const displayedUserMessage = deriveDisplayedUserMessageState(row.message.text);
426431 const terminalContexts = displayedUserMessage.contexts;
432 const elementContextState = extractTrailingElementContexts(displayedUserMessage.visibleText);
427433 const canRevertAgentWork = typeof row.revertTurnCount === "number";
428434
429435 return (
......@@ -462,8 +468,18 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"
462468 ))}
463469 </div>
464470 )}
471 {elementContextState.contexts.length > 0 ? (
472 <div className="mb-2 flex flex-wrap gap-1.5">
473 {elementContextState.contexts.map((context, index) => (
474 <UserMessageElementContextChip
475 key={`${context.header}:${index}`}
476 context={context}
477 />
478 ))}
479 </div>
480 ) : null}
465481 <CollapsibleUserMessageBody
466 text={displayedUserMessage.visibleText}
482 text={elementContextState.promptText}
467483 terminalContexts={terminalContexts}
468484 skills={ctx.skills}
469485 markdownCwd={ctx.markdownCwd}
......@@ -563,16 +579,10 @@ function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "mess
563579 <TooltipTrigger
564580 render={<p className="text-muted-foreground text-xs tabular-nums" />}
565581 >
566 {formatShortTimestamp(
567 row.message.completedAt ?? row.message.createdAt,
568 ctx.timestampFormat,
569 )}
582 {formatShortTimestamp(row.message.updatedAt, ctx.timestampFormat)}
570583 </TooltipTrigger>
571584 <TooltipPopup>
572 {formatChatTimestampTooltip(
573 row.message.completedAt ?? row.message.createdAt,
574 ctx.timestampFormat,
575 )}
585 {formatChatTimestampTooltip(row.message.updatedAt, ctx.timestampFormat)}
576586 </TooltipPopup>
577587 </Tooltip>
578588 )}
......@@ -899,6 +909,29 @@ const UserMessageTerminalContextInlineLabel = memo(
899909 },
900910);
901911
912const UserMessageElementContextChip = memo(function UserMessageElementContextChip(props: {
913 context: ParsedElementContextEntry;
914}) {
915 const tooltipText = props.context.body
916 ? `${props.context.header}\n${props.context.body}`
917 : props.context.header;
918 return (
919 <Tooltip>
920 <TooltipTrigger
921 render={
922 <span className="inline-flex max-w-full items-center gap-1 rounded-md border border-border/70 bg-background/70 px-1.5 py-0.5 text-xs text-foreground/85">
923 <MousePointerClickIcon className="size-3 shrink-0" />
924 <span className="truncate">{props.context.header}</span>
925 </span>
926 }
927 />
928 <TooltipPopup side="top" className="max-w-96 whitespace-pre-wrap leading-tight">
929 {tooltipText}
930 </TooltipPopup>
931 </Tooltip>
932 );
933});
934
902935const MAX_COLLAPSED_USER_MESSAGE_LINES = 8;
903936const MAX_COLLAPSED_USER_MESSAGE_LENGTH = 600;
904937const COLLAPSED_USER_MESSAGE_FADE_HEIGHT_REM = 1.75;
apps/web/src/components/preview/PreviewChromeRow.tsx+48-1
......@@ -1,4 +1,11 @@
1import { ArrowLeft, ArrowRight, ExternalLink, Globe, RotateCw } from "lucide-react";
1import {
2 ArrowLeft,
3 ArrowRight,
4 ExternalLink,
5 Globe,
6 MousePointerClick,
7 RotateCw,
8} from "lucide-react";
29import {
310 type FormEvent,
411 type KeyboardEvent,
......@@ -29,6 +36,16 @@ interface Props {
2936 onSubmit: (url: string) => void;
3037 /** When provided, renders an "Open in browser" affordance to the right. */
3138 onOpenInBrowser?: (() => void) | undefined;
39 /**
40 * When provided, renders a "Select element" toggle button to the right of
41 * the URL input. Pressed while a pick is active (button shows in `pressed`
42 * state). Disabled in `pickDisabled` mode.
43 */
44 onPickElement?: (() => void) | undefined;
45 pickActive?: boolean | undefined;
46 pickDisabled?: boolean | undefined;
47 /** Optional reason string surfaced in the disabled tooltip. */
48 pickDisabledReason?: string | undefined;
3249 /**
3350 * Trailing slot rendered after the URL input. Used by the preview view
3451 * to mount the three-dot menu (hard reload, devtools, zoom, clear data).
......@@ -52,6 +69,10 @@ export function PreviewChromeRow({
5269 onRefresh,
5370 onSubmit,
5471 onOpenInBrowser,
72 onPickElement,
73 pickActive,
74 pickDisabled,
75 pickDisabledReason,
5576 trailingActions,
5677}: Props) {
5778 const inputRef = useRef<HTMLInputElement | null>(null);
......@@ -162,6 +183,32 @@ export function PreviewChromeRow({
162183 />
163184 </InputGroup>
164185
186 {onPickElement ? (
187 <Tooltip>
188 <TooltipTrigger
189 render={
190 <Button
191 variant={pickActive ? "secondary" : "ghost"}
192 size="icon-xs"
193 onClick={onPickElement}
194 disabled={pickDisabled}
195 aria-label={pickActive ? "Cancel element pick" : "Select element from page"}
196 aria-pressed={pickActive ? "true" : "false"}
197 type="button"
198 />
199 }
200 >
201 <MousePointerClick className={cn(pickActive && "text-primary")} />
202 </TooltipTrigger>
203 <TooltipPopup>
204 {pickDisabled && pickDisabledReason
205 ? pickDisabledReason
206 : pickActive
207 ? "Cancel pick (Esc)"
208 : "Select element to attach"}
209 </TooltipPopup>
210 </Tooltip>
211 ) : null}
165212 {onOpenInBrowser ? (
166213 <Tooltip>
167214 <TooltipTrigger
apps/web/src/components/preview/PreviewView.tsx+85-2
......@@ -2,9 +2,11 @@
22
33import { scopedThreadKey } from "@t3tools/client-runtime";
44import { type ScopedThreadRef } from "@t3tools/contracts";
5import { useCallback, useEffect, useState } from "react";
5import { useCallback, useEffect, useRef, useState } from "react";
66
7import { useComposerDraftStore } from "~/composerDraftStore";
78import { ensureEnvironmentApi } from "~/environmentApi";
9import { normalizeElementContextSelection } from "~/lib/elementContext";
810import { ensureLocalApi } from "~/localApi";
911import { selectThreadPreviewState, usePreviewStateStore } from "~/previewStateStore";
1012
......@@ -33,13 +35,24 @@ const localApi = typeof window === "undefined" ? null : ensureLocalApi();
3335 */
3436export function PreviewView({ threadRef, configuredUrls, visible }: Props) {
3537 const [focusUrlNonce, setFocusUrlNonce] = useState(0);
38 const [pickActive, setPickActive] = useState(false);
39 const pickActiveRef = useRef(false);
40 const isMountedRef = useRef(true);
3641 const previewState = usePreviewStateStore((state) =>
3742 selectThreadPreviewState(state.byThreadKey, threadRef),
3843 );
3944 const rememberUrl = usePreviewStateStore((state) => state.rememberUrl);
45 const addElementContext = useComposerDraftStore((store) => store.addElementContext);
4046
4147 usePreviewSession(threadRef);
4248
49 useEffect(() => {
50 isMountedRef.current = true;
51 return () => {
52 isMountedRef.current = false;
53 };
54 }, []);
55
4356 const { snapshot, desktopOverlay } = previewState;
4457 const tabId = snapshot?.tabId ?? null;
4558 const navStatus = snapshot?.navStatus ?? { _tag: "Idle" as const };
......@@ -101,6 +114,67 @@ export function PreviewView({ threadRef, configuredUrls, visible }: Props) {
101114 void localApi.shell.openExternal(url).catch(() => undefined);
102115 }, [url]);
103116
117 const handlePickElement = useCallback(() => {
118 if (!previewBridge || !tabId) return;
119 if (pickActiveRef.current) {
120 void previewBridge.cancelPickElement(tabId).catch(() => undefined);
121 return;
122 }
123 // Snapshot whatever the user was focused on (typically the chat
124 // composer textarea or the chrome-row pick button) BEFORE main steals
125 // focus into the guest webContents. We restore it when the pick
126 // resolves so the user's typing context isn't lost — otherwise after
127 // every pick they'd have to click back into the textarea.
128 const previouslyFocused =
129 typeof document !== "undefined" ? (document.activeElement as HTMLElement | null) : null;
130 pickActiveRef.current = true;
131 setPickActive(true);
132 void (async () => {
133 try {
134 const payload = await previewBridge.pickElement(tabId);
135 if (!payload) return;
136 const selection = normalizeElementContextSelection(payload);
137 if (!selection) return;
138 addElementContext(threadRef, selection);
139 } catch {
140 // Picker failed (e.g. webview navigated). Treat as silent cancel.
141 } finally {
142 pickActiveRef.current = false;
143 // Avoid `setState on unmounted component` if the panel/thread closed
144 // while the pick was in flight.
145 if (isMountedRef.current) setPickActive(false);
146 // Best-effort: restore focus to whatever the user had before the
147 // pick stole it into the guest webContents. Skip if the previously-
148 // focused element was unmounted or is no longer focusable.
149 if (
150 previouslyFocused &&
151 previouslyFocused.isConnected &&
152 typeof previouslyFocused.focus === "function"
153 ) {
154 try {
155 previouslyFocused.focus({ preventScroll: true });
156 } catch {
157 // Some elements throw on .focus() (detached iframes, etc.).
158 }
159 }
160 }
161 })();
162 }, [addElementContext, tabId, threadRef]);
163
164 // If the active tab changes mid-pick (close, thread switch, hot restart),
165 // tell main to tear down the in-flight session AND reset our local toggle
166 // state so the button doesn't get stuck pressed against a stale tab id.
167 useEffect(() => {
168 return () => {
169 if (!pickActiveRef.current) return;
170 pickActiveRef.current = false;
171 if (previewBridge && tabId) {
172 void previewBridge.cancelPickElement(tabId).catch(() => undefined);
173 }
174 if (isMountedRef.current) setPickActive(false);
175 };
176 }, [tabId]);
177
104178 // Subscribe only while visible; `toggle-panel` is owned by ChatView's
105179 // URL-aware handler regardless of whether the panel is currently mounted.
106180 useEffect(() => {
......@@ -146,6 +220,15 @@ export function PreviewView({ threadRef, configuredUrls, visible }: Props) {
146220 onRefresh={handleRefresh}
147221 onSubmit={(next) => void handleSubmitUrl(next)}
148222 onOpenInBrowser={tabId ? handleOpenInBrowser : undefined}
223 onPickElement={previewBridge && tabId ? handlePickElement : undefined}
224 pickActive={pickActive}
225 // Disable when there's no tab (nothing to pick on) OR the page
226 // failed to load (a React overlay covers the webview, so the
227 // user wouldn't be able to actually click anything underneath).
228 pickDisabled={!tabId || isUnreachable}
229 pickDisabledReason={
230 isUnreachable ? "Page didn't load — pick unavailable until the page renders" : undefined
231 }
149232 trailingActions={
150233 previewBridge ? (
151234 <PreviewMoreMenu
......@@ -177,7 +260,7 @@ export function PreviewView({ threadRef, configuredUrls, visible }: Props) {
177260 {snapshot && desktopOverlay ? (
178261 <ZoomIndicator zoomFactor={desktopOverlay.zoomFactor} />
179262 ) : null}
180 {isUnreachable && navStatus._tag === "LoadFailed" ? (
263 {navStatus._tag === "LoadFailed" ? (
181264 <div className="absolute inset-0 z-10 bg-background">
182265 <PreviewUnreachable
183266 url={navStatus.url}
apps/web/src/components/preview/PreviewWebview.tsx+36-10
......@@ -1,7 +1,7 @@
11"use client";
22
3import type { ScopedThreadRef } from "@t3tools/contracts";
4import { useEffect, useRef, useState } from "react";
3import type { DesktopPreviewWebviewConfig, ScopedThreadRef } from "@t3tools/contracts";
4import { useCallback, useEffect, useRef, useState } from "react";
55
66import { isElectron } from "~/env";
77
......@@ -25,7 +25,13 @@ interface Props {
2525interface ElectronWebview extends HTMLElement {
2626 src: string;
2727 partition: string;
28 allowpopups: boolean;
28 preload?: string;
29 /**
30 * Comma-separated `key=value` web-preferences override. We use it to drop
31 * `contextIsolation` so the picker preload can see the guest page's
32 * `__REACT_DEVTOOLS_GLOBAL_HOOK__` and resolve component names.
33 */
34 webpreferences?: string;
2935 reload: () => void;
3036 getWebContentsId: () => number;
3137}
......@@ -44,7 +50,7 @@ declare global {
4450 * after mount throws.
4551 */
4652export function PreviewWebview({ threadRef, tabId, initialUrl, className }: Props) {
47 const [config, setConfig] = useState<{ partition: string } | null>(null);
53 const [config, setConfig] = useState<DesktopPreviewWebviewConfig | null>(null);
4854 const webviewRef = useRef<ElectronWebview | null>(null);
4955 // Capture once at mount; never re-derived from `initialUrl` props later.
5056 const initialSrcRef = useRef<string>(initialUrl ?? "about:blank");
......@@ -57,11 +63,11 @@ export function PreviewWebview({ threadRef, tabId, initialUrl, className }: Prop
5763 useEffect(() => {
5864 if (!bridge) return;
5965 let cancelled = false;
60 void bridge
61 .getBrowserPartition()
62 .then((partition) => {
66 bridge
67 .getPreviewConfig()
68 .then((next) => {
6369 if (cancelled) return;
64 setConfig({ partition });
70 setConfig(next);
6571 })
6672 .catch(() => undefined);
6773 return () => {
......@@ -69,6 +75,21 @@ export function PreviewWebview({ threadRef, tabId, initialUrl, className }: Prop
6975 };
7076 }, [bridge]);
7177
78 // Stable callback ref so React doesn't re-invoke the closure on every
79 // commit (otherwise we'd be re-asserting `allowpopups` and re-storing the
80 // ref pointer per render, which is harmless but wasteful).
81 const setWebviewRef = useCallback((node: HTMLElement | null) => {
82 webviewRef.current = node as ElectronWebview | null;
83 if (node && !node.hasAttribute("allowpopups")) {
84 // React's @types declare `allowpopups` as `boolean`, but the runtime
85 // doesn't list <webview> in its boolean-attribute allowlist, so
86 // passing it via JSX triggers a "received true for a non-boolean
87 // attribute" warning. Setting it imperatively bypasses React's prop
88 // normalization entirely.
89 node.setAttribute("allowpopups", "true");
90 }
91 }, []);
92
7293 useEffect(() => {
7394 if (!bridge || !config) return;
7495 const webview = webviewRef.current;
......@@ -103,12 +124,17 @@ export function PreviewWebview({ threadRef, tabId, initialUrl, className }: Prop
103124
104125 if (!isElectron || !bridge || !config) return null;
105126
127 // `preload` and `webpreferences` are HTML attributes Electron only reads
128 // at element-attach time — a state update later in the lifecycle would
129 // have no effect. The renderer-attached values are also enforced by the
130 // main-process `will-attach-webview` validator (defense in depth).
106131 return (
107132 <webview
108 ref={webviewRef}
133 ref={setWebviewRef}
109134 src={initialSrcRef.current}
110135 partition={config.partition}
111 allowpopups
136 webpreferences={config.webPreferences}
137 {...(config.preloadUrl ? { preload: config.preloadUrl } : {})}
112138 data-preview-tab={tabId}
113139 className={className}
114140 />
apps/web/src/composerDraftStore.test.ts+93
......@@ -522,6 +522,99 @@ describe("composerDraftStore terminal contexts", () => {
522522 });
523523});
524524
525describe("composerDraftStore element contexts", () => {
526 const threadId = ThreadId.make("thread-element");
527 const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
528 const baseSelection = {
529 pageUrl: "https://example.com/dashboard",
530 pageTitle: "Dashboard",
531 tagName: "button",
532 selector: "button.submit",
533 htmlPreview: "<button>Save</button>",
534 componentName: "SubmitButton",
535 source: {
536 functionName: "SubmitButton",
537 fileName: "/repo/Button.tsx",
538 lineNumber: 12,
539 columnNumber: 5,
540 },
541 styles: ".submit { color: white; }",
542 } as const;
543
544 beforeEach(() => {
545 resetComposerDraftStore();
546 });
547
548 it("adds an element context and stamps id + threadId + pickedAt", () => {
549 const accepted = useComposerDraftStore.getState().addElementContext(threadRef, baseSelection);
550 expect(accepted).toBe(true);
551 const draft = draftFor(threadId, TEST_ENVIRONMENT_ID);
552 expect(draft?.elementContexts).toHaveLength(1);
553 const entry = draft?.elementContexts[0]!;
554 expect(entry.id.startsWith("el_")).toBe(true);
555 expect(entry.threadId).toBe(threadId);
556 expect(entry.pickedAt.length).toBeGreaterThan(0);
557 expect(entry.componentName).toBe("SubmitButton");
558 });
559
560 it("dedupes by selector + tag + componentName + pageUrl signature", () => {
561 const store = useComposerDraftStore.getState();
562 expect(store.addElementContext(threadRef, baseSelection)).toBe(true);
563 const second = store.addElementContext(threadRef, {
564 ...baseSelection,
565 htmlPreview: "<button>Save 2</button>",
566 });
567 expect(second).toBe(false);
568 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.elementContexts).toHaveLength(1);
569 });
570
571 it("removeElementContext drops by id + leaves siblings intact", () => {
572 const store = useComposerDraftStore.getState();
573 store.addElementContext(threadRef, baseSelection);
574 store.addElementContext(threadRef, { ...baseSelection, selector: "button.cancel" });
575 const ids = draftFor(threadId, TEST_ENVIRONMENT_ID)!.elementContexts.map((c) => c.id);
576 store.removeElementContext(threadRef, ids[0]!);
577 const remaining = draftFor(threadId, TEST_ENVIRONMENT_ID)?.elementContexts;
578 expect(remaining?.map((c) => c.id)).toEqual([ids[1]]);
579 });
580
581 it("setElementContexts replaces the slice and clearComposerContent wipes it", () => {
582 const store = useComposerDraftStore.getState();
583 store.addElementContext(threadRef, baseSelection);
584 store.setElementContexts(threadRef, []);
585 // Fully empty draft should be removed via shouldRemoveDraft.
586 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)).toBeUndefined();
587
588 store.addElementContext(threadRef, baseSelection);
589 store.clearComposerContent(threadRef);
590 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)).toBeUndefined();
591 });
592
593 it("persists element contexts via the partializer (round-trippable)", () => {
594 useComposerDraftStore.getState().addElementContext(threadRef, baseSelection);
595 const persistApi = useComposerDraftStore.persist as unknown as {
596 getOptions: () => {
597 partialize: (state: ReturnType<typeof useComposerDraftStore.getState>) => unknown;
598 };
599 };
600 const persisted = persistApi.getOptions().partialize(useComposerDraftStore.getState()) as {
601 draftsByThreadKey?: Record<string, { elementContexts?: Array<Record<string, unknown>> }>;
602 };
603 const entry =
604 persisted.draftsByThreadKey?.[threadKeyFor(threadId, TEST_ENVIRONMENT_ID)]
605 ?.elementContexts?.[0];
606 expect(entry).toMatchObject({
607 pageUrl: baseSelection.pageUrl,
608 tagName: baseSelection.tagName,
609 selector: baseSelection.selector,
610 componentName: baseSelection.componentName,
611 });
612 // Persistence does NOT include htmlPreview / styles oversize-clamping —
613 // that happens at normalization time, before the value reaches the store.
614 expect(typeof entry?.htmlPreview).toBe("string");
615 });
616});
617
525618describe("composerDraftStore project draft thread mapping", () => {
526619 const projectId = ProjectId.make("project-a");
527620 const otherProjectId = ProjectId.make("project-b");
apps/web/src/composerDraftStore.ts+244-1
......@@ -36,6 +36,12 @@ import {
3636 ensureInlineTerminalContextPlaceholders,
3737 normalizeTerminalContextText,
3838} from "./lib/terminalContext";
39import {
40 type ElementContextDraft,
41 type ElementContextSelection,
42 elementContextDedupKey,
43 newElementContextId,
44} from "./lib/elementContext";
3945import { create } from "zustand";
4046import { createJSONStorage, persist } from "zustand/middleware";
4147import { useShallow } from "zustand/react/shallow";
......@@ -92,10 +98,33 @@ const PersistedTerminalContextDraft = Schema.Struct({
9298});
9399type PersistedTerminalContextDraft = typeof PersistedTerminalContextDraft.Type;
94100
101const PersistedElementContextStackFrame = Schema.Struct({
102 functionName: Schema.NullOr(Schema.String),
103 fileName: Schema.NullOr(Schema.String),
104 lineNumber: Schema.NullOr(Schema.Number),
105 columnNumber: Schema.NullOr(Schema.Number),
106});
107
108const PersistedElementContextDraft = Schema.Struct({
109 id: Schema.String,
110 threadId: ThreadId,
111 pickedAt: Schema.String,
112 pageUrl: Schema.String,
113 pageTitle: Schema.NullOr(Schema.String),
114 tagName: Schema.String,
115 selector: Schema.NullOr(Schema.String),
116 htmlPreview: Schema.String,
117 componentName: Schema.NullOr(Schema.String),
118 source: Schema.NullOr(PersistedElementContextStackFrame),
119 styles: Schema.String,
120});
121type PersistedElementContextDraft = typeof PersistedElementContextDraft.Type;
122
95123const PersistedComposerThreadDraftState = Schema.Struct({
96124 prompt: Schema.String,
97125 attachments: Schema.Array(PersistedComposerImageAttachment),
98126 terminalContexts: Schema.optionalKey(Schema.Array(PersistedTerminalContextDraft)),
127 elementContexts: Schema.optionalKey(Schema.Array(PersistedElementContextDraft)),
99128 // Keyed by `ProviderInstanceId` (open branded slug) so custom provider
100129 // instances (e.g. `codex_personal`) round-trip alongside the built-in
101130 // `codex` / `claudeAgent` / ... entries. Every prior `ProviderDriverKind`
......@@ -216,6 +245,13 @@ export interface ComposerThreadDraftState {
216245 nonPersistedImageIds: string[];
217246 persistedAttachments: PersistedComposerImageAttachment[];
218247 terminalContexts: TerminalContextDraft[];
248 /**
249 * Element-pick attachments captured from the in-app preview browser. The
250 * full payload (selector / html / styles / source frame) is persisted
251 * inline because — unlike terminal contexts — there's no live session to
252 * re-derive the snapshot from on reload.
253 */
254 elementContexts: ElementContextDraft[];
219255 /**
220256 * Per-instance model selection. Keyed by `ProviderInstanceId` (open
221257 * branded slug) so a default `codex` instance and a user-authored
......@@ -397,6 +433,24 @@ interface ComposerDraftStoreState {
397433 addTerminalContexts: (threadRef: ComposerThreadTarget, contexts: TerminalContextDraft[]) => void;
398434 removeTerminalContext: (threadRef: ComposerThreadTarget, contextId: string) => void;
399435 clearTerminalContexts: (threadRef: ComposerThreadTarget) => void;
436 /**
437 * Append a fresh element pick to the draft. Returns true when accepted,
438 * false when deduped against an existing pick of the same element.
439 */
440 addElementContext: (
441 threadRef: ComposerThreadTarget,
442 selection: ElementContextSelection,
443 ) => boolean;
444 /**
445 * Replace the entire element-contexts list (used by send-failure retry to
446 * restore the pre-send snapshot).
447 */
448 setElementContexts: (
449 threadRef: ComposerThreadTarget,
450 contexts: ReadonlyArray<ElementContextDraft>,
451 ) => void;
452 removeElementContext: (threadRef: ComposerThreadTarget, contextId: string) => void;
453 clearElementContexts: (threadRef: ComposerThreadTarget) => void;
400454 clearPersistedAttachments: (threadRef: ComposerThreadTarget) => void;
401455 syncPersistedAttachments: (
402456 threadRef: ComposerThreadTarget,
......@@ -472,9 +526,11 @@ const EMPTY_IMAGES: ComposerImageAttachment[] = [];
472526const EMPTY_IDS: string[] = [];
473527const EMPTY_PERSISTED_ATTACHMENTS: PersistedComposerImageAttachment[] = [];
474528const EMPTY_TERMINAL_CONTEXTS: TerminalContextDraft[] = [];
529const EMPTY_ELEMENT_CONTEXTS: ElementContextDraft[] = [];
475530Object.freeze(EMPTY_IMAGES);
476531Object.freeze(EMPTY_IDS);
477532Object.freeze(EMPTY_PERSISTED_ATTACHMENTS);
533Object.freeze(EMPTY_ELEMENT_CONTEXTS);
478534const EMPTY_MODEL_SELECTION_BY_PROVIDER: Partial<Record<ProviderDriverKind, ModelSelection>> =
479535 Object.freeze({});
480536const EMPTY_COMPOSER_DRAFT_MODEL_STATE = Object.freeze<ComposerDraftModelState>({
......@@ -488,19 +544,27 @@ const EMPTY_THREAD_DRAFT = Object.freeze<ComposerThreadDraftState>({
488544 nonPersistedImageIds: EMPTY_IDS,
489545 persistedAttachments: EMPTY_PERSISTED_ATTACHMENTS,
490546 terminalContexts: EMPTY_TERMINAL_CONTEXTS,
547 elementContexts: EMPTY_ELEMENT_CONTEXTS,
491548 modelSelectionByProvider: EMPTY_MODEL_SELECTION_BY_PROVIDER,
492549 activeProvider: null,
493550 runtimeMode: null,
494551 interactionMode: null,
495552});
496553
497function createEmptyThreadDraft(): ComposerThreadDraftState {
554/**
555 * Canonical factory for a blank `ComposerThreadDraftState`. Exported so tests
556 * (and any other call sites) can build a draft without re-declaring every
557 * slice — adding a new field to the interface (e.g. `elementContexts`) only
558 * has to be reflected here, not in every stub.
559 */
560export function createEmptyThreadDraft(): ComposerThreadDraftState {
498561 return {
499562 prompt: "",
500563 images: [],
501564 nonPersistedImageIds: [],
502565 persistedAttachments: [],
503566 terminalContexts: [],
567 elementContexts: [],
504568 modelSelectionByProvider: {},
505569 activeProvider: null,
506570 runtimeMode: null,
......@@ -571,6 +635,7 @@ function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean {
571635 draft.images.length === 0 &&
572636 draft.persistedAttachments.length === 0 &&
573637 draft.terminalContexts.length === 0 &&
638 draft.elementContexts.length === 0 &&
574639 Object.keys(draft.modelSelectionByProvider).length === 0 &&
575640 draft.activeProvider === null &&
576641 draft.runtimeMode === null &&
......@@ -964,6 +1029,63 @@ function normalizePersistedAttachment(value: unknown): PersistedComposerImageAtt
9641029 };
9651030}
9661031
1032function normalizePersistedElementContextDraft(
1033 value: unknown,
1034): PersistedElementContextDraft | null {
1035 if (!value || typeof value !== "object") return null;
1036 const candidate = value as Record<string, unknown>;
1037 const id = candidate.id;
1038 const threadId = candidate.threadId;
1039 const pickedAt = candidate.pickedAt;
1040 const pageUrl = candidate.pageUrl;
1041 const tagName = candidate.tagName;
1042 if (
1043 typeof id !== "string" ||
1044 id.length === 0 ||
1045 typeof threadId !== "string" ||
1046 threadId.length === 0 ||
1047 typeof pickedAt !== "string" ||
1048 pickedAt.length === 0 ||
1049 typeof pageUrl !== "string" ||
1050 pageUrl.length === 0 ||
1051 typeof tagName !== "string" ||
1052 tagName.length === 0
1053 ) {
1054 return null;
1055 }
1056 const sourceCandidate = candidate.source;
1057 let source: PersistedElementContextDraft["source"] = null;
1058 if (sourceCandidate && typeof sourceCandidate === "object") {
1059 const sourceRecord = sourceCandidate as Record<string, unknown>;
1060 source = {
1061 functionName:
1062 typeof sourceRecord.functionName === "string" ? sourceRecord.functionName : null,
1063 fileName: typeof sourceRecord.fileName === "string" ? sourceRecord.fileName : null,
1064 lineNumber:
1065 typeof sourceRecord.lineNumber === "number" && Number.isFinite(sourceRecord.lineNumber)
1066 ? sourceRecord.lineNumber
1067 : null,
1068 columnNumber:
1069 typeof sourceRecord.columnNumber === "number" && Number.isFinite(sourceRecord.columnNumber)
1070 ? sourceRecord.columnNumber
1071 : null,
1072 };
1073 }
1074 return {
1075 id,
1076 threadId: threadId as ThreadId,
1077 pickedAt,
1078 pageUrl,
1079 pageTitle: typeof candidate.pageTitle === "string" ? candidate.pageTitle : null,
1080 tagName,
1081 selector: typeof candidate.selector === "string" ? candidate.selector : null,
1082 htmlPreview: typeof candidate.htmlPreview === "string" ? candidate.htmlPreview : "",
1083 componentName: typeof candidate.componentName === "string" ? candidate.componentName : null,
1084 source,
1085 styles: typeof candidate.styles === "string" ? candidate.styles : "",
1086 };
1087}
1088
9671089function normalizePersistedTerminalContextDraft(
9681090 value: unknown,
9691091): PersistedTerminalContextDraft | null {
......@@ -1478,6 +1600,12 @@ function normalizePersistedDraftsByThreadId(
14781600 return normalized ? [normalized] : [];
14791601 })
14801602 : [];
1603 const elementContexts = Array.isArray(draftCandidate.elementContexts)
1604 ? draftCandidate.elementContexts.flatMap((entry) => {
1605 const normalized = normalizePersistedElementContextDraft(entry);
1606 return normalized ? [normalized] : [];
1607 })
1608 : [];
14811609 const runtimeMode = isRuntimeMode(draftCandidate.runtimeMode)
14821610 ? draftCandidate.runtimeMode
14831611 : null;
......@@ -1541,6 +1669,7 @@ function normalizePersistedDraftsByThreadId(
15411669 promptCandidate.length === 0 &&
15421670 attachments.length === 0 &&
15431671 terminalContexts.length === 0 &&
1672 elementContexts.length === 0 &&
15441673 !hasModelData &&
15451674 !runtimeMode &&
15461675 !interactionMode
......@@ -1563,6 +1692,7 @@ function normalizePersistedDraftsByThreadId(
15631692 prompt,
15641693 attachments,
15651694 ...(terminalContexts.length > 0 ? { terminalContexts } : {}),
1695 ...(elementContexts.length > 0 ? { elementContexts } : {}),
15661696 ...(hasModelData
15671697 ? {
15681698 modelSelectionByProvider: compactModelSelectionByProvider(modelSelectionByProvider),
......@@ -1645,6 +1775,7 @@ function partializeComposerDraftStoreState(
16451775 draft.prompt.length === 0 &&
16461776 draft.persistedAttachments.length === 0 &&
16471777 draft.terminalContexts.length === 0 &&
1778 draft.elementContexts.length === 0 &&
16481779 !hasModelData &&
16491780 draft.runtimeMode === null &&
16501781 draft.interactionMode === null
......@@ -1667,6 +1798,23 @@ function partializeComposerDraftStoreState(
16671798 })),
16681799 }
16691800 : {}),
1801 ...(draft.elementContexts.length > 0
1802 ? {
1803 elementContexts: draft.elementContexts.map((context) => ({
1804 id: context.id,
1805 threadId: context.threadId,
1806 pickedAt: context.pickedAt,
1807 pageUrl: context.pageUrl,
1808 pageTitle: context.pageTitle,
1809 tagName: context.tagName,
1810 selector: context.selector,
1811 htmlPreview: context.htmlPreview,
1812 componentName: context.componentName,
1813 source: context.source,
1814 styles: context.styles,
1815 })),
1816 }
1817 : {}),
16701818 ...(hasModelData
16711819 ? {
16721820 modelSelectionByProvider: compactModelSelectionByProvider(
......@@ -1903,6 +2051,10 @@ function toHydratedThreadDraft(
19032051 ...context,
19042052 text: "",
19052053 })) ?? [],
2054 elementContexts:
2055 persistedDraft.elementContexts?.map((context) => ({
2056 ...context,
2057 })) ?? [],
19062058 modelSelectionByProvider,
19072059 activeProvider,
19082060 runtimeMode: persistedDraft.runtimeMode ?? null,
......@@ -2815,6 +2967,96 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
28152967 return { draftsByThreadKey: nextDraftsByThreadKey };
28162968 });
28172969 },
2970 addElementContext: (threadRef, selection) => {
2971 const threadKey = resolveComposerDraftKey(get(), threadRef);
2972 const threadId = resolveComposerThreadId(get(), threadRef);
2973 if (!threadKey || !threadId) return false;
2974 let accepted = false;
2975 set((state) => {
2976 const existing = state.draftsByThreadKey[threadKey] ?? createEmptyThreadDraft();
2977 const dedupKey = elementContextDedupKey(selection);
2978 if (
2979 existing.elementContexts.some((entry) => elementContextDedupKey(entry) === dedupKey)
2980 ) {
2981 return state;
2982 }
2983 accepted = true;
2984 const draft: ElementContextDraft = {
2985 ...selection,
2986 id: newElementContextId(),
2987 threadId,
2988 pickedAt: new Date().toISOString(),
2989 };
2990 return {
2991 draftsByThreadKey: {
2992 ...state.draftsByThreadKey,
2993 [threadKey]: {
2994 ...existing,
2995 elementContexts: [...existing.elementContexts, draft],
2996 },
2997 },
2998 };
2999 });
3000 return accepted;
3001 },
3002 setElementContexts: (threadRef, contexts) => {
3003 const threadKey = resolveComposerDraftKey(get(), threadRef);
3004 if (!threadKey) return;
3005 set((state) => {
3006 const existing = state.draftsByThreadKey[threadKey] ?? createEmptyThreadDraft();
3007 const nextDraft: ComposerThreadDraftState = {
3008 ...existing,
3009 elementContexts: [...contexts],
3010 };
3011 const nextDraftsByThreadKey = { ...state.draftsByThreadKey };
3012 if (shouldRemoveDraft(nextDraft)) {
3013 delete nextDraftsByThreadKey[threadKey];
3014 } else {
3015 nextDraftsByThreadKey[threadKey] = nextDraft;
3016 }
3017 return { draftsByThreadKey: nextDraftsByThreadKey };
3018 });
3019 },
3020 removeElementContext: (threadRef, contextId) => {
3021 const threadKey = resolveComposerDraftKey(get(), threadRef) ?? "";
3022 if (threadKey.length === 0 || contextId.length === 0) return;
3023 set((state) => {
3024 const current = state.draftsByThreadKey[threadKey];
3025 if (!current) return state;
3026 const filtered = current.elementContexts.filter((entry) => entry.id !== contextId);
3027 if (filtered.length === current.elementContexts.length) return state;
3028 const nextDraft: ComposerThreadDraftState = {
3029 ...current,
3030 elementContexts: filtered,
3031 };
3032 const nextDraftsByThreadKey = { ...state.draftsByThreadKey };
3033 if (shouldRemoveDraft(nextDraft)) {
3034 delete nextDraftsByThreadKey[threadKey];
3035 } else {
3036 nextDraftsByThreadKey[threadKey] = nextDraft;
3037 }
3038 return { draftsByThreadKey: nextDraftsByThreadKey };
3039 });
3040 },
3041 clearElementContexts: (threadRef) => {
3042 const threadKey = resolveComposerDraftKey(get(), threadRef) ?? "";
3043 if (threadKey.length === 0) return;
3044 set((state) => {
3045 const current = state.draftsByThreadKey[threadKey];
3046 if (!current || current.elementContexts.length === 0) return state;
3047 const nextDraft: ComposerThreadDraftState = {
3048 ...current,
3049 elementContexts: [],
3050 };
3051 const nextDraftsByThreadKey = { ...state.draftsByThreadKey };
3052 if (shouldRemoveDraft(nextDraft)) {
3053 delete nextDraftsByThreadKey[threadKey];
3054 } else {
3055 nextDraftsByThreadKey[threadKey] = nextDraft;
3056 }
3057 return { draftsByThreadKey: nextDraftsByThreadKey };
3058 });
3059 },
28183060 clearPersistedAttachments: (threadRef) => {
28193061 const threadKey = resolveComposerDraftKey(get(), threadRef) ?? "";
28203062 if (threadKey.length === 0) {
......@@ -2887,6 +3129,7 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
28873129 nonPersistedImageIds: [],
28883130 persistedAttachments: [],
28893131 terminalContexts: [],
3132 elementContexts: [],
28903133 };
28913134 const nextDraftsByThreadKey = { ...state.draftsByThreadKey };
28923135 if (shouldRemoveDraft(nextDraft)) {
apps/web/src/lib/elementContext.test.ts created+294
......@@ -0,0 +1,294 @@
1import type { PickedElementPayload } from "@t3tools/contracts";
2import { describe, expect, it } from "vitest";
3
4import {
5 appendElementContextsToPrompt,
6 buildElementContextBlock,
7 type ElementContextSelection,
8 elementContextDedupKey,
9 extractTrailingElementContexts,
10 formatElementContextLabel,
11 formatElementContextSourceLabel,
12 newElementContextId,
13 normalizeElementContextSelection,
14} from "./elementContext";
15
16function makePayload(overrides?: Partial<PickedElementPayload>): PickedElementPayload {
17 return {
18 pageUrl: "https://example.com/dashboard",
19 pageTitle: "Dashboard",
20 tagName: "BUTTON",
21 selector: "button.submit",
22 htmlPreview: '<button class="submit">Save</button>',
23 componentName: "SubmitButton",
24 source: {
25 functionName: "SubmitButton",
26 fileName: "/repo/src/Button.tsx",
27 lineNumber: 12,
28 columnNumber: 5,
29 },
30 stack: [
31 {
32 functionName: "SubmitButton",
33 fileName: "/repo/src/Button.tsx",
34 lineNumber: 12,
35 columnNumber: 5,
36 },
37 ],
38 styles: ".submit { color: white; }",
39 pickedAt: "2026-05-03T18:00:00.000Z",
40 ...overrides,
41 };
42}
43
44function makeSelection(overrides?: Partial<ElementContextSelection>): ElementContextSelection {
45 return {
46 pageUrl: "https://example.com/dashboard",
47 pageTitle: "Dashboard",
48 tagName: "button",
49 selector: "button.submit",
50 htmlPreview: '<button class="submit">Save</button>',
51 componentName: "SubmitButton",
52 source: {
53 functionName: "SubmitButton",
54 fileName: "/repo/src/Button.tsx",
55 lineNumber: 12,
56 columnNumber: 5,
57 },
58 styles: ".submit { color: white; }",
59 ...overrides,
60 };
61}
62
63describe("normalizeElementContextSelection", () => {
64 it("lowercases the tag, trims strings, and prefers `source` over `stack[0]`", () => {
65 const result = normalizeElementContextSelection(
66 makePayload({
67 tagName: " Button ",
68 pageUrl: " https://example.com ",
69 pageTitle: " Dashboard ",
70 selector: " ",
71 componentName: " ",
72 source: {
73 functionName: " Outer ",
74 fileName: " /repo/Outer.tsx ",
75 lineNumber: 7,
76 columnNumber: 0,
77 },
78 stack: [
79 {
80 functionName: "Inner",
81 fileName: "/repo/Inner.tsx",
82 lineNumber: 99,
83 columnNumber: 9,
84 },
85 ],
86 }),
87 );
88 expect(result).not.toBeNull();
89 expect(result?.tagName).toBe("button");
90 expect(result?.pageUrl).toBe("https://example.com");
91 expect(result?.pageTitle).toBe("Dashboard");
92 expect(result?.selector).toBeNull();
93 expect(result?.componentName).toBeNull();
94 expect(result?.source).toEqual({
95 functionName: "Outer",
96 fileName: "/repo/Outer.tsx",
97 lineNumber: 7,
98 columnNumber: 0,
99 });
100 });
101
102 it("returns null when pageUrl or tagName is empty", () => {
103 expect(normalizeElementContextSelection(makePayload({ pageUrl: "" }))).toBeNull();
104 expect(normalizeElementContextSelection(makePayload({ tagName: " " }))).toBeNull();
105 });
106
107 it("clamps oversized htmlPreview / styles so we don't blow localStorage", () => {
108 const huge = "x".repeat(10_000);
109 const result = normalizeElementContextSelection(
110 makePayload({ htmlPreview: huge, styles: huge }),
111 );
112 expect(result).not.toBeNull();
113 expect(result!.htmlPreview.length).toBeLessThanOrEqual(4000);
114 expect(result!.styles.length).toBeLessThanOrEqual(4000);
115 // Truncated values should end with the ellipsis sentinel
116 expect(result!.htmlPreview.endsWith("…")).toBe(true);
117 expect(result!.styles.endsWith("…")).toBe(true);
118 });
119
120 it("normalizes Windows line endings inside html/styles", () => {
121 const result = normalizeElementContextSelection(
122 makePayload({ htmlPreview: "<a>\r\nhi\r\n</a>", styles: ".a {\r\n color: red;\r\n}" }),
123 );
124 expect(result?.htmlPreview).toBe("<a>\nhi\n</a>");
125 expect(result?.styles).toBe(".a {\n color: red;\n}");
126 });
127
128 it("falls back to stack[0] when payload.source is null", () => {
129 const result = normalizeElementContextSelection(
130 makePayload({
131 source: null,
132 stack: [
133 {
134 functionName: "FromStack",
135 fileName: "/repo/FromStack.tsx",
136 lineNumber: 3,
137 columnNumber: null,
138 },
139 ],
140 }),
141 );
142 expect(result?.source).toEqual({
143 functionName: "FromStack",
144 fileName: "/repo/FromStack.tsx",
145 lineNumber: 3,
146 columnNumber: null,
147 });
148 });
149});
150
151describe("formatElementContextLabel", () => {
152 it("prefers component name over tag name", () => {
153 expect(formatElementContextLabel(makeSelection())).toBe("<SubmitButton>");
154 });
155
156 it("falls back to tag name when no component name is present", () => {
157 expect(formatElementContextLabel(makeSelection({ componentName: null }))).toBe("<button>");
158 });
159
160 it("truncates very long tag names", () => {
161 const tagName = "custom-".concat("x".repeat(60));
162 const result = formatElementContextLabel(makeSelection({ componentName: null, tagName }));
163 expect(result.length).toBeLessThan(tagName.length);
164 expect(result.endsWith("…>")).toBe(true);
165 });
166});
167
168describe("formatElementContextSourceLabel", () => {
169 it("renders basename + line number", () => {
170 expect(formatElementContextSourceLabel(makeSelection())).toBe("Button.tsx:12");
171 });
172
173 it("returns null when source has no fileName", () => {
174 expect(formatElementContextSourceLabel(makeSelection({ source: null }))).toBeNull();
175 });
176
177 it("omits line number when missing", () => {
178 expect(
179 formatElementContextSourceLabel(
180 makeSelection({
181 source: {
182 functionName: null,
183 fileName: "/a/b/Foo.tsx",
184 lineNumber: null,
185 columnNumber: null,
186 },
187 }),
188 ),
189 ).toBe("Foo.tsx");
190 });
191});
192
193describe("elementContextDedupKey", () => {
194 it("treats picks of the same element on the same page as duplicates", () => {
195 const first = makeSelection();
196 const second = makeSelection({ htmlPreview: "different preview", styles: "different styles" });
197 expect(elementContextDedupKey(first)).toBe(elementContextDedupKey(second));
198 });
199
200 it("differentiates picks with different selectors", () => {
201 expect(elementContextDedupKey(makeSelection())).not.toBe(
202 elementContextDedupKey(makeSelection({ selector: "button.cancel" })),
203 );
204 });
205
206 it("differentiates picks with different page urls", () => {
207 expect(elementContextDedupKey(makeSelection())).not.toBe(
208 elementContextDedupKey(makeSelection({ pageUrl: "https://example.com/other" })),
209 );
210 });
211});
212
213describe("buildElementContextBlock + appendElementContextsToPrompt", () => {
214 it("returns empty string for empty contexts", () => {
215 expect(buildElementContextBlock([])).toBe("");
216 expect(appendElementContextsToPrompt("Hello", [])).toBe("Hello");
217 });
218
219 it("serializes a context with header / url / selector / source / html / styles", () => {
220 const block = buildElementContextBlock([makeSelection()]);
221 expect(block.startsWith("<element_context>")).toBe(true);
222 expect(block.endsWith("</element_context>")).toBe(true);
223 expect(block).toContain("- <SubmitButton> (Button.tsx:12):");
224 expect(block).toContain(" url: https://example.com/dashboard");
225 expect(block).toContain(" selector: button.submit");
226 expect(block).toContain(" source: /repo/src/Button.tsx:12:5");
227 expect(block).toContain(" html:");
228 expect(block).toContain(" styles:");
229 });
230
231 it("appends with a blank line separator when prompt has text", () => {
232 expect(appendElementContextsToPrompt("Investigate this", [makeSelection()])).toBe(
233 [
234 "Investigate this",
235 "",
236 "<element_context>",
237 "- <SubmitButton> (Button.tsx:12):",
238 " url: https://example.com/dashboard",
239 " selector: button.submit",
240 " source: /repo/src/Button.tsx:12:5",
241 " html:",
242 ' <button class="submit">Save</button>',
243 " styles:",
244 " .submit { color: white; }",
245 "</element_context>",
246 ].join("\n"),
247 );
248 });
249
250 it("emits no leading blank when prompt is empty", () => {
251 expect(
252 appendElementContextsToPrompt("", [makeSelection()]).startsWith("<element_context>"),
253 ).toBe(true);
254 });
255});
256
257describe("extractTrailingElementContexts", () => {
258 it("round-trips appendElementContextsToPrompt and recovers prompt + entries", () => {
259 const prompt = appendElementContextsToPrompt("Investigate this", [
260 makeSelection(),
261 makeSelection({ selector: "button.cancel", componentName: "CancelButton" }),
262 ]);
263 const result = extractTrailingElementContexts(prompt);
264 expect(result.promptText).toBe("Investigate this");
265 expect(result.contextCount).toBe(2);
266 expect(result.contexts.map((c) => c.header)).toEqual([
267 "<SubmitButton> (Button.tsx:12)",
268 "<CancelButton> (Button.tsx:12)",
269 ]);
270 expect(result.contexts[0]?.body).toContain("url: https://example.com/dashboard");
271 expect(result.contexts[0]?.body).toContain("selector: button.submit");
272 });
273
274 it("returns the original prompt unchanged when no trailing block exists", () => {
275 expect(extractTrailingElementContexts("hi")).toEqual({
276 promptText: "hi",
277 contextCount: 0,
278 contexts: [],
279 });
280 });
281});
282
283describe("newElementContextId", () => {
284 it("returns a non-empty string with the element prefix", () => {
285 const id = newElementContextId();
286 expect(id.startsWith("el_")).toBe(true);
287 expect(id.length).toBeGreaterThan(3);
288 });
289
290 it("returns unique ids on repeated calls", () => {
291 const ids = new Set(Array.from({ length: 10 }, () => newElementContextId()));
292 expect(ids.size).toBe(10);
293 });
294});
apps/web/src/lib/elementContext.ts created+245
......@@ -0,0 +1,245 @@
1import { type ThreadId } from "@t3tools/contracts";
2import type { PickedElementPayload, PickedElementStackFrame } from "@t3tools/contracts";
3
4const ELEMENT_CONTEXT_HTML_PREVIEW_LIMIT = 4000;
5const ELEMENT_CONTEXT_STYLES_LIMIT = 4000;
6const ELEMENT_CONTEXT_LABEL_TAG_MAX = 24;
7
8const TRAILING_ELEMENT_CONTEXT_BLOCK_PATTERN =
9 /\n*<element_context>\n([\s\S]*?)\n<\/element_context>\s*$/;
10
11/**
12 * Stable, persistable element selection captured from the in-app preview
13 * browser. We deliberately keep the shape JSON-serializable so it can ride
14 * through `localStorage` persistence, draft restoration, and transcript
15 * snapshots without bespoke marshalling.
16 */
17export interface ElementContextSelection {
18 /** Page URL where the element was picked. */
19 pageUrl: string;
20 /** Best-effort `<title>`. */
21 pageTitle: string | null;
22 /** Lowercase tag, e.g. `"button"`. */
23 tagName: string;
24 /** CSS selector — may be null when react-grab can't compute one. */
25 selector: string | null;
26 /** Truncated outer-HTML preview. */
27 htmlPreview: string;
28 /** Nearest React component display name, or null. */
29 componentName: string | null;
30 /** Source frame (file + line) — null when unavailable. */
31 source: PickedElementStackFrame | null;
32 /** Author CSS (no UA defaults). May be empty. */
33 styles: string;
34}
35
36export interface ElementContextDraft extends ElementContextSelection {
37 /** Stable composer-side id used for keyed rendering + dedupe. */
38 id: string;
39 threadId: ThreadId;
40 /** ISO-8601 wall clock pick time. */
41 pickedAt: string;
42}
43
44export interface ParsedElementContextEntry {
45 header: string;
46 body: string;
47}
48
49export interface ExtractedElementContexts {
50 promptText: string;
51 contextCount: number;
52 contexts: ParsedElementContextEntry[];
53}
54
55function truncateString(value: string, limit: number): string {
56 if (value.length <= limit) return value;
57 return `${value.slice(0, Math.max(0, limit - 1))}…`;
58}
59
60function normalizeText(value: string): string {
61 return value.replace(/\r\n/g, "\n").replace(/^\n+|\n+$/g, "");
62}
63
64/**
65 * Sanitize a payload coming back from the desktop bridge before it lands in
66 * the composer draft. Trims/clamps every string field so we never persist a
67 * 5MB outerHTML blob and silently break `localStorage`.
68 */
69export function normalizeElementContextSelection(
70 raw: PickedElementPayload,
71): ElementContextSelection | null {
72 const pageUrl = raw.pageUrl.trim();
73 const tagName = raw.tagName.trim().toLowerCase();
74 if (pageUrl.length === 0 || tagName.length === 0) {
75 return null;
76 }
77 const stackFrame = raw.source ?? raw.stack[0] ?? null;
78 return {
79 pageUrl,
80 pageTitle: raw.pageTitle?.trim() ?? null,
81 tagName,
82 selector: raw.selector?.trim() || null,
83 htmlPreview: truncateString(normalizeText(raw.htmlPreview), ELEMENT_CONTEXT_HTML_PREVIEW_LIMIT),
84 componentName: raw.componentName?.trim() || null,
85 source: stackFrame
86 ? {
87 functionName: stackFrame.functionName?.trim() || null,
88 fileName: stackFrame.fileName?.trim() || null,
89 lineNumber: stackFrame.lineNumber ?? null,
90 columnNumber: stackFrame.columnNumber ?? null,
91 }
92 : null,
93 styles: truncateString(normalizeText(raw.styles), ELEMENT_CONTEXT_STYLES_LIMIT),
94 };
95}
96
97/**
98 * Stable dedupe key. Two picks of the same element on the same page produce
99 * the same key, so we don't end up with a runaway chip row from spam-clicks.
100 */
101export function elementContextDedupKey(context: ElementContextSelection): string {
102 return [context.pageUrl, context.selector ?? "", context.tagName, context.componentName ?? ""]
103 .join("|")
104 .toLowerCase();
105}
106
107function shortenTagLabel(tagName: string): string {
108 if (tagName.length <= ELEMENT_CONTEXT_LABEL_TAG_MAX) return tagName;
109 return `${tagName.slice(0, ELEMENT_CONTEXT_LABEL_TAG_MAX - 1)}…`;
110}
111
112/**
113 * Compact chip label — `<Button>` for component picks, `<button>` otherwise.
114 * Component name takes priority because it's higher-signal for the agent.
115 */
116export function formatElementContextLabel(context: ElementContextSelection): string {
117 if (context.componentName) return `<${context.componentName}>`;
118 return `<${shortenTagLabel(context.tagName)}>`;
119}
120
121function basenameFromPath(filePath: string): string {
122 const parts = filePath.split(/[\\/]/);
123 return parts[parts.length - 1] ?? filePath;
124}
125
126export function formatElementContextSourceLabel(context: ElementContextSelection): string | null {
127 const source = context.source;
128 if (!source?.fileName) return null;
129 const base = basenameFromPath(source.fileName);
130 if (source.lineNumber == null) return base;
131 return `${base}:${source.lineNumber}`;
132}
133
134function buildContextHeader(context: ElementContextSelection): string {
135 const label = formatElementContextLabel(context);
136 const source = formatElementContextSourceLabel(context);
137 return source ? `${label} (${source})` : label;
138}
139
140function indentLines(value: string): string[] {
141 return value.split("\n").map((line) => ` ${line}`);
142}
143
144function buildSingleContextLines(context: ElementContextSelection): string[] {
145 const lines: string[] = [];
146 lines.push(`- ${buildContextHeader(context)}:`);
147 if (context.pageUrl.length > 0) {
148 lines.push(` url: ${context.pageUrl}`);
149 }
150 if (context.selector) {
151 lines.push(` selector: ${context.selector}`);
152 }
153 if (context.source?.fileName) {
154 const { fileName, lineNumber, columnNumber } = context.source;
155 const location =
156 lineNumber != null
157 ? `${fileName}:${lineNumber}${columnNumber != null ? `:${columnNumber}` : ""}`
158 : fileName;
159 lines.push(` source: ${location}`);
160 }
161 const html = context.htmlPreview.trim();
162 if (html.length > 0) {
163 lines.push(" html:");
164 lines.push(...indentLines(html));
165 }
166 const styles = context.styles.trim();
167 if (styles.length > 0) {
168 lines.push(" styles:");
169 lines.push(...indentLines(styles));
170 }
171 return lines;
172}
173
174/**
175 * Serialize element-context drafts into the `<element_context>` block we
176 * append to the user's outgoing message text. Mirrors the `<terminal_context>`
177 * block format so it composes cleanly when both are present.
178 */
179export function buildElementContextBlock(contexts: ReadonlyArray<ElementContextSelection>): string {
180 if (contexts.length === 0) return "";
181 const lines: string[] = [];
182 for (let index = 0; index < contexts.length; index += 1) {
183 const context = contexts[index]!;
184 lines.push(...buildSingleContextLines(context));
185 if (index < contexts.length - 1) lines.push("");
186 }
187 return ["<element_context>", ...lines, "</element_context>"].join("\n");
188}
189
190export function appendElementContextsToPrompt(
191 prompt: string,
192 contexts: ReadonlyArray<ElementContextSelection>,
193): string {
194 const block = buildElementContextBlock(contexts);
195 if (block.length === 0) return prompt;
196 const trimmed = prompt.trim();
197 return trimmed.length > 0 ? `${trimmed}\n\n${block}` : block;
198}
199
200const ELEMENT_CONTEXT_ID_PREFIX = "el_";
201
202export function newElementContextId(): string {
203 if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
204 return `${ELEMENT_CONTEXT_ID_PREFIX}${crypto.randomUUID()}`;
205 }
206 return `${ELEMENT_CONTEXT_ID_PREFIX}${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`;
207}
208
209/**
210 * Mirror image of `appendElementContextsToPrompt` for transcript display:
211 * detects (and strips) a trailing `<element_context>` block so we can render
212 * the original prompt body and chips separately in user-message bubbles.
213 */
214export function extractTrailingElementContexts(prompt: string): ExtractedElementContexts {
215 const match = TRAILING_ELEMENT_CONTEXT_BLOCK_PATTERN.exec(prompt);
216 if (!match) {
217 return { promptText: prompt, contextCount: 0, contexts: [] };
218 }
219 const promptText = prompt.slice(0, match.index).replace(/\n+$/, "");
220 const contexts = parseElementContextEntries(match[1] ?? "");
221 return { promptText, contextCount: contexts.length, contexts };
222}
223
224function parseElementContextEntries(block: string): ParsedElementContextEntry[] {
225 const entries: ParsedElementContextEntry[] = [];
226 let current: { header: string; bodyLines: string[] } | null = null;
227 const commit = () => {
228 if (!current) return;
229 entries.push({ header: current.header, body: current.bodyLines.join("\n").trimEnd() });
230 current = null;
231 };
232 for (const line of block.split("\n")) {
233 const headerMatch = /^- (.+):$/.exec(line);
234 if (headerMatch) {
235 commit();
236 current = { header: headerMatch[1]!, bodyLines: [] };
237 continue;
238 }
239 if (!current) continue;
240 if (line.startsWith(" ")) current.bodyLines.push(line.slice(2));
241 else if (line.length === 0) current.bodyLines.push("");
242 }
243 commit();
244 return entries;
245}
apps/web/src/lib/terminalContext.test.ts+1
......@@ -122,6 +122,7 @@ describe("terminalContext", () => {
122122 body: "12 | git status\n13 | On branch main",
123123 },
124124 ],
125 elementContexts: [],
125126 });
126127 });
127128
apps/web/src/lib/terminalContext.ts+18-5
......@@ -1,5 +1,7 @@
11import { type ThreadId } from "@t3tools/contracts";
22
3import { extractTrailingElementContexts, type ParsedElementContextEntry } from "./elementContext";
4
35export interface TerminalContextSelection {
46 terminalId: string;
57 terminalLabel: string;
......@@ -27,6 +29,12 @@ export interface DisplayedUserMessageState {
2729 contextCount: number;
2830 previewTitle: string | null;
2931 contexts: ParsedTerminalContextEntry[];
32 /**
33 * Element-context entries extracted from the trailing `<element_context>`
34 * block (if any). Stripped from `visibleText` so the raw block doesn't
35 * leak into the user's bubble.
36 */
37 elementContexts: ParsedElementContextEntry[];
3038}
3139
3240export interface ParsedTerminalContextEntry {
......@@ -238,13 +246,18 @@ export function extractTrailingTerminalContexts(prompt: string): ExtractedTermin
238246}
239247
240248export function deriveDisplayedUserMessageState(prompt: string): DisplayedUserMessageState {
241 const extractedContexts = extractTrailingTerminalContexts(prompt);
249 // Order matters: send-time appends `<terminal_context>` first, then
250 // `<element_context>` last. Strip element first so the (now-trailing)
251 // terminal block can be matched by `extractTrailingTerminalContexts`.
252 const extractedElement = extractTrailingElementContexts(prompt);
253 const extractedTerminal = extractTrailingTerminalContexts(extractedElement.promptText);
242254 return {
243 visibleText: extractedContexts.promptText,
255 visibleText: extractedTerminal.promptText,
244256 copyText: prompt,
245 contextCount: extractedContexts.contextCount,
246 previewTitle: extractedContexts.previewTitle,
247 contexts: extractedContexts.contexts,
257 contextCount: extractedTerminal.contextCount,
258 previewTitle: extractedTerminal.previewTitle,
259 contexts: extractedTerminal.contexts,
260 elementContexts: extractedElement.contexts,
248261 };
249262}
250263
packages/contracts/src/ipc.ts+79-1
......@@ -441,6 +441,70 @@ export interface DesktopPreviewTabState {
441441 updatedAt: string;
442442}
443443
444/**
445 * Static config a renderer needs to mount a preview `<webview>`. Returned
446 * atomically by `DesktopPreviewBridge.getPreviewConfig()` so the renderer
447 * doesn't have to wait on three separate IPC round-trips before the webview
448 * can attach.
449 */
450export interface DesktopPreviewWebviewConfig {
451 /** `persist:t3code-preview` (or whatever the desktop chose). */
452 partition: string;
453 /**
454 * Canonical `<webview webpreferences="...">` string. Encodes the security
455 * posture (sandboxed but contextIsolation off so the picker preload can
456 * read the page's React DevTools hook). Always present.
457 */
458 webPreferences: string;
459 /**
460 * Absolute `file://`-style URL to the picker preload bundle. Set to null
461 * when the bundle isn't present (older builds, broken install) — the
462 * renderer must then disable element-pick affordances.
463 */
464 preloadUrl: string | null;
465}
466
467/**
468 * Single stack frame captured by react-grab's `getElementContext`. We surface
469 * the source file/line so coding agents can jump straight to the JSX that
470 * produced the picked DOM node.
471 */
472export interface PickedElementStackFrame {
473 functionName: string | null;
474 fileName: string | null;
475 lineNumber: number | null;
476 columnNumber: number | null;
477}
478
479/**
480 * A successful element pick from the preview webview. All fields are
481 * best-effort — pages that don't ship a React fiber tree (or aren't running
482 * in dev) will still produce a usable payload (selector + html preview),
483 * just without component / source attribution.
484 */
485export interface PickedElementPayload {
486 /** URL of the page the element was picked on. */
487 pageUrl: string;
488 /** Optional `<title>` of that page (best-effort). */
489 pageTitle: string | null;
490 /** Lowercase tag name, e.g. `"button"`. */
491 tagName: string;
492 /** CSS selector resolving back to the element on a re-render. */
493 selector: string | null;
494 /** Truncated outer-HTML preview (matches react-grab's `htmlPreview`). */
495 htmlPreview: string;
496 /** Nearest React component display name, or null when unavailable. */
497 componentName: string | null;
498 /** First source-mapped stack frame (file + line of the JSX source). */
499 source: PickedElementStackFrame | null;
500 /** Full owner-stack frames; can be empty. Useful for richer context. */
501 stack: ReadonlyArray<PickedElementStackFrame>;
502 /** Author CSS only (UA defaults stripped) — react-grab's `styles`. */
503 styles: string;
504 /** Wall-clock pick time as ISO-8601 string. */
505 pickedAt: string;
506}
507
444508export interface DesktopBridge {
445509 getAppBranding: () => DesktopAppBranding | null;
446510 getLocalEnvironmentBootstrap: () => DesktopEnvironmentBootstrap | null;
......@@ -525,7 +589,21 @@ export interface DesktopPreviewBridge {
525589 clearCookies: () => Promise<void>;
526590 /** Drop the HTTP cache for the preview partition (all tabs). */
527591 clearCache: () => Promise<void>;
528 getBrowserPartition: () => Promise<string>;
592 /**
593 * One-shot config for mounting a preview `<webview>`. Replaces three
594 * earlier round-trip calls (`getBrowserPartition`, `getWebviewPreferences`,
595 * `getPickPreloadPath`) so adding a new field here only requires touching
596 * the contract + main, not the renderer's mount logic.
597 */
598 getPreviewConfig: () => Promise<DesktopPreviewWebviewConfig>;
599 /**
600 * Activate the in-page element picker for the given tab. Resolves with
601 * the picked payload, or `null` when the user cancels (Escape / nav). The
602 * promise rejects if the picker can't be activated (no webview, etc.).
603 */
604 pickElement: (tabId: string) => Promise<PickedElementPayload | null>;
605 /** Cancel an in-flight `pickElement(tabId)` (renderer-side teardown). */
606 cancelPickElement: (tabId: string) => Promise<void>;
529607 onStateChange: (listener: (tabId: string, state: DesktopPreviewTabState) => void) => () => void;
530608}
531609