From 2cb4c8136511e0113087e50787992311dd77f326 Mon Sep 17 00:00:00 2001 From: Omri Katz <9701896+omridevk@users.noreply.github.com> Date: Sat, 15 Aug 2026 02:07:43 +0300 Subject: [PATCH 01/22] refactor(grab): #487 carry the dom preview as markup instead of a live node --- apps/conciv/src/pane/grab-reference.tsx | 2 +- .../test/grab-reference.browser.test.tsx | 6 ++-- packages/extension-testkit/src/host/grab.ts | 4 ++- packages/grab/src/grab.ts | 2 +- .../page/src/react-grab/capture-element.ts | 5 +-- .../page/test/capture-element.browser.test.ts | 33 ++++++++++++------- 6 files changed, 32 insertions(+), 20 deletions(-) diff --git a/apps/conciv/src/pane/grab-reference.tsx b/apps/conciv/src/pane/grab-reference.tsx index 0536b1687..a6040c95c 100644 --- a/apps/conciv/src/pane/grab-reference.tsx +++ b/apps/conciv/src/pane/grab-reference.tsx @@ -42,7 +42,7 @@ function ScaledSnapshot(props: {preview: GrabPreview}): JSX.Element { class="pointer-events-none" data-pw-grab-scale style={{width: `${preview().width}px`, height: `${preview().height}px`}} - ref={(el) => el.appendChild(preview().node.cloneNode(true))} + innerHTML={preview().html} /> diff --git a/apps/conciv/test/grab-reference.browser.test.tsx b/apps/conciv/test/grab-reference.browser.test.tsx index 515ee0e55..7813259e2 100644 --- a/apps/conciv/test/grab-reference.browser.test.tsx +++ b/apps/conciv/test/grab-reference.browser.test.tsx @@ -14,11 +14,9 @@ function mount(grab: Grab | {text: string}): HTMLElement { } function domGrab(size: {width: number; height: number} = {width: 200, height: 40}): Grab { - const node = document.createElement('div') - node.textContent = 'Payroll Deposit clone' return { text: 'Payroll Deposit', - preview: {kind: 'dom', node, width: size.width, height: size.height}, + preview: {kind: 'dom', html: '
Payroll Deposit clone
', width: size.width, height: size.height}, source: null, rect: {x: 0, y: 0, width: size.width, height: size.height}, } @@ -28,7 +26,7 @@ function previewBox(host: HTMLElement): HTMLElement | null | undefined { return host.querySelector('[data-pw-grab-scale]')?.parentElement } -test('grab reference renders the dom preview arm by appending the cloned node', () => { +test('grab reference renders the dom preview arm from the captured markup', () => { const host = mount(domGrab()) expect(page.getByText('Payroll Deposit clone').elements()).toHaveLength(1) expect(host.querySelector('img')).toBeNull() diff --git a/packages/extension-testkit/src/host/grab.ts b/packages/extension-testkit/src/host/grab.ts index 3455e4ae0..f85c75838 100644 --- a/packages/extension-testkit/src/host/grab.ts +++ b/packages/extension-testkit/src/host/grab.ts @@ -28,9 +28,11 @@ function toElementSource(parts: SourceParts): ElementSource { function toGrab(element: Element): Grab { const box = element.getBoundingClientRect() const parts = sourceOf(element) + const clone = element.cloneNode(true) + const html = clone instanceof HTMLElement ? clone.outerHTML : '' return { text: element.textContent ?? '', - preview: {kind: 'dom', node: element.cloneNode(true) as HTMLElement, width: box.width, height: box.height}, + preview: {kind: 'dom', html, width: box.width, height: box.height}, source: parts ? toElementSource(parts) : null, rect: {x: box.x, y: box.y, width: box.width, height: box.height}, } diff --git a/packages/grab/src/grab.ts b/packages/grab/src/grab.ts index f01981e30..a4d115a01 100644 --- a/packages/grab/src/grab.ts +++ b/packages/grab/src/grab.ts @@ -13,7 +13,7 @@ export type ElementRect = { export type DomPreview = { kind: 'dom' - node: HTMLElement + html: string width: number height: number } diff --git a/packages/page/src/react-grab/capture-element.ts b/packages/page/src/react-grab/capture-element.ts index 45159bb10..fbf2101fc 100644 --- a/packages/page/src/react-grab/capture-element.ts +++ b/packages/page/src/react-grab/capture-element.ts @@ -8,8 +8,9 @@ export function captureElement(el: Element): Promise { function captureSync(el: Element): DomPreview { const rect = el.getBoundingClientRect() - const clone = el.cloneNode(true) as HTMLElement + const clone = el.cloneNode(true) const rules: string[] = [] + if (!(clone instanceof HTMLElement)) return {kind: 'dom', html: '', width: rect.width, height: rect.height} inlineComputedStyles(el, clone, rules) neutralizeLayout(clone) @@ -22,7 +23,7 @@ function captureSync(el: Element): DomPreview { node.appendChild(style) } node.appendChild(clone) - return {kind: 'dom', node, width: rect.width, height: rect.height} + return {kind: 'dom', html: node.outerHTML, width: rect.width, height: rect.height} } const SKIP_PROPS = new Set(['cursor', 'pointer-events', 'user-select', '-webkit-user-select']) diff --git a/packages/page/test/capture-element.browser.test.ts b/packages/page/test/capture-element.browser.test.ts index 27eb4062c..379e9fe2f 100644 --- a/packages/page/test/capture-element.browser.test.ts +++ b/packages/page/test/capture-element.browser.test.ts @@ -23,34 +23,45 @@ function mountStyled(): HTMLElement { return section instanceof HTMLElement ? section : host } +function rebuild(html: string): HTMLElement { + const host = document.createElement('div') + host.innerHTML = html + return host +} + describe('captureElement', () => { - it('clones with inlined computed styles and measured size', async () => { + it('captures the clone as markup carrying the inlined styles', async () => { const el = mountStyled() const preview = await captureElement(el) expect(preview.width).toBeGreaterThan(0) - const clone = preview.node.querySelector('section') + expect(preview.html.startsWith(' { const el = mountStyled() const preview = await captureElement(el) - const sheet = preview.node.querySelector('style')?.textContent ?? '' + const rebuilt = rebuild(preview.html) + const sheet = rebuilt.querySelector('style')?.textContent ?? '' expect(sheet).toContain('::before') expect(sheet).toContain('content:"★"') - const clone = preview.node.querySelector('section') + const clone = rebuilt.querySelector('section') expect([...(clone?.classList ?? [])].some((cls) => cls.startsWith('pw-grab-pseudo-'))).toBe(true) }) it('strips ids and neutralizes the root layout', async () => { const el = mountStyled() const preview = await captureElement(el) - expect(preview.node.querySelector('[id]')).toBeNull() - const clone = preview.node.querySelector('section') - expect(clone?.style.position).toBe('static') - expect(clone?.style.margin).toBe('0px') + const rebuilt = rebuild(preview.html) + expect(rebuilt.querySelector('[id]')).toBeNull() + const clone = rebuilt.querySelector('section') + expect(clone instanceof HTMLElement ? clone.style.position : '').toBe('static') + expect(clone instanceof HTMLElement ? clone.style.margin : '').toBe('0px') }) }) From c3040eb3d8f2bef188dcf36483329878e809258d Mon Sep 17 00:00:00 2001 From: Omri Katz <9701896+omridevk@users.noreply.github.com> Date: Sat, 15 Aug 2026 02:20:21 +0300 Subject: [PATCH 02/22] feat(drafts): #487 persist composer attachments in the session draft row --- apps/conciv/src/pane/draft-storage.ts | 13 +- apps/conciv/test/draft-storage.test.ts | 24 + packages/contract/src/rows.ts | 14 + .../migration.sql | 1 + .../snapshot.json | 695 ++++++++++++++++++ packages/db/src/schema.ts | 3 + 6 files changed, 749 insertions(+), 1 deletion(-) create mode 100644 packages/db/drizzle/20260814231022_draft_attachments/migration.sql create mode 100644 packages/db/drizzle/20260814231022_draft_attachments/snapshot.json diff --git a/apps/conciv/src/pane/draft-storage.ts b/apps/conciv/src/pane/draft-storage.ts index 89e2af826..371254c7b 100644 --- a/apps/conciv/src/pane/draft-storage.ts +++ b/apps/conciv/src/pane/draft-storage.ts @@ -6,9 +6,18 @@ import type {SelectionOffsets} from './composer-input-adapter.js' const WRITE_DELAY_MS = 300 +const PersistedAttachmentSchema = z.object({ + id: z.string(), + type: z.string(), + name: z.string(), + contentType: z.string(), + data: z.string(), +}) + const PersistedDraftSchema = z.object({ text: z.string().catch(''), grabs: z.array(z.string()).catch([]), + attachments: z.array(PersistedAttachmentSchema).catch([]), }) type PersistedDraft = z.infer @@ -42,12 +51,13 @@ export async function appendDraft(rpc: RpcClient, sessionId: string, text: strin selectionStart: nextText.length, selectionEnd: nextText.length, grabs: row?.grabs ?? [], + attachments: row?.attachments ?? [], }) } export async function makeDraftStorage(rpc: RpcClient, sessionId: string): Promise { const row = await rpc.drafts.get({sessionId}).catch(() => null) - let cache = row ? JSON.stringify({text: row.text, quote: null, grabs: row.grabs, attachments: []}) : null + let cache = row ? JSON.stringify({text: row.text, quote: null, grabs: row.grabs, attachments: row.attachments}) : null let selection: SelectionOffsets | null = null const write = debounce( (draft: PersistedDraft) => { @@ -59,6 +69,7 @@ export async function makeDraftStorage(rpc: RpcClient, sessionId: string): Promi selectionStart: offsets.start, selectionEnd: offsets.end, grabs: draft.grabs, + attachments: draft.attachments, }) .catch(() => {}) }, diff --git a/apps/conciv/test/draft-storage.test.ts b/apps/conciv/test/draft-storage.test.ts index 64a3d3711..bd6582750 100644 --- a/apps/conciv/test/draft-storage.test.ts +++ b/apps/conciv/test/draft-storage.test.ts @@ -168,6 +168,30 @@ test('appends to the stored draft on a new line with the caret at the end', asyn }) }) +const PERSISTED = { + id: 'a1', + type: 'document', + name: 'Grabbed element', + contentType: 'application/vnd.conciv.grab+json', + data: 'eyJ4IjoxfQ==', +} + +test('an attachment written to the draft comes back on the next mount', async ({kit, core, sessionId}) => { + const first = await makeDraftStorage(makeRpcClient(core.base), sessionId) + + first.storage.setItem( + sessionId, + JSON.stringify({text: 'look at this', quote: null, grabs: [], attachments: [PERSISTED]}), + ) + await core.awaitRequest(DRAFTS_SET_PATH) + + expect((await kit.rpc.drafts.get({sessionId}))?.attachments).toEqual([PERSISTED]) + + const second = await makeDraftStorage(makeRpcClient(core.base), sessionId) + + expect(JSON.parse(second.storage.getItem(sessionId) ?? '{}')).toMatchObject({attachments: [{id: 'a1'}]}) +}) + test('survives a failed initial read and still accepts writes', async ({kit, core, sessionId}) => { await seedDraft(kit.rpc, sessionId, {text: 'unreachable at boot'}) core.fail(DRAFTS_GET_PATH) diff --git a/packages/contract/src/rows.ts b/packages/contract/src/rows.ts index ad778f4b5..97a1516fc 100644 --- a/packages/contract/src/rows.ts +++ b/packages/contract/src/rows.ts @@ -19,12 +19,26 @@ export const SessionMetaSchema = z.object({ }) export type SessionMeta = z.infer +export const PersistedAttachmentSchema = z.object({ + id: z.string(), + type: z.string(), + name: z.string(), + contentType: z.string(), + data: z.string(), +}) +export type PersistedAttachment = z.infer + export const DraftRowSchema = z.object({ sessionId: z.string(), text: z.string(), selectionStart: z.number().int().min(0), selectionEnd: z.number().int().min(0), grabs: z.array(z.string()), + attachments: z + .array(PersistedAttachmentSchema) + .nullable() + .default([]) + .transform((value) => value ?? []), updatedAt: z.number(), }) export type DraftRow = z.infer diff --git a/packages/db/drizzle/20260814231022_draft_attachments/migration.sql b/packages/db/drizzle/20260814231022_draft_attachments/migration.sql new file mode 100644 index 000000000..cec3713e8 --- /dev/null +++ b/packages/db/drizzle/20260814231022_draft_attachments/migration.sql @@ -0,0 +1 @@ +ALTER TABLE `drafts` ADD `attachments` text; \ No newline at end of file diff --git a/packages/db/drizzle/20260814231022_draft_attachments/snapshot.json b/packages/db/drizzle/20260814231022_draft_attachments/snapshot.json new file mode 100644 index 000000000..3b4bc06e8 --- /dev/null +++ b/packages/db/drizzle/20260814231022_draft_attachments/snapshot.json @@ -0,0 +1,695 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "bd003028-f108-43e2-a0eb-585be16e9020", + "prevIds": ["5250e755-c403-442d-adbb-eabdd6688bcf"], + "ddl": [ + { + "name": "css_bundles", + "entityType": "tables" + }, + { + "name": "drafts", + "entityType": "tables" + }, + { + "name": "markers", + "entityType": "tables" + }, + { + "name": "navigation", + "entityType": "tables" + }, + { + "name": "sessions", + "entityType": "tables" + }, + { + "name": "tool_captures", + "entityType": "tables" + }, + { + "name": "replies", + "entityType": "tables" + }, + { + "name": "run_messages", + "entityType": "tables" + }, + { + "name": "runs", + "entityType": "tables" + }, + { + "name": "image_history", + "entityType": "tables" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "hash", + "entityType": "columns", + "table": "css_bundles" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "css", + "entityType": "columns", + "table": "css_bundles" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "css_bundles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "drafts" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "text", + "entityType": "columns", + "table": "drafts" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "selection_start", + "entityType": "columns", + "table": "drafts" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "selection_end", + "entityType": "columns", + "table": "drafts" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "grabs", + "entityType": "columns", + "table": "drafts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "attachments", + "entityType": "columns", + "table": "drafts" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "drafts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "markers" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "markers" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "after_turn", + "entityType": "columns", + "table": "markers" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "kind", + "entityType": "columns", + "table": "markers" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": "'navigation'", + "generated": null, + "name": "id", + "entityType": "columns", + "table": "navigation" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "entries", + "entityType": "columns", + "table": "navigation" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "index", + "entityType": "columns", + "table": "navigation" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "navigation" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "sessions" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "harness_session_id", + "entityType": "columns", + "table": "sessions" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "harness_kind", + "entityType": "columns", + "table": "sessions" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "origin", + "entityType": "columns", + "table": "sessions" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "title", + "entityType": "columns", + "table": "sessions" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "sessions" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "usage", + "entityType": "columns", + "table": "sessions" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "cwd", + "entityType": "columns", + "table": "sessions" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "transcript_cwd", + "entityType": "columns", + "table": "sessions" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "attached_pid", + "entityType": "columns", + "table": "sessions" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "attached_at", + "entityType": "columns", + "table": "sessions" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "deleted_at", + "entityType": "columns", + "table": "sessions" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "sessions" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "sessions" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "tool_call_id", + "entityType": "columns", + "table": "tool_captures" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "kind", + "entityType": "columns", + "table": "tool_captures" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "tool_captures" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "css_bundle_id", + "entityType": "columns", + "table": "tool_captures" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "payload", + "entityType": "columns", + "table": "tool_captures" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "tool_captures" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "replies" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "key", + "entityType": "columns", + "table": "replies" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "value", + "entityType": "columns", + "table": "replies" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "replies" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "run_messages" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "messages", + "entityType": "columns", + "table": "run_messages" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "run_messages" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "runs" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'idle'", + "generated": null, + "name": "status", + "entityType": "columns", + "table": "runs" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "run_epoch", + "entityType": "columns", + "table": "runs" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_error", + "entityType": "columns", + "table": "runs" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_error_epoch", + "entityType": "columns", + "table": "runs" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "runs" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "image_history" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "messages", + "entityType": "columns", + "table": "image_history" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "image_history" + }, + { + "columns": ["tool_call_id", "kind", "session_id"], + "nameExplicit": true, + "name": "tool_captures_pk", + "entityType": "pks", + "table": "tool_captures" + }, + { + "columns": ["session_id", "key"], + "nameExplicit": false, + "name": "replies_pk", + "entityType": "pks", + "table": "replies" + }, + { + "columns": ["hash"], + "nameExplicit": false, + "name": "css_bundles_pk", + "table": "css_bundles", + "entityType": "pks" + }, + { + "columns": ["session_id"], + "nameExplicit": false, + "name": "drafts_pk", + "table": "drafts", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "markers_pk", + "table": "markers", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "navigation_pk", + "table": "navigation", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "sessions_pk", + "table": "sessions", + "entityType": "pks" + }, + { + "columns": ["session_id"], + "nameExplicit": false, + "name": "run_messages_pk", + "table": "run_messages", + "entityType": "pks" + }, + { + "columns": ["session_id"], + "nameExplicit": false, + "name": "runs_pk", + "table": "runs", + "entityType": "pks" + }, + { + "columns": ["session_id"], + "nameExplicit": false, + "name": "image_history_pk", + "table": "image_history", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "harness_kind", + "isExpression": false + }, + { + "value": "cwd", + "isExpression": false + }, + { + "value": "harness_session_id", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "sessions_native_key_unique", + "entityType": "indexes", + "table": "sessions" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "tool_captures_session_id_idx", + "entityType": "indexes", + "table": "tool_captures" + } + ], + "renames": [] +} diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 15d3fdddc..1f90cf820 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -24,12 +24,15 @@ export const sessions = sqliteTable( (table) => [uniqueIndex('sessions_native_key_unique').on(table.harnessKind, table.cwd, table.harnessSessionId)], ) +type PersistedAttachment = {id: string; type: string; name: string; contentType: string; data: string} + export const drafts = sqliteTable('drafts', { sessionId: text('session_id').primaryKey(), text: text('text').notNull(), selectionStart: integer('selection_start').notNull(), selectionEnd: integer('selection_end').notNull(), grabs: text('grabs', {mode: 'json'}).$type().notNull(), + attachments: text('attachments', {mode: 'json'}).$type(), updatedAt: integer('updated_at').notNull(), }) From 6bd3f534468fe3daad97c74d7955c4b252262d96 Mon Sep 17 00:00:00 2001 From: Omri Katz <9701896+omridevk@users.noreply.github.com> Date: Sat, 15 Aug 2026 02:33:29 +0300 Subject: [PATCH 03/22] feat(grab): #487 grab attachment payload codec and sourceLabel move --- apps/conciv/src/pane/grab-reference.tsx | 3 +- apps/conciv/src/pane/grab-source-label.ts | 14 --- packages/grab/package.json | 7 ++ packages/grab/src/grab-attachment.ts | 93 +++++++++++++++++++ packages/grab/src/grab.ts | 13 +++ packages/grab/test/grab-attachment.test.ts | 64 +++++++++++++ .../grab/test/source-label.test.ts | 2 +- packages/grab/tsdown.config.ts | 2 +- pnpm-lock.yaml | 4 + 9 files changed, 184 insertions(+), 18 deletions(-) delete mode 100644 apps/conciv/src/pane/grab-source-label.ts create mode 100644 packages/grab/src/grab-attachment.ts create mode 100644 packages/grab/test/grab-attachment.test.ts rename apps/conciv/test/grab-source-label.test.ts => packages/grab/test/source-label.test.ts (94%) diff --git a/apps/conciv/src/pane/grab-reference.tsx b/apps/conciv/src/pane/grab-reference.tsx index a6040c95c..f68defa26 100644 --- a/apps/conciv/src/pane/grab-reference.tsx +++ b/apps/conciv/src/pane/grab-reference.tsx @@ -1,8 +1,7 @@ import {Show, type JSX} from 'solid-js' import X from 'lucide-solid/icons/x' import {TooltipIconButton} from '@conciv/ui-kit-system' -import type {GrabPreview, Grab} from '@conciv/grab' -import {sourceLabel} from './grab-source-label.js' +import {sourceLabel, type GrabPreview, type Grab} from '@conciv/grab' function domPreview(preview: GrabPreview): Extract | null { return preview.kind === 'dom' ? preview : null diff --git a/apps/conciv/src/pane/grab-source-label.ts b/apps/conciv/src/pane/grab-source-label.ts deleted file mode 100644 index e1d14ce47..000000000 --- a/apps/conciv/src/pane/grab-source-label.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type {ElementSource} from '@conciv/grab' - -function whereLabel(source: ElementSource): string { - if (source.filePath === '') return '' - if (source.lineNumber === null) return source.filePath - return `${source.filePath}:${source.lineNumber}` -} - -export function sourceLabel(source: ElementSource): string { - const where = whereLabel(source) - if (!source.componentName) return where - if (where === '') return source.componentName - return `${source.componentName} at ${where}` -} diff --git a/packages/grab/package.json b/packages/grab/package.json index ee037adc7..3e0f8b4d0 100644 --- a/packages/grab/package.json +++ b/packages/grab/package.json @@ -22,6 +22,10 @@ ".": { "types": "./dist/grab.d.ts", "import": "./dist/grab.js" + }, + "./grab-attachment": { + "types": "./dist/grab-attachment.d.ts", + "import": "./dist/grab-attachment.js" } }, "publishConfig": { @@ -35,6 +39,9 @@ "publint": "publint", "attw": "attw --pack . --profile esm-only" }, + "dependencies": { + "zod": "^4.4.3" + }, "devDependencies": { "@conciv/vitest-config": "workspace:*", "tsdown": "^0.22.4", diff --git a/packages/grab/src/grab-attachment.ts b/packages/grab/src/grab-attachment.ts new file mode 100644 index 000000000..e96c13b11 --- /dev/null +++ b/packages/grab/src/grab-attachment.ts @@ -0,0 +1,93 @@ +import {z} from 'zod' +import type {Grab, GrabPreview} from './grab.js' + +export const GRAB_MIME = 'application/vnd.conciv.grab+json' + +export const GRAB_FILE_NAME = 'Grabbed element' + +const MAX_PERSISTED_BASE64_CHARACTERS = 1_000_000 + +const BASE64_BYTES_PER_CHARACTER = 3 / 4 + +const MAX_PAYLOAD_BYTES = Math.floor(MAX_PERSISTED_BASE64_CHARACTERS * BASE64_BYTES_PER_CHARACTER) + +const ElementSourceSchema = z.object({ + componentName: z.string().nullable(), + filePath: z.string(), + lineNumber: z.number().nullable(), +}) + +const ElementRectSchema = z.object({x: z.number(), y: z.number(), width: z.number(), height: z.number()}) + +const GrabPreviewSchema = z.discriminatedUnion('kind', [ + z.object({kind: z.literal('dom'), html: z.string(), width: z.number(), height: z.number()}), + z.object({kind: z.literal('image'), dataUrl: z.string(), width: z.number(), height: z.number()}), +]) + +const GrabPayloadSchema = z.object({ + text: z.string(), + snippet: z.string().optional(), + source: ElementSourceSchema.nullable(), + rect: ElementRectSchema.nullable(), + preview: GrabPreviewSchema.nullable(), +}) + +export type GrabPayload = z.infer + +function payloadOf(grab: Grab, preview: GrabPreview | null): GrabPayload { + return { + text: grab.text, + ...(grab.snippet === undefined ? {} : {snippet: grab.snippet}), + source: grab.source, + rect: grab.rect, + preview, + } +} + +function payloadBytes(payload: GrabPayload): number { + return new TextEncoder().encode(JSON.stringify(payload)).length +} + +function withinBudget(payload: GrabPayload): boolean { + return payloadBytes(payload) <= MAX_PAYLOAD_BYTES +} + +function truncatedTo(payload: GrabPayload, codePoints: readonly string[], length: number): GrabPayload { + return {...payload, text: `${codePoints.slice(0, length).join('')}…`} +} + +function shrunkToBudget(payload: GrabPayload): GrabPayload { + const codePoints = Array.from(payload.text) + let low = 0 + let high = codePoints.length + while (low < high) { + const middle = Math.ceil((low + high) / 2) + const fits = withinBudget(truncatedTo(payload, codePoints, middle)) + if (fits) low = middle + if (!fits) high = middle - 1 + } + return truncatedTo(payload, codePoints, low) +} + +export function grabToPayload(grab: Grab): GrabPayload { + const full = payloadOf(grab, grab.preview) + if (withinBudget(full)) return full + const withoutPreview = payloadOf({...grab, snippet: undefined}, null) + if (withinBudget(withoutPreview)) return withoutPreview + const shrunk = shrunkToBudget(withoutPreview) + if (withinBudget(shrunk)) return shrunk + return {text: shrunk.text, source: null, rect: null, preview: null} +} + +export function grabToFile(grab: Grab): File { + return new File([JSON.stringify(grabToPayload(grab))], GRAB_FILE_NAME, {type: GRAB_MIME}) +} + +export function parseGrabPayload(raw: string): GrabPayload | null { + try { + const parsed = GrabPayloadSchema.safeParse(JSON.parse(raw)) + return parsed.success ? parsed.data : null + } catch { + return null + } +} diff --git a/packages/grab/src/grab.ts b/packages/grab/src/grab.ts index a4d115a01..b5b2106b8 100644 --- a/packages/grab/src/grab.ts +++ b/packages/grab/src/grab.ts @@ -27,6 +27,19 @@ export type ImagePreview = { export type GrabPreview = DomPreview | ImagePreview +function whereLabel(source: ElementSource): string { + if (source.filePath === '') return '' + if (source.lineNumber === null) return source.filePath + return `${source.filePath}:${source.lineNumber}` +} + +export function sourceLabel(source: ElementSource): string { + const where = whereLabel(source) + if (!source.componentName) return where + if (where === '') return source.componentName + return `${source.componentName} at ${where}` +} + export type GrabFrame = { fileName: string line: number diff --git a/packages/grab/test/grab-attachment.test.ts b/packages/grab/test/grab-attachment.test.ts new file mode 100644 index 000000000..bfebec15b --- /dev/null +++ b/packages/grab/test/grab-attachment.test.ts @@ -0,0 +1,64 @@ +import {describe, expect, it} from 'vitest' +import {GRAB_FILE_NAME, GRAB_MIME, grabToFile, parseGrabPayload} from '../src/grab-attachment.js' +import type {Grab} from '../src/grab.js' + +const GRAB: Grab = { + text: '

