| author | |
| committer | |
| log | 17fb0e4a9a068f3ba6c55871dd44ed3c5d70aefe |
| tree | 4331a3518814b843c2be3e1b43ba2c024fa49cf9 |
| parent | 43e8bbd7aea2f73a0ea7b7a15af2903a471d5e4a |
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 @@ |
| 20 | 20 | "@t3tools/tailscale": "workspace:*", |
| 21 | 21 | "effect": "catalog:", |
| 22 | 22 | "electron": "41.5.0", |
| 23 | "electron-updater": "^6.6.2" | |
| 23 | "electron-updater": "^6.6.2", | |
| 24 | "react-grab": "^0.1.32" | |
| 24 | 25 | }, |
| 25 | 26 | "devDependencies": { |
| 26 | 27 | "@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"; |
| 53 | 53 | export const PREVIEW_OPEN_DEVTOOLS_CHANNEL = "desktop:preview-open-devtools"; |
| 54 | 54 | export const PREVIEW_CLEAR_COOKIES_CHANNEL = "desktop:preview-clear-cookies"; |
| 55 | 55 | export const PREVIEW_CLEAR_CACHE_CHANNEL = "desktop:preview-clear-cache"; |
| 56 | export const PREVIEW_GET_BROWSER_PARTITION_CHANNEL = "desktop:preview-get-browser-partition"; | |
| 56 | export const PREVIEW_GET_CONFIG_CHANNEL = "desktop:preview-get-config"; | |
| 57 | export const PREVIEW_PICK_ELEMENT_CHANNEL = "desktop:preview-pick-element"; | |
| 58 | export const PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL = "desktop:preview-cancel-pick-element"; | |
| 57 | 59 | export const PREVIEW_STATE_CHANGE_CHANNEL = "desktop:preview-state-change"; |
apps/desktop/src/ipc/methods/preview.ts+18-1| ... | ... | @@ -1,7 +1,11 @@ |
| 1 | 1 | import * as Effect from "effect/Effect"; |
| 2 | 2 | import { BrowserWindow } from "electron"; |
| 3 | import * as NodeFileSystem from "node:fs"; | |
| 4 | import * as NodePath from "node:path"; | |
| 5 | import { pathToFileURL } from "node:url"; | |
| 3 | 6 | |
| 4 | 7 | import { previewViewManager } from "../../preview-view-manager.ts"; |
| 8 | import { PREVIEW_WEBVIEW_PREFERENCES } from "../../preview-webview-preferences.ts"; | |
| 5 | 9 | import * as IpcChannels from "../channels.ts"; |
| 6 | 10 | import type { DesktopIpcMethod } from "../DesktopIpc.ts"; |
| 7 | 11 | |
| ... | ... | @@ -57,5 +61,18 @@ export const previewMethods = [ |
| 57 | 61 | method(IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, (raw) => previewViewManager.openDevTools(tabIdFrom(raw))), |
| 58 | 62 | method(IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL, () => previewViewManager.clearCookies()), |
| 59 | 63 | 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 | ), | |
| 61 | 78 | ] as const; |
apps/desktop/src/main.ts+2| ... | ... | @@ -28,6 +28,7 @@ import * as DesktopApp from "./app/DesktopApp.ts"; |
| 28 | 28 | import * as DesktopAppIdentity from "./app/DesktopAppIdentity.ts"; |
| 29 | 29 | import * as DesktopCloudAuth from "./app/DesktopCloudAuth.ts"; |
| 30 | 30 | import * as DesktopCloudAuthTokenStore from "./app/DesktopCloudAuthTokenStore.ts"; |
| 31 | import * as DesktopConnectionCatalogStore from "./app/DesktopConnectionCatalogStore.ts"; | |
| 31 | 32 | import * as DesktopApplicationMenu from "./window/DesktopApplicationMenu.ts"; |
| 32 | 33 | import * as DesktopAssets from "./app/DesktopAssets.ts"; |
| 33 | 34 | import * as DesktopBackendConfiguration from "./backend/DesktopBackendConfiguration.ts"; |
| ... | ... | @@ -114,6 +115,7 @@ const desktopFoundationLayer = Layer.mergeAll( |
| 114 | 115 | DesktopClientSettings.layer, |
| 115 | 116 | DesktopSavedEnvironments.layer, |
| 116 | 117 | DesktopCloudAuthTokenStore.layer, |
| 118 | DesktopConnectionCatalogStore.layer, | |
| 117 | 119 | DesktopAssets.layer, |
| 118 | 120 | DesktopObservability.layer, |
| 119 | 121 | ).pipe(Layer.provideMerge(desktopEnvironmentLayer)); |
apps/desktop/src/picked-element-payload.test.ts created+135| ... | ... | @@ -0,0 +1,135 @@ |
| 1 | import { describe, expect, it } from "vitest"; | |
| 2 | ||
| 3 | import { isPickedElementPayload } from "./picked-element-payload.ts"; | |
| 4 | ||
| 5 | function 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 | ||
| 33 | describe("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 | */ | |
| 13 | import type { PickedElementPayload } from "@t3tools/contracts"; | |
| 14 | ||
| 15 | function isStringOrNull(value: unknown): value is string | null { | |
| 16 | return value === null || typeof value === "string"; | |
| 17 | } | |
| 18 | ||
| 19 | function isFiniteNumberOrNull(value: unknown): value is number | null { | |
| 20 | return value === null || (typeof value === "number" && Number.isFinite(value)); | |
| 21 | } | |
| 22 | ||
| 23 | function 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 | ||
| 34 | export 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", { |
| 47 | 47 | ipcRenderer.invoke(IpcChannels.SET_SAVED_ENVIRONMENT_SECRET_CHANNEL, { environmentId, secret }), |
| 48 | 48 | removeSavedEnvironmentSecret: (environmentId) => |
| 49 | 49 | 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), | |
| 50 | 54 | discoverSshHosts: () => ipcRenderer.invoke(IpcChannels.DISCOVER_SSH_HOSTS_CHANNEL), |
| 51 | 55 | ensureSshEnvironment: async (target, options) => |
| 52 | 56 | unwrapEnsureSshEnvironmentResult( |
| ... | ... | @@ -159,8 +163,11 @@ contextBridge.exposeInMainWorld("desktopBridge", { |
| 159 | 163 | ipcRenderer.invoke(IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, { tabId }), |
| 160 | 164 | clearCookies: () => ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL), |
| 161 | 165 | 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 }), | |
| 164 | 171 | onStateChange: (listener) => { |
| 165 | 172 | const wrappedListener = ( |
| 166 | 173 | _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. */ | |
| 16 | export const LABEL_GAP = 4; | |
| 17 | /** Minimum padding the label keeps from any viewport edge. */ | |
| 18 | export const VIEWPORT_MARGIN = 4; | |
| 19 | ||
| 20 | export 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 @@ |
| 1 | import { describe, expect, it } from "vitest"; | |
| 2 | ||
| 3 | import { computeLabelPosition } from "./preview-pick-label-position.ts"; | |
| 4 | ||
| 5 | const VIEWPORT = { viewportWidth: 1280, viewportHeight: 800 }; | |
| 6 | ||
| 7 | describe("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 | */ | |
| 34 | import { ipcRenderer } from "electron"; | |
| 35 | import { getElementContext } from "react-grab/primitives"; | |
| 36 | import type { PickedElementPayload, PickedElementStackFrame } from "@t3tools/contracts"; | |
| 37 | ||
| 38 | import { computeLabelPosition } from "./preview-pick-label-position.ts"; | |
| 39 | ||
| 40 | const START_PICK_CHANNEL = "preview:start-pick"; | |
| 41 | const CANCEL_PICK_CHANNEL = "preview:cancel-pick"; | |
| 42 | const ELEMENT_PICKED_CHANNEL = "preview:element-picked"; | |
| 43 | ||
| 44 | const HIGHLIGHT_COLOR = "rgba(37, 99, 235, 0.9)"; // blue-600 | |
| 45 | const HIGHLIGHT_FILL = "rgba(37, 99, 235, 0.16)"; | |
| 46 | const LABEL_BG = "rgba(37, 99, 235, 0.96)"; | |
| 47 | const Z_INDEX_OVERLAY = 2147483646; | |
| 48 | ||
| 49 | interface 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 | ||
| 62 | let activeSession: PickSession | null = null; | |
| 63 | ||
| 64 | function 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 | ||
| 74 | interface OverlayHandles { | |
| 75 | readonly overlay: HTMLDivElement; | |
| 76 | readonly outline: HTMLDivElement; | |
| 77 | readonly label: HTMLDivElement; | |
| 78 | readonly destroyDom: () => void; | |
| 79 | } | |
| 80 | ||
| 81 | function 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 | */ | |
| 163 | function 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 | ||
| 177 | function 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 | */ | |
| 198 | const componentNameCache = new WeakMap<Element, string | null>(); | |
| 199 | const componentNameInFlight = new WeakSet<Element>(); | |
| 200 | ||
| 201 | function describeElement(element: Element): string { | |
| 202 | const cached = componentNameCache.get(element); | |
| 203 | if (cached) return `<${cached}> ${describeRawElement(element)}`; | |
| 204 | return describeRawElement(element); | |
| 205 | } | |
| 206 | ||
| 207 | function 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 | */ | |
| 247 | function 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 | ||
| 265 | function clearOutline(handles: OverlayHandles): void { | |
| 266 | handles.outline.style.display = "none"; | |
| 267 | handles.label.style.display = "none"; | |
| 268 | } | |
| 269 | ||
| 270 | function 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 | ||
| 284 | async 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 | ||
| 305 | function 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 | ||
| 417 | ipcRenderer.on(START_PICK_CHANNEL, () => { | |
| 418 | startPick(); | |
| 419 | }); |
apps/desktop/src/preview-view-manager.ts+124| ... | ... | @@ -5,10 +5,22 @@ |
| 5 | 5 | * elements live in the renderer; we only attach listeners and forward state |
| 6 | 6 | * here). Single layer-scoped browser session partition. |
| 7 | 7 | */ |
| 8 | import type { PickedElementPayload } from "@t3tools/contracts"; | |
| 8 | 9 | import { normalizePreviewUrl } from "@t3tools/shared/preview"; |
| 9 | 10 | import { type BrowserWindow, type Session, session, webContents } from "electron"; |
| 10 | 11 | |
| 12 | import { isPickedElementPayload } from "./picked-element-payload.ts"; | |
| 13 | ||
| 11 | 14 | const PREVIEW_PARTITION = "persist:t3code-preview"; |
| 15 | const START_PICK_CHANNEL = "preview:start-pick"; | |
| 16 | const CANCEL_PICK_CHANNEL = "preview:cancel-pick"; | |
| 17 | const 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. | |
| 22 | export { PREVIEW_WEBVIEW_PREFERENCES } from "./preview-webview-preferences.ts"; | |
| 23 | import { PREVIEW_WEBVIEW_PREFERENCES } from "./preview-webview-preferences.ts"; | |
| 12 | 24 | |
| 13 | 25 | export type PreviewNavStatus = |
| 14 | 26 | | { kind: "Idle" } |
| ... | ... | @@ -63,6 +75,11 @@ interface ManagedListeners { |
| 63 | 75 | failed: (event: Event, code: number, description: string) => void; |
| 64 | 76 | } |
| 65 | 77 | |
| 78 | interface PickSession { | |
| 79 | readonly resolve: (payload: PickedElementPayload | null) => void; | |
| 80 | readonly cleanup: () => void; | |
| 81 | } | |
| 82 | ||
| 66 | 83 | const APP_FORWARDED_SHORTCUTS: ReadonlyArray<{ |
| 67 | 84 | key: string; |
| 68 | 85 | meta: boolean; |
| ... | ... | @@ -85,6 +102,8 @@ export class PreviewViewManager { |
| 85 | 102 | private readonly attached = new Map<number, ManagedListeners>(); |
| 86 | 103 | private browserSession: Session | null = null; |
| 87 | 104 | 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>(); | |
| 88 | 107 | |
| 89 | 108 | setMainWindow(window: BrowserWindow): void { |
| 90 | 109 | this.mainWindow = window; |
| ... | ... | @@ -94,6 +113,16 @@ export class PreviewViewManager { |
| 94 | 113 | return PREVIEW_PARTITION; |
| 95 | 114 | } |
| 96 | 115 | |
| 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 | ||
| 97 | 126 | getBrowserSession(): Session { |
| 98 | 127 | if (this.browserSession) return this.browserSession; |
| 99 | 128 | const sess = session.fromPartition(PREVIEW_PARTITION); |
| ... | ... | @@ -130,6 +159,7 @@ export class PreviewViewManager { |
| 130 | 159 | closeTab(tabId: string): void { |
| 131 | 160 | const tab = this.tabs.get(tabId); |
| 132 | 161 | if (!tab) return; |
| 162 | this.cancelPickElement(tabId); | |
| 133 | 163 | if (tab.webContentsId != null) { |
| 134 | 164 | this.detachListeners(tab.webContentsId); |
| 135 | 165 | } |
| ... | ... | @@ -169,6 +199,10 @@ export class PreviewViewManager { |
| 169 | 199 | } |
| 170 | 200 | if (tab.webContentsId != null && tab.webContentsId !== webContentsId) { |
| 171 | 201 | 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); | |
| 172 | 206 | } |
| 173 | 207 | this.attachListeners(tabId, wc); |
| 174 | 208 | // Restore the persisted zoom factor onto the freshly-attached WebContents |
| ... | ... | @@ -254,6 +288,96 @@ export class PreviewViewManager { |
| 254 | 288 | await sess.clearCache(); |
| 255 | 289 | } |
| 256 | 290 | |
| 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 | ||
| 257 | 381 | zoomIn(tabId: string): void { |
| 258 | 382 | this.applyZoom(tabId, (current) => nextZoomLevel(current, "in")); |
| 259 | 383 | } |
apps/desktop/src/preview-webview-preferences.test.ts created+78| ... | ... | @@ -0,0 +1,78 @@ |
| 1 | import { describe, expect, it } from "vitest"; | |
| 2 | ||
| 3 | import { 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 | */ | |
| 28 | function 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 | ||
| 43 | describe("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 | */ | |
| 41 | export 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* () { |
| 198 | 198 | }); |
| 199 | 199 | |
| 200 | 200 | 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 | }); | |
| 201 | 211 | |
| 202 | 212 | window.webContents.on("context-menu", (event, params) => { |
| 203 | 213 | event.preventDefault(); |
apps/desktop/vite.config.ts+10| ... | ... | @@ -56,5 +56,15 @@ export default defineConfig({ |
| 56 | 56 | define: publicConfigDefine, |
| 57 | 57 | entry: ["src/preload.ts"], |
| 58 | 58 | }, |
| 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 | }, | |
| 59 | 69 | ], |
| 60 | 70 | }); |
apps/web/src/components/ChatView.logic.test.ts+24| ... | ... | @@ -73,6 +73,30 @@ describe("deriveComposerSendState", () => { |
| 73 | 73 | expect(state.expiredTerminalContextCount).toBe(1); |
| 74 | 74 | expect(state.hasSendableContent).toBe(true); |
| 75 | 75 | }); |
| 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 | }); | |
| 76 | 100 | }); |
| 77 | 101 | |
| 78 | 102 | describe("buildExpiredTerminalContextToastCopy", () => { |
apps/web/src/components/ChatView.logic.ts+11-1| ... | ... | @@ -185,6 +185,12 @@ export function deriveComposerSendState(options: { |
| 185 | 185 | prompt: string; |
| 186 | 186 | imageCount: number; |
| 187 | 187 | 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; | |
| 188 | 194 | }): { |
| 189 | 195 | trimmedPrompt: string; |
| 190 | 196 | sendableTerminalContexts: TerminalContextDraft[]; |
| ... | ... | @@ -195,12 +201,16 @@ export function deriveComposerSendState(options: { |
| 195 | 201 | const sendableTerminalContexts = filterTerminalContextsWithText(options.terminalContexts); |
| 196 | 202 | const expiredTerminalContextCount = |
| 197 | 203 | options.terminalContexts.length - sendableTerminalContexts.length; |
| 204 | const elementContextCount = options.elementContextCount ?? 0; | |
| 198 | 205 | return { |
| 199 | 206 | trimmedPrompt, |
| 200 | 207 | sendableTerminalContexts, |
| 201 | 208 | expiredTerminalContextCount, |
| 202 | 209 | 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, | |
| 204 | 214 | }; |
| 205 | 215 | } |
| 206 | 216 |
apps/web/src/components/ChatView.tsx+25-5| ... | ... | @@ -149,6 +149,11 @@ import { |
| 149 | 149 | } from "../lib/terminalContext"; |
| 150 | 150 | import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; |
| 151 | 151 | import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../terminalSessionState"; |
| 152 | import { | |
| 153 | appendElementContextsToPrompt, | |
| 154 | type ElementContextDraft, | |
| 155 | formatElementContextLabel, | |
| 156 | } from "../lib/elementContext"; | |
| 152 | 157 | import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; |
| 153 | 158 | import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; |
| 154 | 159 | import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; |
| ... | ... | @@ -873,6 +878,9 @@ export default function ChatView(props: ChatViewProps) { |
| 873 | 878 | const setComposerDraftTerminalContexts = useComposerDraftStore( |
| 874 | 879 | (store) => store.setTerminalContexts, |
| 875 | 880 | ); |
| 881 | const setComposerDraftElementContexts = useComposerDraftStore( | |
| 882 | (store) => store.setElementContexts, | |
| 883 | ); | |
| 876 | 884 | const setComposerDraftModelSelection = useComposerDraftStore((store) => store.setModelSelection); |
| 877 | 885 | const setComposerDraftRuntimeMode = useComposerDraftStore((store) => store.setRuntimeMode); |
| 878 | 886 | const setComposerDraftInteractionMode = useComposerDraftStore( |
| ... | ... | @@ -897,6 +905,7 @@ export default function ChatView(props: ChatViewProps) { |
| 897 | 905 | const promptRef = useRef(""); |
| 898 | 906 | const composerImagesRef = useRef<ComposerImageAttachment[]>([]); |
| 899 | 907 | const composerTerminalContextsRef = useRef<TerminalContextDraft[]>([]); |
| 908 | const composerElementContextsRef = useRef<ElementContextDraft[]>([]); | |
| 900 | 909 | const localComposerRef = useRef<ChatComposerHandle | null>(null); |
| 901 | 910 | const composerRef = useComposerHandleContext() ?? localComposerRef; |
| 902 | 911 | const [showScrollToBottom, setShowScrollToBottom] = useState(false); |
| ... | ... | @@ -2982,6 +2991,7 @@ export default function ChatView(props: ChatViewProps) { |
| 2982 | 2991 | const { |
| 2983 | 2992 | images: composerImages, |
| 2984 | 2993 | terminalContexts: composerTerminalContexts, |
| 2994 | elementContexts: composerElementContexts, | |
| 2985 | 2995 | selectedProvider: ctxSelectedProvider, |
| 2986 | 2996 | selectedModel: ctxSelectedModel, |
| 2987 | 2997 | selectedProviderModels: ctxSelectedProviderModels, |
| ... | ... | @@ -2998,6 +3008,7 @@ export default function ChatView(props: ChatViewProps) { |
| 2998 | 3008 | prompt: promptForSend, |
| 2999 | 3009 | imageCount: composerImages.length, |
| 3000 | 3010 | terminalContexts: composerTerminalContexts, |
| 3011 | elementContextCount: composerElementContexts.length, | |
| 3001 | 3012 | }); |
| 3002 | 3013 | if (showPlanFollowUpPrompt && activeProposedPlan) { |
| 3003 | 3014 | const followUp = resolvePlanFollowUpSubmission({ |
| ... | ... | @@ -3014,7 +3025,9 @@ export default function ChatView(props: ChatViewProps) { |
| 3014 | 3025 | return; |
| 3015 | 3026 | } |
| 3016 | 3027 | const standaloneSlashCommand = |
| 3017 | composerImages.length === 0 && sendableComposerTerminalContexts.length === 0 | |
| 3028 | composerImages.length === 0 && | |
| 3029 | sendableComposerTerminalContexts.length === 0 && | |
| 3030 | composerElementContexts.length === 0 | |
| 3018 | 3031 | ? parseStandaloneComposerSlashCommand(trimmed) |
| 3019 | 3032 | : null; |
| 3020 | 3033 | if (standaloneSlashCommand) { |
| ... | ... | @@ -3062,9 +3075,10 @@ export default function ChatView(props: ChatViewProps) { |
| 3062 | 3075 | |
| 3063 | 3076 | const composerImagesSnapshot = [...composerImages]; |
| 3064 | 3077 | 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, | |
| 3068 | 3082 | ); |
| 3069 | 3083 | const messageIdForSend = newMessageId(); |
| 3070 | 3084 | const messageCreatedAt = new Date().toISOString(); |
| ... | ... | @@ -3145,6 +3159,8 @@ export default function ChatView(props: ChatViewProps) { |
| 3145 | 3159 | titleSeed = `Image: ${firstComposerImageName}`; |
| 3146 | 3160 | } else if (composerTerminalContextsSnapshot.length > 0) { |
| 3147 | 3161 | titleSeed = formatTerminalContextLabel(composerTerminalContextsSnapshot[0]!); |
| 3162 | } else if (composerElementContextsSnapshot.length > 0) { | |
| 3163 | titleSeed = formatElementContextLabel(composerElementContextsSnapshot[0]!); | |
| 3148 | 3164 | } else { |
| 3149 | 3165 | titleSeed = "New thread"; |
| 3150 | 3166 | } |
| ... | ... | @@ -3230,7 +3246,8 @@ export default function ChatView(props: ChatViewProps) { |
| 3230 | 3246 | !turnStartSucceeded && |
| 3231 | 3247 | promptRef.current.length === 0 && |
| 3232 | 3248 | composerImagesRef.current.length === 0 && |
| 3233 | composerTerminalContextsRef.current.length === 0 | |
| 3249 | composerTerminalContextsRef.current.length === 0 && | |
| 3250 | composerElementContextsRef.current.length === 0 | |
| 3234 | 3251 | ) { |
| 3235 | 3252 | setOptimisticUserMessages((existing) => { |
| 3236 | 3253 | const removed = existing.filter((message) => message.id === messageIdForSend); |
| ... | ... | @@ -3244,9 +3261,11 @@ export default function ChatView(props: ChatViewProps) { |
| 3244 | 3261 | const retryComposerImages = composerImagesSnapshot.map(cloneComposerImageForRetry); |
| 3245 | 3262 | composerImagesRef.current = retryComposerImages; |
| 3246 | 3263 | composerTerminalContextsRef.current = composerTerminalContextsSnapshot; |
| 3264 | composerElementContextsRef.current = composerElementContextsSnapshot; | |
| 3247 | 3265 | setComposerDraftPrompt(composerDraftTarget, promptForSend); |
| 3248 | 3266 | addComposerDraftImages(composerDraftTarget, retryComposerImages); |
| 3249 | 3267 | setComposerDraftTerminalContexts(composerDraftTarget, composerTerminalContextsSnapshot); |
| 3268 | setComposerDraftElementContexts(composerDraftTarget, composerElementContextsSnapshot); | |
| 3250 | 3269 | composerRef.current?.resetCursorState({ |
| 3251 | 3270 | cursor: collapseExpandedComposerCursor(promptForSend, promptForSend.length), |
| 3252 | 3271 | prompt: promptForSend, |
| ... | ... | @@ -4045,6 +4064,7 @@ export default function ChatView(props: ChatViewProps) { |
| 4045 | 4064 | promptRef={promptRef} |
| 4046 | 4065 | composerImagesRef={composerImagesRef} |
| 4047 | 4066 | composerTerminalContextsRef={composerTerminalContextsRef} |
| 4067 | composerElementContextsRef={composerElementContextsRef} | |
| 4048 | 4068 | shouldAutoScrollRef={isAtEndRef} |
| 4049 | 4069 | scheduleStickToBottom={scrollToEnd} |
| 4050 | 4070 | onSend={onSend} |
apps/web/src/components/chat/ChatComposer.tsx+38-7| ... | ... | @@ -17,6 +17,10 @@ import { |
| 17 | 17 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, |
| 18 | 18 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, |
| 19 | 19 | } from "@t3tools/contracts"; |
| 20 | import { | |
| 21 | connectionStatusText, | |
| 22 | type EnvironmentConnectionPresentation, | |
| 23 | } from "@t3tools/client-runtime/connection"; | |
| 20 | 24 | import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; |
| 21 | 25 | import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; |
| 22 | 26 | import { |
| ... | ... | @@ -53,6 +57,8 @@ import { |
| 53 | 57 | removeInlineTerminalContextPlaceholder, |
| 54 | 58 | } from "../../lib/terminalContext"; |
| 55 | 59 | import { useComposerPathSearch } from "../../lib/composerPathSearchState"; |
| 60 | import { type ElementContextDraft } from "../../lib/elementContext"; | |
| 61 | import { ComposerPendingElementContexts } from "./ComposerPendingElementContexts"; | |
| 56 | 62 | import { |
| 57 | 63 | shouldUseCompactComposerPrimaryActions, |
| 58 | 64 | shouldUseCompactComposerFooter, |
| ... | ... | @@ -400,6 +406,7 @@ export interface ChatComposerHandle { |
| 400 | 406 | prompt: string; |
| 401 | 407 | images: ComposerImageAttachment[]; |
| 402 | 408 | terminalContexts: TerminalContextDraft[]; |
| 409 | elementContexts: ElementContextDraft[]; | |
| 403 | 410 | selectedPromptEffort: string | null; |
| 404 | 411 | selectedModelOptionsForDispatch: unknown; |
| 405 | 412 | selectedModelSelection: ModelSelection; |
| ... | ... | @@ -434,7 +441,7 @@ export interface ChatComposerProps { |
| 434 | 441 | isPreparingWorktree: boolean; |
| 435 | 442 | environmentUnavailable: { |
| 436 | 443 | readonly label: string; |
| 437 | readonly connectionState: "connecting" | "disconnected" | "error"; | |
| 444 | readonly connection: EnvironmentConnectionPresentation; | |
| 438 | 445 | } | null; |
| 439 | 446 | |
| 440 | 447 | // Pending approvals / inputs |
| ... | ... | @@ -486,6 +493,7 @@ export interface ChatComposerProps { |
| 486 | 493 | promptRef: React.RefObject<string>; |
| 487 | 494 | composerImagesRef: React.RefObject<ComposerImageAttachment[]>; |
| 488 | 495 | composerTerminalContextsRef: React.RefObject<TerminalContextDraft[]>; |
| 496 | composerElementContextsRef: React.RefObject<ElementContextDraft[]>; | |
| 489 | 497 | composerRef: React.RefObject<ChatComposerHandle | null>; |
| 490 | 498 | |
| 491 | 499 | // Scroll |
| ... | ... | @@ -576,6 +584,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 576 | 584 | composerRef, |
| 577 | 585 | composerImagesRef, |
| 578 | 586 | composerTerminalContextsRef, |
| 587 | composerElementContextsRef, | |
| 579 | 588 | shouldAutoScrollRef, |
| 580 | 589 | scheduleStickToBottom, |
| 581 | 590 | onSend, |
| ... | ... | @@ -605,6 +614,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 605 | 614 | const prompt = composerDraft.prompt; |
| 606 | 615 | const composerImages = composerDraft.images; |
| 607 | 616 | const composerTerminalContexts = composerDraft.terminalContexts; |
| 617 | const composerElementContexts = composerDraft.elementContexts; | |
| 608 | 618 | const nonPersistedComposerImageIds = composerDraft.nonPersistedImageIds; |
| 609 | 619 | |
| 610 | 620 | const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); |
| ... | ... | @@ -620,6 +630,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 620 | 630 | const setComposerDraftTerminalContexts = useComposerDraftStore( |
| 621 | 631 | (store) => store.setTerminalContexts, |
| 622 | 632 | ); |
| 633 | const removeComposerDraftElementContext = useComposerDraftStore( | |
| 634 | (store) => store.removeElementContext, | |
| 635 | ); | |
| 623 | 636 | const clearComposerDraftPersistedAttachments = useComposerDraftStore( |
| 624 | 637 | (store) => store.clearPersistedAttachments, |
| 625 | 638 | ); |
| ... | ... | @@ -880,8 +893,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 880 | 893 | prompt, |
| 881 | 894 | imageCount: composerImages.length, |
| 882 | 895 | terminalContexts: composerTerminalContexts, |
| 896 | elementContextCount: composerElementContexts.length, | |
| 883 | 897 | }), |
| 884 | [composerImages.length, composerTerminalContexts, prompt], | |
| 898 | [composerElementContexts.length, composerImages.length, composerTerminalContexts, prompt], | |
| 885 | 899 | ); |
| 886 | 900 | |
| 887 | 901 | // ------------------------------------------------------------------ |
| ... | ... | @@ -1170,6 +1184,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1170 | 1184 | composerTerminalContextsRef.current = composerTerminalContexts; |
| 1171 | 1185 | }, [composerTerminalContexts, composerTerminalContextsRef]); |
| 1172 | 1186 | |
| 1187 | useEffect(() => { | |
| 1188 | composerElementContextsRef.current = composerElementContexts; | |
| 1189 | }, [composerElementContexts, composerElementContextsRef]); | |
| 1190 | ||
| 1173 | 1191 | // ------------------------------------------------------------------ |
| 1174 | 1192 | // Composer menu highlight sync |
| 1175 | 1193 | // ------------------------------------------------------------------ |
| ... | ... | @@ -1969,6 +1987,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1969 | 1987 | prompt: promptRef.current, |
| 1970 | 1988 | images: composerImagesRef.current, |
| 1971 | 1989 | terminalContexts: composerTerminalContextsRef.current, |
| 1990 | elementContexts: composerElementContextsRef.current, | |
| 1972 | 1991 | selectedPromptEffort, |
| 1973 | 1992 | selectedModelOptionsForDispatch, |
| 1974 | 1993 | selectedModelSelection, |
| ... | ... | @@ -1986,6 +2005,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1986 | 2005 | promptRef, |
| 1987 | 2006 | composerImagesRef, |
| 1988 | 2007 | composerTerminalContextsRef, |
| 2008 | composerElementContextsRef, | |
| 1989 | 2009 | isConnecting, |
| 1990 | 2010 | isComposerApprovalState, |
| 1991 | 2011 | pendingUserInputs.length, |
| ... | ... | @@ -2223,6 +2243,19 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2223 | 2243 | </div> |
| 2224 | 2244 | )} |
| 2225 | 2245 | |
| 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 | ||
| 2226 | 2259 | {!isComposerCollapsedMobile && |
| 2227 | 2260 | !isComposerApprovalState && |
| 2228 | 2261 | pendingUserInputs.length === 0 && |
| ... | ... | @@ -2321,11 +2354,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2321 | 2354 | : showPlanFollowUpPrompt && activeProposedPlan |
| 2322 | 2355 | ? "Add feedback to refine the plan, or leave this blank to implement it" |
| 2323 | 2356 | : environmentUnavailable |
| 2324 | ? `${environmentUnavailable.label} is ${ | |
| 2325 | environmentUnavailable.connectionState === "connecting" | |
| 2326 | ? "connecting" | |
| 2327 | : "disconnected" | |
| 2328 | }` | |
| 2357 | ? `${environmentUnavailable.label}: ${connectionStatusText( | |
| 2358 | environmentUnavailable.connection, | |
| 2359 | )}` | |
| 2329 | 2360 | : phase === "disconnected" |
| 2330 | 2361 | ? "Ask for follow-up changes or attach images" |
| 2331 | 2362 | : "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 |
| 17 | 17 | |
| 18 | 18 | import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu"; |
| 19 | 19 | import { TraitsMenuContent } from "./TraitsPicker"; |
| 20 | import { useComposerDraftStore } from "../../composerDraftStore"; | |
| 20 | import { createEmptyThreadDraft, useComposerDraftStore } from "../../composerDraftStore"; | |
| 21 | 21 | |
| 22 | 22 | const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); |
| 23 | 23 | |
| ... | ... | @@ -60,18 +60,16 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str |
| 60 | 60 | |
| 61 | 61 | useComposerDraftStore.setState({ |
| 62 | 62 | 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 ...`. | |
| 63 | 66 | [threadKey]: { |
| 67 | ...createEmptyThreadDraft(), | |
| 64 | 68 | prompt: props?.prompt ?? "", |
| 65 | images: [], | |
| 66 | nonPersistedImageIds: [], | |
| 67 | persistedAttachments: [], | |
| 68 | terminalContexts: [], | |
| 69 | 69 | modelSelectionByProvider: { |
| 70 | 70 | [instanceId]: createModelSelection(instanceId, model, props?.modelSelection?.options), |
| 71 | 71 | }, |
| 72 | 72 | activeProvider: instanceId, |
| 73 | runtimeMode: null, | |
| 74 | interactionMode: null, | |
| 75 | 73 | }, |
| 76 | 74 | }, |
| 77 | 75 | draftThreadsByThreadKey: {}, |
apps/web/src/components/chat/ComposerPendingElementContexts.tsx created+95| ... | ... | @@ -0,0 +1,95 @@ |
| 1 | import { MousePointerClick, X } from "lucide-react"; | |
| 2 | ||
| 3 | import { | |
| 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"; | |
| 9 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | |
| 10 | import { cn } from "~/lib/utils"; | |
| 11 | import { | |
| 12 | type ElementContextDraft, | |
| 13 | formatElementContextLabel, | |
| 14 | formatElementContextSourceLabel, | |
| 15 | } from "~/lib/elementContext"; | |
| 16 | ||
| 17 | interface ComposerPendingElementContextsProps { | |
| 18 | contexts: ReadonlyArray<ElementContextDraft>; | |
| 19 | onRemove: (contextId: string) => void; | |
| 20 | className?: string; | |
| 21 | } | |
| 22 | ||
| 23 | interface ComposerPendingElementContextChipProps { | |
| 24 | context: ElementContextDraft; | |
| 25 | onRemove: (contextId: string) => void; | |
| 26 | } | |
| 27 | ||
| 28 | function 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 | ||
| 42 | export 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 | ||
| 82 | export 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 { |
| 46 | 46 | GlobeIcon, |
| 47 | 47 | HammerIcon, |
| 48 | 48 | MessageCircleIcon, |
| 49 | MousePointerClickIcon, | |
| 49 | 50 | MinusIcon, |
| 50 | 51 | SquarePenIcon, |
| 51 | 52 | TerminalIcon, |
| ... | ... | @@ -76,6 +77,10 @@ import { |
| 76 | 77 | deriveDisplayedUserMessageState, |
| 77 | 78 | type ParsedTerminalContextEntry, |
| 78 | 79 | } from "~/lib/terminalContext"; |
| 80 | import { | |
| 81 | extractTrailingElementContexts, | |
| 82 | type ParsedElementContextEntry, | |
| 83 | } from "~/lib/elementContext"; | |
| 79 | 84 | import { cn } from "~/lib/utils"; |
| 80 | 85 | import { useUiStateStore } from "~/uiStateStore"; |
| 81 | 86 | import { type TimestampFormat } from "@t3tools/contracts/settings"; |
| ... | ... | @@ -424,6 +429,7 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" |
| 424 | 429 | const userImages = row.message.attachments ?? []; |
| 425 | 430 | const displayedUserMessage = deriveDisplayedUserMessageState(row.message.text); |
| 426 | 431 | const terminalContexts = displayedUserMessage.contexts; |
| 432 | const elementContextState = extractTrailingElementContexts(displayedUserMessage.visibleText); | |
| 427 | 433 | const canRevertAgentWork = typeof row.revertTurnCount === "number"; |
| 428 | 434 | |
| 429 | 435 | return ( |
| ... | ... | @@ -462,8 +468,18 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" |
| 462 | 468 | ))} |
| 463 | 469 | </div> |
| 464 | 470 | )} |
| 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} | |
| 465 | 481 | <CollapsibleUserMessageBody |
| 466 | text={displayedUserMessage.visibleText} | |
| 482 | text={elementContextState.promptText} | |
| 467 | 483 | terminalContexts={terminalContexts} |
| 468 | 484 | skills={ctx.skills} |
| 469 | 485 | markdownCwd={ctx.markdownCwd} |
| ... | ... | @@ -563,16 +579,10 @@ function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "mess |
| 563 | 579 | <TooltipTrigger |
| 564 | 580 | render={<p className="text-muted-foreground text-xs tabular-nums" />} |
| 565 | 581 | > |
| 566 | {formatShortTimestamp( | |
| 567 | row.message.completedAt ?? row.message.createdAt, | |
| 568 | ctx.timestampFormat, | |
| 569 | )} | |
| 582 | {formatShortTimestamp(row.message.updatedAt, ctx.timestampFormat)} | |
| 570 | 583 | </TooltipTrigger> |
| 571 | 584 | <TooltipPopup> |
| 572 | {formatChatTimestampTooltip( | |
| 573 | row.message.completedAt ?? row.message.createdAt, | |
| 574 | ctx.timestampFormat, | |
| 575 | )} | |
| 585 | {formatChatTimestampTooltip(row.message.updatedAt, ctx.timestampFormat)} | |
| 576 | 586 | </TooltipPopup> |
| 577 | 587 | </Tooltip> |
| 578 | 588 | )} |
| ... | ... | @@ -899,6 +909,29 @@ const UserMessageTerminalContextInlineLabel = memo( |
| 899 | 909 | }, |
| 900 | 910 | ); |
| 901 | 911 | |
| 912 | const 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 | ||
| 902 | 935 | const MAX_COLLAPSED_USER_MESSAGE_LINES = 8; |
| 903 | 936 | const MAX_COLLAPSED_USER_MESSAGE_LENGTH = 600; |
| 904 | 937 | const COLLAPSED_USER_MESSAGE_FADE_HEIGHT_REM = 1.75; |
apps/web/src/components/preview/PreviewChromeRow.tsx+48-1| ... | ... | @@ -1,4 +1,11 @@ |
| 1 | import { ArrowLeft, ArrowRight, ExternalLink, Globe, RotateCw } from "lucide-react"; | |
| 1 | import { | |
| 2 | ArrowLeft, | |
| 3 | ArrowRight, | |
| 4 | ExternalLink, | |
| 5 | Globe, | |
| 6 | MousePointerClick, | |
| 7 | RotateCw, | |
| 8 | } from "lucide-react"; | |
| 2 | 9 | import { |
| 3 | 10 | type FormEvent, |
| 4 | 11 | type KeyboardEvent, |
| ... | ... | @@ -29,6 +36,16 @@ interface Props { |
| 29 | 36 | onSubmit: (url: string) => void; |
| 30 | 37 | /** When provided, renders an "Open in browser" affordance to the right. */ |
| 31 | 38 | 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; | |
| 32 | 49 | /** |
| 33 | 50 | * Trailing slot rendered after the URL input. Used by the preview view |
| 34 | 51 | * to mount the three-dot menu (hard reload, devtools, zoom, clear data). |
| ... | ... | @@ -52,6 +69,10 @@ export function PreviewChromeRow({ |
| 52 | 69 | onRefresh, |
| 53 | 70 | onSubmit, |
| 54 | 71 | onOpenInBrowser, |
| 72 | onPickElement, | |
| 73 | pickActive, | |
| 74 | pickDisabled, | |
| 75 | pickDisabledReason, | |
| 55 | 76 | trailingActions, |
| 56 | 77 | }: Props) { |
| 57 | 78 | const inputRef = useRef<HTMLInputElement | null>(null); |
| ... | ... | @@ -162,6 +183,32 @@ export function PreviewChromeRow({ |
| 162 | 183 | /> |
| 163 | 184 | </InputGroup> |
| 164 | 185 | |
| 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} | |
| 165 | 212 | {onOpenInBrowser ? ( |
| 166 | 213 | <Tooltip> |
| 167 | 214 | <TooltipTrigger |
apps/web/src/components/preview/PreviewView.tsx+85-2| ... | ... | @@ -2,9 +2,11 @@ |
| 2 | 2 | |
| 3 | 3 | import { scopedThreadKey } from "@t3tools/client-runtime"; |
| 4 | 4 | import { type ScopedThreadRef } from "@t3tools/contracts"; |
| 5 | import { useCallback, useEffect, useState } from "react"; | |
| 5 | import { useCallback, useEffect, useRef, useState } from "react"; | |
| 6 | 6 | |
| 7 | import { useComposerDraftStore } from "~/composerDraftStore"; | |
| 7 | 8 | import { ensureEnvironmentApi } from "~/environmentApi"; |
| 9 | import { normalizeElementContextSelection } from "~/lib/elementContext"; | |
| 8 | 10 | import { ensureLocalApi } from "~/localApi"; |
| 9 | 11 | import { selectThreadPreviewState, usePreviewStateStore } from "~/previewStateStore"; |
| 10 | 12 | |
| ... | ... | @@ -33,13 +35,24 @@ const localApi = typeof window === "undefined" ? null : ensureLocalApi(); |
| 33 | 35 | */ |
| 34 | 36 | export function PreviewView({ threadRef, configuredUrls, visible }: Props) { |
| 35 | 37 | const [focusUrlNonce, setFocusUrlNonce] = useState(0); |
| 38 | const [pickActive, setPickActive] = useState(false); | |
| 39 | const pickActiveRef = useRef(false); | |
| 40 | const isMountedRef = useRef(true); | |
| 36 | 41 | const previewState = usePreviewStateStore((state) => |
| 37 | 42 | selectThreadPreviewState(state.byThreadKey, threadRef), |
| 38 | 43 | ); |
| 39 | 44 | const rememberUrl = usePreviewStateStore((state) => state.rememberUrl); |
| 45 | const addElementContext = useComposerDraftStore((store) => store.addElementContext); | |
| 40 | 46 | |
| 41 | 47 | usePreviewSession(threadRef); |
| 42 | 48 | |
| 49 | useEffect(() => { | |
| 50 | isMountedRef.current = true; | |
| 51 | return () => { | |
| 52 | isMountedRef.current = false; | |
| 53 | }; | |
| 54 | }, []); | |
| 55 | ||
| 43 | 56 | const { snapshot, desktopOverlay } = previewState; |
| 44 | 57 | const tabId = snapshot?.tabId ?? null; |
| 45 | 58 | const navStatus = snapshot?.navStatus ?? { _tag: "Idle" as const }; |
| ... | ... | @@ -101,6 +114,67 @@ export function PreviewView({ threadRef, configuredUrls, visible }: Props) { |
| 101 | 114 | void localApi.shell.openExternal(url).catch(() => undefined); |
| 102 | 115 | }, [url]); |
| 103 | 116 | |
| 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 | ||
| 104 | 178 | // Subscribe only while visible; `toggle-panel` is owned by ChatView's |
| 105 | 179 | // URL-aware handler regardless of whether the panel is currently mounted. |
| 106 | 180 | useEffect(() => { |
| ... | ... | @@ -146,6 +220,15 @@ export function PreviewView({ threadRef, configuredUrls, visible }: Props) { |
| 146 | 220 | onRefresh={handleRefresh} |
| 147 | 221 | onSubmit={(next) => void handleSubmitUrl(next)} |
| 148 | 222 | 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 | } | |
| 149 | 232 | trailingActions={ |
| 150 | 233 | previewBridge ? ( |
| 151 | 234 | <PreviewMoreMenu |
| ... | ... | @@ -177,7 +260,7 @@ export function PreviewView({ threadRef, configuredUrls, visible }: Props) { |
| 177 | 260 | {snapshot && desktopOverlay ? ( |
| 178 | 261 | <ZoomIndicator zoomFactor={desktopOverlay.zoomFactor} /> |
| 179 | 262 | ) : null} |
| 180 | {isUnreachable && navStatus._tag === "LoadFailed" ? ( | |
| 263 | {navStatus._tag === "LoadFailed" ? ( | |
| 181 | 264 | <div className="absolute inset-0 z-10 bg-background"> |
| 182 | 265 | <PreviewUnreachable |
| 183 | 266 | url={navStatus.url} |
apps/web/src/components/preview/PreviewWebview.tsx+36-10| ... | ... | @@ -1,7 +1,7 @@ |
| 1 | 1 | "use client"; |
| 2 | 2 | |
| 3 | import type { ScopedThreadRef } from "@t3tools/contracts"; | |
| 4 | import { useEffect, useRef, useState } from "react"; | |
| 3 | import type { DesktopPreviewWebviewConfig, ScopedThreadRef } from "@t3tools/contracts"; | |
| 4 | import { useCallback, useEffect, useRef, useState } from "react"; | |
| 5 | 5 | |
| 6 | 6 | import { isElectron } from "~/env"; |
| 7 | 7 | |
| ... | ... | @@ -25,7 +25,13 @@ interface Props { |
| 25 | 25 | interface ElectronWebview extends HTMLElement { |
| 26 | 26 | src: string; |
| 27 | 27 | 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; | |
| 29 | 35 | reload: () => void; |
| 30 | 36 | getWebContentsId: () => number; |
| 31 | 37 | } |
| ... | ... | @@ -44,7 +50,7 @@ declare global { |
| 44 | 50 | * after mount throws. |
| 45 | 51 | */ |
| 46 | 52 | export function PreviewWebview({ threadRef, tabId, initialUrl, className }: Props) { |
| 47 | const [config, setConfig] = useState<{ partition: string } | null>(null); | |
| 53 | const [config, setConfig] = useState<DesktopPreviewWebviewConfig | null>(null); | |
| 48 | 54 | const webviewRef = useRef<ElectronWebview | null>(null); |
| 49 | 55 | // Capture once at mount; never re-derived from `initialUrl` props later. |
| 50 | 56 | const initialSrcRef = useRef<string>(initialUrl ?? "about:blank"); |
| ... | ... | @@ -57,11 +63,11 @@ export function PreviewWebview({ threadRef, tabId, initialUrl, className }: Prop |
| 57 | 63 | useEffect(() => { |
| 58 | 64 | if (!bridge) return; |
| 59 | 65 | let cancelled = false; |
| 60 | void bridge | |
| 61 | .getBrowserPartition() | |
| 62 | .then((partition) => { | |
| 66 | bridge | |
| 67 | .getPreviewConfig() | |
| 68 | .then((next) => { | |
| 63 | 69 | if (cancelled) return; |
| 64 | setConfig({ partition }); | |
| 70 | setConfig(next); | |
| 65 | 71 | }) |
| 66 | 72 | .catch(() => undefined); |
| 67 | 73 | return () => { |
| ... | ... | @@ -69,6 +75,21 @@ export function PreviewWebview({ threadRef, tabId, initialUrl, className }: Prop |
| 69 | 75 | }; |
| 70 | 76 | }, [bridge]); |
| 71 | 77 | |
| 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 | ||
| 72 | 93 | useEffect(() => { |
| 73 | 94 | if (!bridge || !config) return; |
| 74 | 95 | const webview = webviewRef.current; |
| ... | ... | @@ -103,12 +124,17 @@ export function PreviewWebview({ threadRef, tabId, initialUrl, className }: Prop |
| 103 | 124 | |
| 104 | 125 | if (!isElectron || !bridge || !config) return null; |
| 105 | 126 | |
| 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). | |
| 106 | 131 | return ( |
| 107 | 132 | <webview |
| 108 | ref={webviewRef} | |
| 133 | ref={setWebviewRef} | |
| 109 | 134 | src={initialSrcRef.current} |
| 110 | 135 | partition={config.partition} |
| 111 | allowpopups | |
| 136 | webpreferences={config.webPreferences} | |
| 137 | {...(config.preloadUrl ? { preload: config.preloadUrl } : {})} | |
| 112 | 138 | data-preview-tab={tabId} |
| 113 | 139 | className={className} |
| 114 | 140 | /> |
apps/web/src/composerDraftStore.test.ts+93| ... | ... | @@ -522,6 +522,99 @@ describe("composerDraftStore terminal contexts", () => { |
| 522 | 522 | }); |
| 523 | 523 | }); |
| 524 | 524 | |
| 525 | describe("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 | ||
| 525 | 618 | describe("composerDraftStore project draft thread mapping", () => { |
| 526 | 619 | const projectId = ProjectId.make("project-a"); |
| 527 | 620 | const otherProjectId = ProjectId.make("project-b"); |
apps/web/src/composerDraftStore.ts+244-1| ... | ... | @@ -36,6 +36,12 @@ import { |
| 36 | 36 | ensureInlineTerminalContextPlaceholders, |
| 37 | 37 | normalizeTerminalContextText, |
| 38 | 38 | } from "./lib/terminalContext"; |
| 39 | import { | |
| 40 | type ElementContextDraft, | |
| 41 | type ElementContextSelection, | |
| 42 | elementContextDedupKey, | |
| 43 | newElementContextId, | |
| 44 | } from "./lib/elementContext"; | |
| 39 | 45 | import { create } from "zustand"; |
| 40 | 46 | import { createJSONStorage, persist } from "zustand/middleware"; |
| 41 | 47 | import { useShallow } from "zustand/react/shallow"; |
| ... | ... | @@ -92,10 +98,33 @@ const PersistedTerminalContextDraft = Schema.Struct({ |
| 92 | 98 | }); |
| 93 | 99 | type PersistedTerminalContextDraft = typeof PersistedTerminalContextDraft.Type; |
| 94 | 100 | |
| 101 | const 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 | ||
| 108 | const 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 | }); | |
| 121 | type PersistedElementContextDraft = typeof PersistedElementContextDraft.Type; | |
| 122 | ||
| 95 | 123 | const PersistedComposerThreadDraftState = Schema.Struct({ |
| 96 | 124 | prompt: Schema.String, |
| 97 | 125 | attachments: Schema.Array(PersistedComposerImageAttachment), |
| 98 | 126 | terminalContexts: Schema.optionalKey(Schema.Array(PersistedTerminalContextDraft)), |
| 127 | elementContexts: Schema.optionalKey(Schema.Array(PersistedElementContextDraft)), | |
| 99 | 128 | // Keyed by `ProviderInstanceId` (open branded slug) so custom provider |
| 100 | 129 | // instances (e.g. `codex_personal`) round-trip alongside the built-in |
| 101 | 130 | // `codex` / `claudeAgent` / ... entries. Every prior `ProviderDriverKind` |
| ... | ... | @@ -216,6 +245,13 @@ export interface ComposerThreadDraftState { |
| 216 | 245 | nonPersistedImageIds: string[]; |
| 217 | 246 | persistedAttachments: PersistedComposerImageAttachment[]; |
| 218 | 247 | 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[]; | |
| 219 | 255 | /** |
| 220 | 256 | * Per-instance model selection. Keyed by `ProviderInstanceId` (open |
| 221 | 257 | * branded slug) so a default `codex` instance and a user-authored |
| ... | ... | @@ -397,6 +433,24 @@ interface ComposerDraftStoreState { |
| 397 | 433 | addTerminalContexts: (threadRef: ComposerThreadTarget, contexts: TerminalContextDraft[]) => void; |
| 398 | 434 | removeTerminalContext: (threadRef: ComposerThreadTarget, contextId: string) => void; |
| 399 | 435 | 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; | |
| 400 | 454 | clearPersistedAttachments: (threadRef: ComposerThreadTarget) => void; |
| 401 | 455 | syncPersistedAttachments: ( |
| 402 | 456 | threadRef: ComposerThreadTarget, |
| ... | ... | @@ -472,9 +526,11 @@ const EMPTY_IMAGES: ComposerImageAttachment[] = []; |
| 472 | 526 | const EMPTY_IDS: string[] = []; |
| 473 | 527 | const EMPTY_PERSISTED_ATTACHMENTS: PersistedComposerImageAttachment[] = []; |
| 474 | 528 | const EMPTY_TERMINAL_CONTEXTS: TerminalContextDraft[] = []; |
| 529 | const EMPTY_ELEMENT_CONTEXTS: ElementContextDraft[] = []; | |
| 475 | 530 | Object.freeze(EMPTY_IMAGES); |
| 476 | 531 | Object.freeze(EMPTY_IDS); |
| 477 | 532 | Object.freeze(EMPTY_PERSISTED_ATTACHMENTS); |
| 533 | Object.freeze(EMPTY_ELEMENT_CONTEXTS); | |
| 478 | 534 | const EMPTY_MODEL_SELECTION_BY_PROVIDER: Partial<Record<ProviderDriverKind, ModelSelection>> = |
| 479 | 535 | Object.freeze({}); |
| 480 | 536 | const EMPTY_COMPOSER_DRAFT_MODEL_STATE = Object.freeze<ComposerDraftModelState>({ |
| ... | ... | @@ -488,19 +544,27 @@ const EMPTY_THREAD_DRAFT = Object.freeze<ComposerThreadDraftState>({ |
| 488 | 544 | nonPersistedImageIds: EMPTY_IDS, |
| 489 | 545 | persistedAttachments: EMPTY_PERSISTED_ATTACHMENTS, |
| 490 | 546 | terminalContexts: EMPTY_TERMINAL_CONTEXTS, |
| 547 | elementContexts: EMPTY_ELEMENT_CONTEXTS, | |
| 491 | 548 | modelSelectionByProvider: EMPTY_MODEL_SELECTION_BY_PROVIDER, |
| 492 | 549 | activeProvider: null, |
| 493 | 550 | runtimeMode: null, |
| 494 | 551 | interactionMode: null, |
| 495 | 552 | }); |
| 496 | 553 | |
| 497 | function 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 | */ | |
| 560 | export function createEmptyThreadDraft(): ComposerThreadDraftState { | |
| 498 | 561 | return { |
| 499 | 562 | prompt: "", |
| 500 | 563 | images: [], |
| 501 | 564 | nonPersistedImageIds: [], |
| 502 | 565 | persistedAttachments: [], |
| 503 | 566 | terminalContexts: [], |
| 567 | elementContexts: [], | |
| 504 | 568 | modelSelectionByProvider: {}, |
| 505 | 569 | activeProvider: null, |
| 506 | 570 | runtimeMode: null, |
| ... | ... | @@ -571,6 +635,7 @@ function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean { |
| 571 | 635 | draft.images.length === 0 && |
| 572 | 636 | draft.persistedAttachments.length === 0 && |
| 573 | 637 | draft.terminalContexts.length === 0 && |
| 638 | draft.elementContexts.length === 0 && | |
| 574 | 639 | Object.keys(draft.modelSelectionByProvider).length === 0 && |
| 575 | 640 | draft.activeProvider === null && |
| 576 | 641 | draft.runtimeMode === null && |
| ... | ... | @@ -964,6 +1029,63 @@ function normalizePersistedAttachment(value: unknown): PersistedComposerImageAtt |
| 964 | 1029 | }; |
| 965 | 1030 | } |
| 966 | 1031 | |
| 1032 | function 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 | ||
| 967 | 1089 | function normalizePersistedTerminalContextDraft( |
| 968 | 1090 | value: unknown, |
| 969 | 1091 | ): PersistedTerminalContextDraft | null { |
| ... | ... | @@ -1478,6 +1600,12 @@ function normalizePersistedDraftsByThreadId( |
| 1478 | 1600 | return normalized ? [normalized] : []; |
| 1479 | 1601 | }) |
| 1480 | 1602 | : []; |
| 1603 | const elementContexts = Array.isArray(draftCandidate.elementContexts) | |
| 1604 | ? draftCandidate.elementContexts.flatMap((entry) => { | |
| 1605 | const normalized = normalizePersistedElementContextDraft(entry); | |
| 1606 | return normalized ? [normalized] : []; | |
| 1607 | }) | |
| 1608 | : []; | |
| 1481 | 1609 | const runtimeMode = isRuntimeMode(draftCandidate.runtimeMode) |
| 1482 | 1610 | ? draftCandidate.runtimeMode |
| 1483 | 1611 | : null; |
| ... | ... | @@ -1541,6 +1669,7 @@ function normalizePersistedDraftsByThreadId( |
| 1541 | 1669 | promptCandidate.length === 0 && |
| 1542 | 1670 | attachments.length === 0 && |
| 1543 | 1671 | terminalContexts.length === 0 && |
| 1672 | elementContexts.length === 0 && | |
| 1544 | 1673 | !hasModelData && |
| 1545 | 1674 | !runtimeMode && |
| 1546 | 1675 | !interactionMode |
| ... | ... | @@ -1563,6 +1692,7 @@ function normalizePersistedDraftsByThreadId( |
| 1563 | 1692 | prompt, |
| 1564 | 1693 | attachments, |
| 1565 | 1694 | ...(terminalContexts.length > 0 ? { terminalContexts } : {}), |
| 1695 | ...(elementContexts.length > 0 ? { elementContexts } : {}), | |
| 1566 | 1696 | ...(hasModelData |
| 1567 | 1697 | ? { |
| 1568 | 1698 | modelSelectionByProvider: compactModelSelectionByProvider(modelSelectionByProvider), |
| ... | ... | @@ -1645,6 +1775,7 @@ function partializeComposerDraftStoreState( |
| 1645 | 1775 | draft.prompt.length === 0 && |
| 1646 | 1776 | draft.persistedAttachments.length === 0 && |
| 1647 | 1777 | draft.terminalContexts.length === 0 && |
| 1778 | draft.elementContexts.length === 0 && | |
| 1648 | 1779 | !hasModelData && |
| 1649 | 1780 | draft.runtimeMode === null && |
| 1650 | 1781 | draft.interactionMode === null |
| ... | ... | @@ -1667,6 +1798,23 @@ function partializeComposerDraftStoreState( |
| 1667 | 1798 | })), |
| 1668 | 1799 | } |
| 1669 | 1800 | : {}), |
| 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 | : {}), | |
| 1670 | 1818 | ...(hasModelData |
| 1671 | 1819 | ? { |
| 1672 | 1820 | modelSelectionByProvider: compactModelSelectionByProvider( |
| ... | ... | @@ -1903,6 +2051,10 @@ function toHydratedThreadDraft( |
| 1903 | 2051 | ...context, |
| 1904 | 2052 | text: "", |
| 1905 | 2053 | })) ?? [], |
| 2054 | elementContexts: | |
| 2055 | persistedDraft.elementContexts?.map((context) => ({ | |
| 2056 | ...context, | |
| 2057 | })) ?? [], | |
| 1906 | 2058 | modelSelectionByProvider, |
| 1907 | 2059 | activeProvider, |
| 1908 | 2060 | runtimeMode: persistedDraft.runtimeMode ?? null, |
| ... | ... | @@ -2815,6 +2967,96 @@ const composerDraftStore = create<ComposerDraftStoreState>()( |
| 2815 | 2967 | return { draftsByThreadKey: nextDraftsByThreadKey }; |
| 2816 | 2968 | }); |
| 2817 | 2969 | }, |
| 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 | }, | |
| 2818 | 3060 | clearPersistedAttachments: (threadRef) => { |
| 2819 | 3061 | const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; |
| 2820 | 3062 | if (threadKey.length === 0) { |
| ... | ... | @@ -2887,6 +3129,7 @@ const composerDraftStore = create<ComposerDraftStoreState>()( |
| 2887 | 3129 | nonPersistedImageIds: [], |
| 2888 | 3130 | persistedAttachments: [], |
| 2889 | 3131 | terminalContexts: [], |
| 3132 | elementContexts: [], | |
| 2890 | 3133 | }; |
| 2891 | 3134 | const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; |
| 2892 | 3135 | if (shouldRemoveDraft(nextDraft)) { |
apps/web/src/lib/elementContext.test.ts created+294| ... | ... | @@ -0,0 +1,294 @@ |
| 1 | import type { PickedElementPayload } from "@t3tools/contracts"; | |
| 2 | import { describe, expect, it } from "vitest"; | |
| 3 | ||
| 4 | import { | |
| 5 | appendElementContextsToPrompt, | |
| 6 | buildElementContextBlock, | |
| 7 | type ElementContextSelection, | |
| 8 | elementContextDedupKey, | |
| 9 | extractTrailingElementContexts, | |
| 10 | formatElementContextLabel, | |
| 11 | formatElementContextSourceLabel, | |
| 12 | newElementContextId, | |
| 13 | normalizeElementContextSelection, | |
| 14 | } from "./elementContext"; | |
| 15 | ||
| 16 | function 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 | ||
| 44 | function 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 | ||
| 63 | describe("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 | ||
| 151 | describe("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 | ||
| 168 | describe("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 | ||
| 193 | describe("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 | ||
| 213 | describe("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 | ||
| 257 | describe("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 | ||
| 283 | describe("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 @@ |
| 1 | import { type ThreadId } from "@t3tools/contracts"; | |
| 2 | import type { PickedElementPayload, PickedElementStackFrame } from "@t3tools/contracts"; | |
| 3 | ||
| 4 | const ELEMENT_CONTEXT_HTML_PREVIEW_LIMIT = 4000; | |
| 5 | const ELEMENT_CONTEXT_STYLES_LIMIT = 4000; | |
| 6 | const ELEMENT_CONTEXT_LABEL_TAG_MAX = 24; | |
| 7 | ||
| 8 | const 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 | */ | |
| 17 | export 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 | ||
| 36 | export 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 | ||
| 44 | export interface ParsedElementContextEntry { | |
| 45 | header: string; | |
| 46 | body: string; | |
| 47 | } | |
| 48 | ||
| 49 | export interface ExtractedElementContexts { | |
| 50 | promptText: string; | |
| 51 | contextCount: number; | |
| 52 | contexts: ParsedElementContextEntry[]; | |
| 53 | } | |
| 54 | ||
| 55 | function 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 | ||
| 60 | function 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 | */ | |
| 69 | export 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 | */ | |
| 101 | export function elementContextDedupKey(context: ElementContextSelection): string { | |
| 102 | return [context.pageUrl, context.selector ?? "", context.tagName, context.componentName ?? ""] | |
| 103 | .join("|") | |
| 104 | .toLowerCase(); | |
| 105 | } | |
| 106 | ||
| 107 | function 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 | */ | |
| 116 | export function formatElementContextLabel(context: ElementContextSelection): string { | |
| 117 | if (context.componentName) return `<${context.componentName}>`; | |
| 118 | return `<${shortenTagLabel(context.tagName)}>`; | |
| 119 | } | |
| 120 | ||
| 121 | function basenameFromPath(filePath: string): string { | |
| 122 | const parts = filePath.split(/[\\/]/); | |
| 123 | return parts[parts.length - 1] ?? filePath; | |
| 124 | } | |
| 125 | ||
| 126 | export 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 | ||
| 134 | function buildContextHeader(context: ElementContextSelection): string { | |
| 135 | const label = formatElementContextLabel(context); | |
| 136 | const source = formatElementContextSourceLabel(context); | |
| 137 | return source ? `${label} (${source})` : label; | |
| 138 | } | |
| 139 | ||
| 140 | function indentLines(value: string): string[] { | |
| 141 | return value.split("\n").map((line) => ` ${line}`); | |
| 142 | } | |
| 143 | ||
| 144 | function 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 | */ | |
| 179 | export 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 | ||
| 190 | export 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 | ||
| 200 | const ELEMENT_CONTEXT_ID_PREFIX = "el_"; | |
| 201 | ||
| 202 | export 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 | */ | |
| 214 | export 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 | ||
| 224 | function 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", () => { |
| 122 | 122 | body: "12 | git status\n13 | On branch main", |
| 123 | 123 | }, |
| 124 | 124 | ], |
| 125 | elementContexts: [], | |
| 125 | 126 | }); |
| 126 | 127 | }); |
| 127 | 128 |
apps/web/src/lib/terminalContext.ts+18-5| ... | ... | @@ -1,5 +1,7 @@ |
| 1 | 1 | import { type ThreadId } from "@t3tools/contracts"; |
| 2 | 2 | |
| 3 | import { extractTrailingElementContexts, type ParsedElementContextEntry } from "./elementContext"; | |
| 4 | ||
| 3 | 5 | export interface TerminalContextSelection { |
| 4 | 6 | terminalId: string; |
| 5 | 7 | terminalLabel: string; |
| ... | ... | @@ -27,6 +29,12 @@ export interface DisplayedUserMessageState { |
| 27 | 29 | contextCount: number; |
| 28 | 30 | previewTitle: string | null; |
| 29 | 31 | 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[]; | |
| 30 | 38 | } |
| 31 | 39 | |
| 32 | 40 | export interface ParsedTerminalContextEntry { |
| ... | ... | @@ -238,13 +246,18 @@ export function extractTrailingTerminalContexts(prompt: string): ExtractedTermin |
| 238 | 246 | } |
| 239 | 247 | |
| 240 | 248 | export 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); | |
| 242 | 254 | return { |
| 243 | visibleText: extractedContexts.promptText, | |
| 255 | visibleText: extractedTerminal.promptText, | |
| 244 | 256 | 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, | |
| 248 | 261 | }; |
| 249 | 262 | } |
| 250 | 263 |
packages/contracts/src/ipc.ts+79-1| ... | ... | @@ -441,6 +441,70 @@ export interface DesktopPreviewTabState { |
| 441 | 441 | updatedAt: string; |
| 442 | 442 | } |
| 443 | 443 | |
| 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 | */ | |
| 450 | export 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 | */ | |
| 472 | export 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 | */ | |
| 485 | export 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 | ||
| 444 | 508 | export interface DesktopBridge { |
| 445 | 509 | getAppBranding: () => DesktopAppBranding | null; |
| 446 | 510 | getLocalEnvironmentBootstrap: () => DesktopEnvironmentBootstrap | null; |
| ... | ... | @@ -525,7 +589,21 @@ export interface DesktopPreviewBridge { |
| 525 | 589 | clearCookies: () => Promise<void>; |
| 526 | 590 | /** Drop the HTTP cache for the preview partition (all tabs). */ |
| 527 | 591 | 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>; | |
| 529 | 607 | onStateChange: (listener: (tabId: string, state: DesktopPreviewTabState) => void) => () => void; |
| 530 | 608 | } |
| 531 | 609 |