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