Start simple

at src/routes/index.tsx:12:9', + snippet: '

Start simple

', + preview: {kind: 'dom', html: '

Start simple

', width: 320, height: 48}, + source: {componentName: 'Hero', filePath: 'src/routes/index.tsx', lineNumber: 12}, + rect: {x: 0, y: 0, width: 320, height: 48}, +} + +describe('grab attachment payload', () => { + it('round-trips a dom grab through a file body', async () => { + const file = grabToFile(GRAB) + + expect(file.type).toBe(GRAB_MIME) + expect(file.name).toBe(GRAB_FILE_NAME) + expect(parseGrabPayload(await file.text())).toEqual({ + text: GRAB.text, + snippet: GRAB.snippet, + source: GRAB.source, + rect: GRAB.rect, + preview: {kind: 'dom', html: '

Start simple

', width: 320, height: 48}, + }) + }) + + it('keeps an image preview instead of discarding it', async () => { + const imageGrab: Grab = { + ...GRAB, + preview: {kind: 'image', dataUrl: 'data:image/png;base64,AAA', width: 10, height: 10}, + } + + const payload = parseGrabPayload(await grabToFile(imageGrab).text()) + + expect(payload?.preview).toEqual({kind: 'image', dataUrl: 'data:image/png;base64,AAA', width: 10, height: 10}) + }) + + it('drops the preview when the payload would exceed the persistence budget', async () => { + const huge: Grab = {...GRAB, preview: {kind: 'dom', html: '

é

'.repeat(200_000), width: 10, height: 10}} + + const payload = parseGrabPayload(await grabToFile(huge).text()) + + expect(payload?.preview).toBeNull() + expect(payload?.text).toBe(GRAB.text) + }) + + it('truncates the text when even the preview-less payload is over budget', async () => { + const wordy: Grab = {...GRAB, text: 'é'.repeat(500_000), snippet: 'é'.repeat(500_000), preview: GRAB.preview} + + const file = grabToFile(wordy) + const payload = parseGrabPayload(await file.text()) + + expect(payload?.preview).toBeNull() + expect(payload?.snippet).toBeUndefined() + expect(new TextEncoder().encode(await file.text()).length).toBeLessThanOrEqual(750_000) + expect(payload?.text.endsWith('…')).toBe(true) + }) + + it('returns null for a body that is not a grab payload', () => { + expect(parseGrabPayload('{"nope":true}')).toBeNull() + expect(parseGrabPayload('not json at all')).toBeNull() + }) +}) diff --git a/apps/conciv/test/grab-source-label.test.ts b/packages/grab/test/source-label.test.ts similarity index 94% rename from apps/conciv/test/grab-source-label.test.ts rename to packages/grab/test/source-label.test.ts index 61889a0a4..96aa0a10c 100644 --- a/apps/conciv/test/grab-source-label.test.ts +++ b/packages/grab/test/source-label.test.ts @@ -1,5 +1,5 @@ import {describe, expect, it} from 'vitest' -import {sourceLabel} from '../src/pane/grab-source-label.js' +import {sourceLabel} from '../src/grab.js' describe('sourceLabel', () => { it('renders component and file:line for a web grab', () => { diff --git a/packages/grab/tsdown.config.ts b/packages/grab/tsdown.config.ts index f5e5c2aa7..25aca6c6d 100644 --- a/packages/grab/tsdown.config.ts +++ b/packages/grab/tsdown.config.ts @@ -1,7 +1,7 @@ import {defineConfig} from 'tsdown' export default defineConfig({ - entry: ['src/grab.ts'], + entry: ['src/grab.ts', 'src/grab-attachment.ts'], format: 'esm', fixedExtension: false, dts: true, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ea676707d..23effe9b4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2805,6 +2805,10 @@ importers: version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.0)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(jsdom@28.1.0(@noble/hashes@1.8.0))(vite@8.1.3(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.22.4)(yaml@2.9.0)) packages/grab: + dependencies: + zod: + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@conciv/vitest-config': specifier: workspace:* From efacef20742c3a65b5059b7553ab4b3b6174989f Mon Sep 17 00:00:00 2001 From: Omri Katz <9701896+omridevk@users.noreply.github.com> Date: Sat, 15 Aug 2026 02:46:11 +0300 Subject: [PATCH 04/22] feat(page): #487 refit oversized image previews to the payload budget --- packages/page/src/grab-fit.ts | 60 ++++++++++++++ packages/page/test/grab-fit.browser.test.ts | 87 +++++++++++++++++++++ 2 files changed, 147 insertions(+) create mode 100644 packages/page/src/grab-fit.ts create mode 100644 packages/page/test/grab-fit.browser.test.ts diff --git a/packages/page/src/grab-fit.ts b/packages/page/src/grab-fit.ts new file mode 100644 index 000000000..605f2bc94 --- /dev/null +++ b/packages/page/src/grab-fit.ts @@ -0,0 +1,60 @@ +import type {ImagePreview} from '@conciv/grab' + +const FIT_MIME = 'image/webp' + +const FIT_QUALITY = 0.8 + +const FIT_SCALE_STEP = 0.75 + +const MIN_FIT_SCALE = 0.05 + +function byteLength(value: string): number { + return new TextEncoder().encode(value).length +} + +async function decoded(dataUrl: string): Promise { + const image = new Image() + image.src = dataUrl + try { + await image.decode() + } catch { + return null + } + return image.naturalWidth > 0 && image.naturalHeight > 0 ? image : null +} + +function reencoded(image: HTMLImageElement, scale: number): string | null { + const canvas = document.createElement('canvas') + canvas.width = Math.max(1, Math.round(image.naturalWidth * scale)) + canvas.height = Math.max(1, Math.round(image.naturalHeight * scale)) + const context = canvas.getContext('2d') + if (context === null) return null + try { + context.drawImage(image, 0, 0, canvas.width, canvas.height) + const dataUrl = canvas.toDataURL(FIT_MIME, FIT_QUALITY) + return dataUrl.startsWith('data:image/') ? dataUrl : null + } catch { + return null + } +} + +function smallestWithin(image: HTMLImageElement, maxBytes: number): string | null { + let scale = 1 + let smallest: string | null = null + while (scale >= MIN_FIT_SCALE) { + const candidate = reencoded(image, scale) + if (candidate !== null) smallest = candidate + if (candidate !== null && byteLength(candidate) <= maxBytes) return candidate + scale *= FIT_SCALE_STEP + } + return smallest +} + +export async function fitImagePreview(preview: ImagePreview, maxBytes: number): Promise { + if (byteLength(preview.dataUrl) <= maxBytes) return preview + const image = await decoded(preview.dataUrl) + if (image === null) return preview + const fitted = smallestWithin(image, maxBytes) + if (fitted === null) return preview + return {...preview, dataUrl: fitted} +} diff --git a/packages/page/test/grab-fit.browser.test.ts b/packages/page/test/grab-fit.browser.test.ts new file mode 100644 index 000000000..122b8cc57 --- /dev/null +++ b/packages/page/test/grab-fit.browser.test.ts @@ -0,0 +1,87 @@ +import {describe, expect, it} from 'vitest' +import {grabToFile, parseGrabPayload} from '@conciv/grab/grab-attachment' +import type {Grab, ImagePreview} from '@conciv/grab' +import {fitImagePreview} from '../src/grab-fit.js' + +function noiseDataUrl(size: number): string { + const canvas = document.createElement('canvas') + canvas.width = size + canvas.height = size + const context = canvas.getContext('2d') + if (context === null) throw new Error('the test browser gave no 2d context') + const pixels = context.createImageData(size, size) + let seed = 0x2f6e_2b1 + for (let index = 0; index < pixels.data.length; index += 4) { + seed ^= seed << 13 + seed ^= seed >>> 17 + seed ^= seed << 5 + pixels.data[index] = seed & 0xff + pixels.data[index + 1] = (seed >>> 8) & 0xff + pixels.data[index + 2] = (seed >>> 16) & 0xff + pixels.data[index + 3] = 255 + } + context.putImageData(pixels, 0, 0) + return canvas.toDataURL('image/png') +} + +function byteLength(value: string): number { + return new TextEncoder().encode(value).length +} + +function imagePreview(dataUrl: string, size: number): ImagePreview { + return {kind: 'image', dataUrl, width: size, height: size} +} + +function grabWith(preview: ImagePreview): Grab { + return { + text: 'the payment card at src/card.tsx:42:3', + preview, + source: {componentName: 'PaymentCardCell', filePath: 'src/card.tsx', lineNumber: 42}, + rect: {x: 0, y: 0, width: preview.width, height: preview.height}, + } +} + +describe('fitImagePreview', () => { + it('returns a preview that already fits untouched', async () => { + const preview = imagePreview(noiseDataUrl(8), 8) + + const fitted = await fitImagePreview(preview, 1_000_000) + + expect(fitted).toEqual(preview) + expect(fitted.dataUrl).toBe(preview.dataUrl) + }) + + it('refits an oversized preview so it survives a payload round-trip as a smaller image', async () => { + const original = imagePreview(noiseDataUrl(320), 320) + const budget = 20_000 + expect(byteLength(original.dataUrl)).toBeGreaterThan(budget) + + const fitted = await fitImagePreview(original, budget) + + expect(byteLength(fitted.dataUrl)).toBeLessThanOrEqual(budget) + expect(fitted.width).toBe(320) + expect(fitted.height).toBe(320) + + const payload = parseGrabPayload(await grabToFile(grabWith(fitted)).text()) + + expect(payload?.preview).toEqual(fitted) + }) + + it('keeps an image rather than dropping it when nothing can reach the budget', async () => { + const original = imagePreview(noiseDataUrl(320), 320) + + const fitted = await fitImagePreview(original, 1) + + expect(fitted.kind).toBe('image') + expect(fitted.dataUrl.startsWith('data:image/')).toBe(true) + expect(byteLength(fitted.dataUrl)).toBeLessThan(byteLength(original.dataUrl)) + }) + + it('returns the original preview when the data url cannot be decoded', async () => { + const broken = imagePreview('data:image/png;base64,notreallyanimage', 320) + + const fitted = await fitImagePreview(broken, 10) + + expect(fitted).toEqual(broken) + }) +}) From 72f902520888d3b4623155622118adce5ed77a20 Mon Sep 17 00:00:00 2001 From: Omri Katz <9701896+omridevk@users.noreply.github.com> Date: Sat, 15 Aug 2026 03:11:18 +0300 Subject: [PATCH 05/22] feat(page): #487 sandboxed grab snapshot frame Co-Authored-By: Claude Fable 5 --- .../src/client/cards/grab-snapshot-frame.tsx | 33 +++++++++++++++++++ ...grab-snapshot-frame.solid.browser.test.tsx | 26 +++++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 packages/extensions/page/src/client/cards/grab-snapshot-frame.tsx create mode 100644 packages/extensions/page/test/grab-snapshot-frame.solid.browser.test.tsx diff --git a/packages/extensions/page/src/client/cards/grab-snapshot-frame.tsx b/packages/extensions/page/src/client/cards/grab-snapshot-frame.tsx new file mode 100644 index 000000000..6f51b09a7 --- /dev/null +++ b/packages/extensions/page/src/client/cards/grab-snapshot-frame.tsx @@ -0,0 +1,33 @@ +import type {JSX} from 'solid-js' + +const CSP = "default-src 'none'; img-src data:; style-src 'unsafe-inline' data:; font-src data:" + +const WRAPPER = 'block overflow-hidden max-w-full' + +const FRAME = 'block border-none bg-transparent pointer-events-none' + +function documentFor(html: string): string { + return `${html}` +} + +export function GrabSnapshotFrame(props: {html: string; width: number; height: number; scale: number}): JSX.Element { + return ( +
+