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 (
+
+
+
+ )
+}
diff --git a/packages/extensions/page/test/grab-snapshot-frame.solid.browser.test.tsx b/packages/extensions/page/test/grab-snapshot-frame.solid.browser.test.tsx
new file mode 100644
index 000000000..c58f710be
--- /dev/null
+++ b/packages/extensions/page/test/grab-snapshot-frame.solid.browser.test.tsx
@@ -0,0 +1,26 @@
+import {render} from '@solidjs/testing-library'
+import {expect, test} from 'vitest'
+import {page} from 'vitest/browser'
+import {GrabSnapshotFrame} from '../src/client/cards/grab-snapshot-frame.js'
+
+function hostileMarkup(marker: string): string {
+ return `
`
+}
+
+test('the snapshot frame renders the markup in its own document', async () => {
+ render(() => )
+
+ await expect.element(page.getByTitle('Grabbed element snapshot')).toBeVisible()
+})
+
+test('inline handlers in captured markup never run inside the widget', async () => {
+ render(() => (
+ <>
+
+
+ >
+ ))
+
+ await expect.element(page.getByText('control escaped')).toBeVisible()
+ await expect.element(page.getByText('snapshot escaped')).not.toBeInTheDocument()
+})
From 744d6994900b09cb2a4839f6f4db3c12d1ef078b Mon Sep 17 00:00:00 2001
From: Omri Katz <9701896+omridevk@users.noreply.github.com>
Date: Sat, 15 Aug 2026 03:23:32 +0300
Subject: [PATCH 06/22] feat(page): #487 grab attachment card and registration
Co-Authored-By: Claude Fable 5
---
.../test/pane-attachments.browser.test.ts | 15 ++
packages/extensions/page/package.json | 1 +
packages/extensions/page/src/client.tsx | 10 +-
.../page/src/client/cards/grab-card.tsx | 159 ++++++++++++++++++
.../page/src/shared/grab-attachment.ts | 5 +
.../page/test/fixtures/attachment-harness.tsx | 14 ++
.../test/grab-card.solid.browser.test.tsx | 66 ++++++++
pnpm-lock.yaml | 3 +
8 files changed, 272 insertions(+), 1 deletion(-)
create mode 100644 packages/extensions/page/src/client/cards/grab-card.tsx
create mode 100644 packages/extensions/page/src/shared/grab-attachment.ts
create mode 100644 packages/extensions/page/test/fixtures/attachment-harness.tsx
create mode 100644 packages/extensions/page/test/grab-card.solid.browser.test.tsx
diff --git a/apps/conciv/test/pane-attachments.browser.test.ts b/apps/conciv/test/pane-attachments.browser.test.ts
index 9c24435d1..8f6d06c01 100644
--- a/apps/conciv/test/pane-attachments.browser.test.ts
+++ b/apps/conciv/test/pane-attachments.browser.test.ts
@@ -1,5 +1,6 @@
import {describe, expect, it} from 'vitest'
import {defineAttachment, defineExtension} from '@conciv/extension'
+import pageExtension from '@conciv/extension-page/client'
import {paneAttachments} from '../src/pane/pane-attachments.js'
function fixtureExtension() {
@@ -18,6 +19,20 @@ describe('paneAttachments', () => {
expect(pending.type).toBe('document')
})
+ it('accepts grab attachments when the page extension is installed', () => {
+ const {cards, adapter} = paneAttachments([pageExtension], false)
+
+ expect(cards.some((entry) => entry.mime === 'application/vnd.conciv.grab+json')).toBe(true)
+ expect(adapter.accept).toContain('application/vnd.conciv.grab+json')
+ })
+
+ it('does not accept grab attachments with no extensions installed', () => {
+ const {cards, adapter} = paneAttachments([], false)
+
+ expect(cards.some((entry) => entry.mime === 'application/vnd.conciv.grab+json')).toBe(false)
+ expect(adapter.accept).not.toContain('application/vnd.conciv.grab+json')
+ })
+
it('still accepts text files and gates images on harness support', () => {
expect(paneAttachments([], false).adapter.accept).not.toContain('image/*')
expect(paneAttachments([], 'native').adapter.accept).toContain('image/*')
diff --git a/packages/extensions/page/package.json b/packages/extensions/page/package.json
index b36d62d06..2b7ccc001 100644
--- a/packages/extensions/page/package.json
+++ b/packages/extensions/page/package.json
@@ -59,6 +59,7 @@
},
"dependencies": {
"@conciv/extension": "workspace:^",
+ "@conciv/grab": "workspace:^",
"@conciv/page": "workspace:^",
"@conciv/protocol": "workspace:^",
"@conciv/ui-kit-chat": "workspace:*",
diff --git a/packages/extensions/page/src/client.tsx b/packages/extensions/page/src/client.tsx
index 89022d689..11115e98c 100644
--- a/packages/extensions/page/src/client.tsx
+++ b/packages/extensions/page/src/client.tsx
@@ -3,8 +3,16 @@ import {PAGE_SESSION_GROUP_KEY, type GroupEntry} from '@conciv/ui-kit-chat'
import {PAGE_EXTENSION_NAME} from './shared/defs.js'
import {PAGE_CLIENT_TOOLS} from './client/bodies.js'
import {SessionCard} from './client/cards/session-card.js'
+import {GrabCard} from './client/cards/grab-card.js'
+import {grabAttachment} from './shared/grab-attachment.js'
-export const page = defineExtension({name: PAGE_EXTENSION_NAME, tools: PAGE_CLIENT_TOOLS}).client(() => ({value: {}}))
+grabAttachment.card(GrabCard)
+
+export const page = defineExtension({
+ name: PAGE_EXTENSION_NAME,
+ tools: PAGE_CLIENT_TOOLS,
+ attachments: [grabAttachment],
+}).client(() => ({value: {}}))
export default page
diff --git a/packages/extensions/page/src/client/cards/grab-card.tsx b/packages/extensions/page/src/client/cards/grab-card.tsx
new file mode 100644
index 000000000..0926f1362
--- /dev/null
+++ b/packages/extensions/page/src/client/cards/grab-card.tsx
@@ -0,0 +1,159 @@
+import {createResource, createSignal, Show, type JSX} from 'solid-js'
+import {useAttachment} from '@conciv/ui-kit-chat'
+import {Button, Dialog} from '@conciv/ui-kit-system'
+import type {AttachmentCardProps} from '@conciv/extension'
+import {parseGrabPayload, type GrabPayload} from '@conciv/grab/grab-attachment'
+import {sourceLabel} from '@conciv/grab'
+import {GrabSnapshotFrame} from './grab-snapshot-frame.js'
+
+type AttachmentState = ReturnType
+
+type GrabPreview = NonNullable
+
+const CARD =
+ 'text-[0.6875rem] font-pw-mono p-3 border border-pw-line rounded-pw-md bg-pw-fill flex flex-col gap-2.5 items-start relative'
+const PREVIEW_SLOT = 'relative max-w-full'
+const OPEN_BUTTON = 'absolute inset-0 cursor-pointer'
+const SOURCE_LINE = 'text-pw-text-2 flex gap-1.5 [word-break:break-all] items-center'
+const AGENT_TEXT = 'text-pw-text-2 font-pw-mono text-xs whitespace-pre-wrap [word-break:break-all] m-0'
+const SNAPSHOT_IMAGE = 'block w-auto h-auto max-w-full'
+
+const SNAPSHOT_LABEL = 'Grabbed element snapshot'
+
+function decodeBase64Utf8(value: string): string | null {
+ try {
+ const binary = atob(value)
+ const bytes = new Uint8Array(binary.length)
+ for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index)
+ return new TextDecoder().decode(bytes)
+ } catch {
+ return null
+ }
+}
+
+async function resolvePayload(attachment: AttachmentState): Promise {
+ if ('content' in attachment)
+ for (const part of attachment.content)
+ if (part.type === 'document' && part.source.type === 'data') {
+ const body = decodeBase64Utf8(part.source.value)
+ return body === null ? null : parseGrabPayload(body)
+ }
+ if (attachment.file) return parseGrabPayload(await attachment.file.text())
+ return null
+}
+
+function domPreview(preview: GrabPreview): Extract | null {
+ return preview.kind === 'dom' ? preview : null
+}
+
+function imagePreview(preview: GrabPreview): Extract | null {
+ return preview.kind === 'image' ? preview : null
+}
+
+function Preview(props: {preview: GrabPreview; scale: number}): JSX.Element {
+ return (
+
+ {(image) => (
+
+ )}
+
+ }
+ >
+ {(dom) => }
+
+ )
+}
+
+function GrabBody(props: {payload: GrabPayload; remove?: JSX.Element}): JSX.Element {
+ const [open, setOpen] = createSignal(false)
+ const [showAgentText, setShowAgentText] = createSignal(false)
+ return (
+
+ {props.remove}
+
{props.payload.text}}>
+ {(preview) => (
+
+
+
+ )}
+
+
+ {(source) => (
+
+ {(label) => (
+
+
+ ↳
+ {' '}
+ in {label()}
+
+ )}
+
+ )}
+
+
+
+ )
+}
+
+export function GrabCard(props: AttachmentCardProps): JSX.Element {
+ const attachment = useAttachment()
+ const [payload] = createResource(() => resolvePayload(attachment))
+ return (
+
+ Reading grabbed element…
+
+ }
+ >
+
+ Grabbed element could not be read
+
+ }
+ >
+ {(value) => }
+
+
+ )
+}
diff --git a/packages/extensions/page/src/shared/grab-attachment.ts b/packages/extensions/page/src/shared/grab-attachment.ts
new file mode 100644
index 000000000..994949630
--- /dev/null
+++ b/packages/extensions/page/src/shared/grab-attachment.ts
@@ -0,0 +1,5 @@
+import {defineAttachment} from '@conciv/extension'
+import {GRAB_MIME} from '@conciv/grab/grab-attachment'
+import type {PageServerContext} from '../server.js'
+
+export const grabAttachment = defineAttachment({mime: GRAB_MIME})
diff --git a/packages/extensions/page/test/fixtures/attachment-harness.tsx b/packages/extensions/page/test/fixtures/attachment-harness.tsx
new file mode 100644
index 000000000..b3743d5be
--- /dev/null
+++ b/packages/extensions/page/test/fixtures/attachment-harness.tsx
@@ -0,0 +1,14 @@
+import type {JSX} from 'solid-js'
+import {AttachmentProvider, type PendingAttachment} from '@conciv/ui-kit-chat'
+
+export function mountAttachment(file: File, children: () => JSX.Element): JSX.Element {
+ const attachment: PendingAttachment = {
+ id: 'grab-1',
+ type: 'document',
+ name: file.name,
+ contentType: file.type,
+ file,
+ status: {type: 'requires-action', reason: 'composer-send'},
+ }
+ return {children()}
+}
diff --git a/packages/extensions/page/test/grab-card.solid.browser.test.tsx b/packages/extensions/page/test/grab-card.solid.browser.test.tsx
new file mode 100644
index 000000000..195115c0e
--- /dev/null
+++ b/packages/extensions/page/test/grab-card.solid.browser.test.tsx
@@ -0,0 +1,66 @@
+import {render} from '@solidjs/testing-library'
+import {expect, test} from 'vitest'
+import {page, userEvent} from 'vitest/browser'
+import {GRAB_FILE_NAME, GRAB_MIME} from '@conciv/grab/grab-attachment'
+import {GrabCard} from '../src/client/cards/grab-card.js'
+import {mountAttachment} from './fixtures/attachment-harness.js'
+
+const PAYLOAD = JSON.stringify({
+ text: 'Start simple
at src/routes/index.tsx:12:9',
+ snippet: 'Start simple
',
+ source: {componentName: 'Hero', filePath: 'src/routes/index.tsx', lineNumber: 12},
+ rect: {x: 0, y: 0, width: 320, height: 48},
+ preview: {kind: 'dom', html: 'Start simple
', width: 320, height: 48},
+})
+
+function mount(payload: string = PAYLOAD) {
+ return render(() => mountAttachment(new File([payload], GRAB_FILE_NAME, {type: GRAB_MIME}), () => ))
+}
+
+test('the card shows the snapshot and its source label', async () => {
+ mount()
+
+ await expect.element(page.getByTitle('Grabbed element snapshot')).toBeVisible()
+ await expect.element(page.getByText('Hero at src/routes/index.tsx:12')).toBeVisible()
+})
+
+test('clicking the card opens a dialog that can reveal the agent text', async () => {
+ mount()
+
+ await userEvent.click(page.getByRole('button', {name: 'Open grabbed element'}))
+ await expect.element(page.getByRole('dialog', {name: 'Grabbed element'})).toBeVisible()
+
+ await userEvent.click(page.getByRole('button', {name: 'What the agent sees'}))
+
+ await expect.element(page.getByText('Start simple
at src/routes/index.tsx:12:9')).toBeVisible()
+})
+
+test('a payload with no preview falls back to the agent text', async () => {
+ mount(JSON.stringify({text: 'grabbed thing at a.tsx:1:1', source: null, rect: null, preview: null}))
+
+ await expect.element(page.getByText('grabbed thing at a.tsx:1:1')).toBeVisible()
+})
+
+test('an unreadable payload reports that the grab could not be read', async () => {
+ mount('not a grab payload at all')
+
+ await expect.element(page.getByRole('status')).toHaveTextContent('Grabbed element could not be read')
+})
+
+test('an image preview renders as the snapshot image', async () => {
+ mount(
+ JSON.stringify({
+ text: 'grabbed thing at a.tsx:1:1',
+ source: null,
+ rect: null,
+ preview: {
+ kind: 'image',
+ dataUrl: 'data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==',
+ width: 40,
+ height: 20,
+ },
+ }),
+ )
+
+ await expect.element(page.getByRole('img', {name: 'Grabbed element snapshot'})).toBeVisible()
+})
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 23effe9b4..1d1bef800 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -2106,6 +2106,9 @@ importers:
'@conciv/extension':
specifier: workspace:^
version: link:../../extension
+ '@conciv/grab':
+ specifier: workspace:^
+ version: link:../../grab
'@conciv/page':
specifier: workspace:^
version: link:../../page
From 9685e1ed29b0fec51df4e9101be76393bd7b0b36 Mon Sep 17 00:00:00 2001
From: Omri Katz <9701896+omridevk@users.noreply.github.com>
Date: Sat, 15 Aug 2026 03:34:28 +0300
Subject: [PATCH 07/22] feat(page): #487 expand grab attachments into grounding
text
Co-Authored-By: Claude Fable 5
---
packages/extensions/page/src/server.ts | 7 +++-
.../page/src/server/grab-attachment.ts | 19 +++++++++++
.../page/src/shared/grab-attachment.ts | 3 +-
.../page/test/grab-expander.test.ts | 32 +++++++++++++++++++
4 files changed, 58 insertions(+), 3 deletions(-)
create mode 100644 packages/extensions/page/src/server/grab-attachment.ts
create mode 100644 packages/extensions/page/test/grab-expander.test.ts
diff --git a/packages/extensions/page/src/server.ts b/packages/extensions/page/src/server.ts
index cd2d034dc..0bb8332fc 100644
--- a/packages/extensions/page/src/server.ts
+++ b/packages/extensions/page/src/server.ts
@@ -2,6 +2,7 @@ import {z} from 'zod'
import {defineExtension, type AnyToolBuilder, type ServerPageCaller} from '@conciv/extension'
import {RawFrameSchema, type RawFrame, type SourceLoc} from '@conciv/protocol/page-types'
import {locateDef, PAGE_EXTENSION_NAME, PAGE_TOOL_DEFS} from './shared/defs.js'
+import {grabAttachment} from './server/grab-attachment.js'
export type PageServerContext = {
page: ServerPageCaller
@@ -20,7 +21,11 @@ const locateServer = locateDef.server(async (input, ctx: PageServerContext) => {
const declarations: AnyToolBuilder[] = PAGE_TOOL_DEFS.map((def) => (def === locateDef ? locateServer : def.client()))
-export const page = defineExtension({name: PAGE_EXTENSION_NAME, tools: declarations}).server((server) => ({
+export const page = defineExtension({
+ name: PAGE_EXTENSION_NAME,
+ tools: declarations,
+ attachments: [grabAttachment],
+}).server((server) => ({
context: {page: server.page, symbolicate: server.symbolicate},
}))
diff --git a/packages/extensions/page/src/server/grab-attachment.ts b/packages/extensions/page/src/server/grab-attachment.ts
new file mode 100644
index 000000000..ddbf7b0d9
--- /dev/null
+++ b/packages/extensions/page/src/server/grab-attachment.ts
@@ -0,0 +1,19 @@
+import {parseGrabPayload} from '@conciv/grab/grab-attachment'
+import {grabAttachment} from '../shared/grab-attachment.js'
+
+function decodeBody(value: string): string | null {
+ try {
+ return Buffer.from(value, 'base64').toString('utf8')
+ } catch {
+ return null
+ }
+}
+
+grabAttachment.server(async (part) => {
+ const body = decodeBody(part.source.value)
+ const payload = body === null ? null : parseGrabPayload(body)
+ if (!payload || payload.text === '') return []
+ return [{type: 'text', content: payload.text}]
+})
+
+export {grabAttachment}
diff --git a/packages/extensions/page/src/shared/grab-attachment.ts b/packages/extensions/page/src/shared/grab-attachment.ts
index 994949630..e5f557b19 100644
--- a/packages/extensions/page/src/shared/grab-attachment.ts
+++ b/packages/extensions/page/src/shared/grab-attachment.ts
@@ -1,5 +1,4 @@
import {defineAttachment} from '@conciv/extension'
import {GRAB_MIME} from '@conciv/grab/grab-attachment'
-import type {PageServerContext} from '../server.js'
-export const grabAttachment = defineAttachment({mime: GRAB_MIME})
+export const grabAttachment = defineAttachment({mime: GRAB_MIME})
diff --git a/packages/extensions/page/test/grab-expander.test.ts b/packages/extensions/page/test/grab-expander.test.ts
new file mode 100644
index 000000000..ec5cb9533
--- /dev/null
+++ b/packages/extensions/page/test/grab-expander.test.ts
@@ -0,0 +1,32 @@
+import {expect, test} from 'vitest'
+import {grabAttachment} from '../src/server/grab-attachment.js'
+import pageServerExtension from '../src/server.js'
+
+const PAYLOAD = {text: 'Start simple
at src/routes/index.tsx:12:9', source: null, rect: null, preview: null}
+
+function grabPart(body: string) {
+ return {
+ type: 'document' as const,
+ source: {type: 'data' as const, mimeType: grabAttachment.mime, value: Buffer.from(body, 'utf8').toString('base64')},
+ }
+}
+
+test('a grab attachment expands into grounding text', async () => {
+ const expand = grabAttachment.__expand
+ if (!expand) throw new Error('expected the grab attachment to declare a server expander')
+
+ expect(await expand(grabPart(JSON.stringify(PAYLOAD)), {})).toEqual([{type: 'text', content: PAYLOAD.text}])
+})
+
+test('the page server extension carries the grab attachment expander', () => {
+ const declared = pageServerExtension.attachments?.find((entry) => entry.mime === grabAttachment.mime)
+
+ expect(declared?.__expand).toBeTypeOf('function')
+})
+
+test('an unreadable grab payload expands to nothing rather than throwing', async () => {
+ const expand = grabAttachment.__expand
+ if (!expand) throw new Error('expected the grab attachment to declare a server expander')
+
+ expect(await expand(grabPart('nope'), {})).toEqual([])
+})
From 3404f222a5e6a07ec331968ca7dd441ebed5f56f Mon Sep 17 00:00:00 2001
From: Omri Katz <9701896+omridevk@users.noreply.github.com>
Date: Sat, 15 Aug 2026 03:56:57 +0300
Subject: [PATCH 08/22] feat(ui-kit-chat): #487 atomic composer attachment
replace with stable position
Co-Authored-By: Claude Fable 5
---
apps/conciv/src/pane/chat-pane.tsx | 2 +-
.../attachment/attachment.stories.tsx | 4 +-
.../primitives/composer/composer-context.tsx | 4 +-
.../src/primitives/composer/composer.tsx | 39 ++++-
.../styled/attachment-dispatch.stories.tsx | 4 +-
.../src/styled/attachment-ui.stories.tsx | 4 +-
.../composer-attachment-ops.browser.test.tsx | 164 ++++++++++++++++++
7 files changed, 214 insertions(+), 7 deletions(-)
create mode 100644 packages/ui-kit-chat/test/composer-attachment-ops.browser.test.tsx
diff --git a/apps/conciv/src/pane/chat-pane.tsx b/apps/conciv/src/pane/chat-pane.tsx
index 07ecc10f4..0340bc2ae 100644
--- a/apps/conciv/src/pane/chat-pane.tsx
+++ b/apps/conciv/src/pane/chat-pane.tsx
@@ -101,7 +101,7 @@ function grabTexts(grabs: ReadonlyArray): string[] {
}
type ComposerApi = {
- addAttachment: (file: File) => Promise
+ addAttachment: (file: File) => Promise
}
function ComposerWiring(props: {onReady: (api: ComposerApi) => void}): JSX.Element {
diff --git a/packages/ui-kit-chat/src/primitives/attachment/attachment.stories.tsx b/packages/ui-kit-chat/src/primitives/attachment/attachment.stories.tsx
index 5500556f3..d3a94fba2 100644
--- a/packages/ui-kit-chat/src/primitives/attachment/attachment.stories.tsx
+++ b/packages/ui-kit-chat/src/primitives/attachment/attachment.stories.tsx
@@ -33,7 +33,9 @@ function Harness(): JSX.Element {
value={{
attachments,
attachmentAdapter: () => undefined,
- addAttachment: async () => {},
+ addAttachment: async () => null,
+ hasAttachment: () => false,
+ replaceAttachment: async () => null,
removeAttachment: async (id) => {
setAttachments((prev) => prev.filter((attachment) => attachment.id !== id))
},
diff --git a/packages/ui-kit-chat/src/primitives/composer/composer-context.tsx b/packages/ui-kit-chat/src/primitives/composer/composer-context.tsx
index 4b9113d9a..98e06f81e 100644
--- a/packages/ui-kit-chat/src/primitives/composer/composer-context.tsx
+++ b/packages/ui-kit-chat/src/primitives/composer/composer-context.tsx
@@ -6,7 +6,9 @@ export type ComposerDraft = {draft: string; attachments: Attachment[]; quote: st
export type ComposerContextValue = {
attachments: Accessor
attachmentAdapter: Accessor
- addAttachment: (file: File) => Promise
+ addAttachment: (file: File) => Promise
+ hasAttachment: (id: string) => boolean
+ replaceAttachment: (id: string, file: File) => Promise
removeAttachment: (id: string) => Promise
sendingAttachments: Accessor
snapshotDraft: () => ComposerDraft
diff --git a/packages/ui-kit-chat/src/primitives/composer/composer.tsx b/packages/ui-kit-chat/src/primitives/composer/composer.tsx
index 7a2308a12..6c45ee188 100644
--- a/packages/ui-kit-chat/src/primitives/composer/composer.tsx
+++ b/packages/ui-kit-chat/src/primitives/composer/composer.tsx
@@ -175,7 +175,7 @@ function pastedFiles(event: ClipboardEvent): File[] {
async function addPastedFiles(
event: ClipboardEvent,
enabled: boolean | undefined,
- add: (file: File) => Promise,
+ add: (file: File) => Promise,
): Promise {
if (!enabled) return
await Promise.allSettled(pastedFiles(event).map((file) => add(file)))
@@ -206,11 +206,44 @@ function Root(props: FormProps): JSX.Element {
return current.toSpliced(index, 1, attachment)
})
}
- const addAttachment = async (file: File) => {
+ const addAttachment = async (file: File): Promise => {
const adapter = requireAttachmentAdapter(attachmentAdapter())
assertAcceptedFile(file, adapter)
const id = await addAdapterAttachment(adapter, file, upsertAttachment)
if (id) removedIds.delete(id)
+ return id ?? null
+ }
+ const hasAttachment = (id: string): boolean => attachments().some((entry) => entry.id === id)
+ const replaceAttachment = async (id: string, file: File): Promise => {
+ if (!hasAttachment(id)) return null
+ const adapter = requireAttachmentAdapter(attachmentAdapter())
+ assertAcceptedFile(file, adapter)
+ const staged: PendingAttachment[] = []
+ const collect = (attachment: PendingAttachment): void => {
+ const index = staged.findIndex((entry) => entry.id === attachment.id)
+ if (index < 0) staged.push(attachment)
+ if (index >= 0) staged.splice(index, 1, attachment)
+ }
+ const added = await addAdapterAttachment(adapter, file, collect).catch(async (error: unknown) => {
+ const orphan = staged.at(-1)
+ if (orphan) await removeAdapterAttachment(adapter, orphan).catch(() => {})
+ throw error
+ })
+ const replacement = staged.find((entry) => entry.id === added)
+ if (!added || !replacement) return null
+ const displaced = attachments().find((entry) => entry.id === id)
+ if (!displaced) {
+ await removeAdapterAttachment(adapter, replacement).catch(() => {})
+ return null
+ }
+ setState('attachments', (current) => {
+ const position = current.findIndex((entry) => entry.id === id)
+ if (position < 0) return current
+ return current.toSpliced(position, 1, replacement)
+ })
+ removedIds.add(id)
+ await removeAdapterAttachment(adapter, displaced).catch(() => {})
+ return added
}
const removeAttachment = async (id: string) => {
const attachment = requireAttachment(attachments(), id)
@@ -294,6 +327,8 @@ function Root(props: FormProps): JSX.Element {
attachments,
attachmentAdapter,
addAttachment,
+ hasAttachment,
+ replaceAttachment,
removeAttachment,
sendingAttachments: () => state.sendingAttachments,
snapshotDraft,
diff --git a/packages/ui-kit-chat/src/styled/attachment-dispatch.stories.tsx b/packages/ui-kit-chat/src/styled/attachment-dispatch.stories.tsx
index efbfa106a..dce8bc881 100644
--- a/packages/ui-kit-chat/src/styled/attachment-dispatch.stories.tsx
+++ b/packages/ui-kit-chat/src/styled/attachment-dispatch.stories.tsx
@@ -42,7 +42,9 @@ function Frame(): JSX.Element {
value={{
attachments,
attachmentAdapter: () => undefined,
- addAttachment: async () => {},
+ addAttachment: async () => null,
+ hasAttachment: () => false,
+ replaceAttachment: async () => null,
removeAttachment: async (id) => {
setAttachments((prev) => prev.filter((attachment) => attachment.id !== id))
},
diff --git a/packages/ui-kit-chat/src/styled/attachment-ui.stories.tsx b/packages/ui-kit-chat/src/styled/attachment-ui.stories.tsx
index 757c562b5..3705e911f 100644
--- a/packages/ui-kit-chat/src/styled/attachment-ui.stories.tsx
+++ b/packages/ui-kit-chat/src/styled/attachment-ui.stories.tsx
@@ -35,7 +35,9 @@ function Frame(): JSX.Element {
value={{
attachments,
attachmentAdapter: () => undefined,
- addAttachment: async () => {},
+ addAttachment: async () => null,
+ hasAttachment: () => false,
+ replaceAttachment: async () => null,
removeAttachment: async (id) => {
setAttachments((prev) => prev.filter((attachment) => attachment.id !== id))
},
diff --git a/packages/ui-kit-chat/test/composer-attachment-ops.browser.test.tsx b/packages/ui-kit-chat/test/composer-attachment-ops.browser.test.tsx
new file mode 100644
index 000000000..be9d06de9
--- /dev/null
+++ b/packages/ui-kit-chat/test/composer-attachment-ops.browser.test.tsx
@@ -0,0 +1,164 @@
+import 'virtual:uno.css'
+import {render} from '@solidjs/testing-library'
+import {untrack, type JSX, type ParentProps} from 'solid-js'
+import {expect, it} from 'vitest'
+import {useChat} from '@tanstack/ai-solid'
+import {ChatProvider} from '../src/store/chat-context.js'
+import {storyConnection} from '../src/store/story-connection.js'
+import {Composer as ComposerPrimitive} from '../src/primitives/composer/composer.js'
+import {ComposerHandlersProvider} from '../src/primitives/composer/composer-handlers.js'
+import {useComposerContext, type ComposerContextValue} from '../src/primitives/composer/composer-context.js'
+import {
+ createTextAttachmentAdapter,
+ type Attachment,
+ type AttachmentAdapter,
+ type PendingAttachment,
+} from '../src/primitives/attachment/attachment-adapter.js'
+
+function textFile(body: string, name: string): File {
+ return new File([body], name, {type: 'text/plain'})
+}
+
+function trackedAdapter(onRemove?: (attachment: Attachment) => void): AttachmentAdapter {
+ const adapter = createTextAttachmentAdapter()
+ return {
+ ...adapter,
+ remove: async (attachment) => {
+ onRemove?.(attachment)
+ await adapter.remove(attachment)
+ },
+ }
+}
+
+function ChatHost(props: ParentProps): JSX.Element {
+ const chat = useChat({connection: storyConnection(untrack(() => undefined))})
+ return (
+
+ {props.children}
+
+ )
+}
+
+function ContextProbe(props: {onReady: (context: ComposerContextValue) => void}): JSX.Element {
+ const context = useComposerContext()
+ untrack(() => props.onReady(context))
+ return null
+}
+
+function mountComposer(options: {onAdapterRemove?: (attachment: Attachment) => void} = {}): ComposerContextValue {
+ let captured: ComposerContextValue | undefined
+ render(() => (
+
+
+
+ {
+ captured = context
+ }}
+ />
+
+
+ ))
+ if (!captured) throw new Error('expected the composer context to be captured')
+ return captured
+}
+
+it('addAttachment resolves the id of the attachment it created', async () => {
+ const composer = mountComposer()
+
+ const id = await composer.addAttachment(textFile('one', 'one.txt'))
+
+ expect(id).not.toBeNull()
+ expect(composer.hasAttachment(id ?? '')).toBe(true)
+})
+
+it('hasAttachment stops reporting an attachment once it is removed', async () => {
+ const composer = mountComposer()
+ const id = await composer.addAttachment(textFile('one', 'one.txt'))
+ if (!id) throw new Error('expected the attachment to be added')
+
+ await composer.removeAttachment(id)
+
+ expect(composer.hasAttachment(id)).toBe(false)
+})
+
+it('replaceAttachment swaps the payload in place and keeps its position', async () => {
+ const composer = mountComposer()
+ const first = await composer.addAttachment(textFile('one', 'one.txt'))
+ const second = await composer.addAttachment(textFile('two', 'two.txt'))
+ if (!first || !second) throw new Error('expected both attachments to be added')
+
+ const replaced = await composer.replaceAttachment(first, textFile('uno', 'uno.txt'))
+
+ expect(replaced).not.toBeNull()
+ expect(composer.attachments().map((entry) => entry.name)).toEqual(['uno.txt', 'two.txt'])
+})
+
+it('replaceAttachment is a no-op once the attachment is gone', async () => {
+ const composer = mountComposer()
+ const id = await composer.addAttachment(textFile('one', 'one.txt'))
+ if (!id) throw new Error('expected the attachment to be added')
+ await composer.removeAttachment(id)
+
+ expect(await composer.replaceAttachment(id, textFile('uno', 'uno.txt'))).toBeNull()
+ expect(composer.attachments()).toHaveLength(0)
+})
+
+it('replaceAttachment releases the displaced attachment through the adapter', async () => {
+ const removed: string[] = []
+ const composer = mountComposer({onAdapterRemove: (attachment) => removed.push(attachment.name)})
+ const id = await composer.addAttachment(textFile('one', 'one.txt'))
+ if (!id) throw new Error('expected the attachment to be added')
+
+ await composer.replaceAttachment(id, textFile('uno', 'uno.txt'))
+
+ expect(removed).toEqual(['one.txt'])
+ expect(composer.attachments()).toHaveLength(1)
+})
+
+it('keeps the in-flight replacement out of the attachment list until the swap', async () => {
+ const seenBetweenEmissions: number[] = []
+ let composer: ComposerContextValue | undefined
+ let nextId = 0
+ const watchingAdapter: AttachmentAdapter = {
+ accept: 'text/plain',
+ add: async function* ({file}) {
+ nextId += 1
+ const pending: PendingAttachment = {
+ id: `watched-${nextId}`,
+ type: 'document',
+ name: file.name,
+ contentType: file.type,
+ file,
+ status: {type: 'running', reason: 'uploading', progress: 0},
+ }
+ yield pending
+ seenBetweenEmissions.push(composer?.attachments().length ?? -1)
+ yield {...pending, status: {type: 'requires-action', reason: 'composer-send'}}
+ },
+ remove: async () => {},
+ send: async (attachment) => ({...attachment, status: {type: 'complete'}, content: []}),
+ }
+ let captured: ComposerContextValue | undefined
+ render(() => (
+
+
+
+ {
+ captured = context
+ }}
+ />
+
+
+ ))
+ if (!captured) throw new Error('expected the composer context to be captured')
+ composer = captured
+ const id = await composer.addAttachment(textFile('one', 'one.txt'))
+ if (!id) throw new Error('expected the attachment to be added')
+
+ await composer.replaceAttachment(id, textFile('uno', 'uno.txt'))
+
+ expect(seenBetweenEmissions).toEqual([1, 1])
+ expect(composer.attachments().map((entry) => entry.name)).toEqual(['uno.txt'])
+})
From 89d49ba596767732667ec4df4626a45077878067 Mon Sep 17 00:00:00 2001
From: Omri Katz <9701896+omridevk@users.noreply.github.com>
Date: Sat, 15 Aug 2026 04:38:37 +0300
Subject: [PATCH 09/22] feat(conciv): #487 grab staging module over composer
attachments
Co-Authored-By: Claude Fable 5
---
apps/conciv/src/pane/grab-staging.ts | 166 +++++++++++
.../test/grab-staging-module.browser.test.ts | 260 ++++++++++++++++++
packages/grab/src/grab-attachment.ts | 2 +-
packages/page/src/index.ts | 1 +
4 files changed, 428 insertions(+), 1 deletion(-)
create mode 100644 apps/conciv/src/pane/grab-staging.ts
create mode 100644 apps/conciv/test/grab-staging-module.browser.test.ts
diff --git a/apps/conciv/src/pane/grab-staging.ts b/apps/conciv/src/pane/grab-staging.ts
new file mode 100644
index 000000000..56c9edd9f
--- /dev/null
+++ b/apps/conciv/src/pane/grab-staging.ts
@@ -0,0 +1,166 @@
+import {createSignal} from 'solid-js'
+import {
+ GRAB_MIME,
+ grabToFile,
+ grabToPayload,
+ MAX_PAYLOAD_BYTES,
+ parseGrabPayload,
+ type GrabPayload,
+} from '@conciv/grab/grab-attachment'
+import type {Grab} from '@conciv/grab'
+import {fitImagePreview} from '@conciv/page'
+import type {AttachmentState} from '@conciv/ui-kit-chat'
+
+export type ComposerGrabPort = {
+ attachments: () => readonly AttachmentState[]
+ addAttachment: (file: File) => Promise
+ replaceAttachment: (id: string, file: File) => Promise
+ removeAttachment: (id: string) => Promise
+ hasAttachment: (id: string) => boolean
+}
+
+export type GrabStaging = {
+ stage: (grab: Grab) => void
+ staged: () => readonly Grab[]
+ clear: () => void
+ reconcile: (attachments: readonly AttachmentState[]) => void
+ connect: (port: ComposerGrabPort) => void
+ disconnect: () => void
+}
+
+type Deps = {
+ ground: (grab: Grab) => Promise
+}
+
+type StagedEntry = {grab: Grab; id: string | null; grounded: Grab | null; placing: Promise | null}
+
+function toGrab(payload: GrabPayload): Grab {
+ return {
+ text: payload.text,
+ ...(payload.snippet === undefined ? {} : {snippet: payload.snippet}),
+ preview: payload.preview ?? {kind: 'dom', html: '', width: 0, height: 0},
+ source: payload.source,
+ rect: payload.rect,
+ }
+}
+
+function isGrabAttachment(attachment: AttachmentState): boolean {
+ return attachment.contentType === GRAB_MIME
+}
+
+async function fitted(grab: Grab): Promise {
+ if (grab.preview.kind !== 'image') return grab
+ return {...grab, preview: await fitImagePreview(grab.preview, MAX_PAYLOAD_BYTES)}
+}
+
+export function makeGrabStaging(deps: Deps): GrabStaging {
+ const [payloads, setPayloads] = createSignal>(new Map())
+ const [pending, setPending] = createSignal([])
+ const [port, setPort] = createSignal(null)
+
+ const remember = (id: string, payload: GrabPayload): void => {
+ setPayloads((current) => new Map(current).set(id, payload))
+ }
+
+ const forget = (id: string): void => {
+ setPayloads((current) => new Map([...current].filter(([known]) => known !== id)))
+ }
+
+ const drop = (entry: StagedEntry): void => {
+ setPending((current) => current.filter((candidate) => candidate !== entry))
+ }
+
+ const putOnComposer = async (entry: StagedEntry, composer: ComposerGrabPort): Promise => {
+ const prepared = await fitted(entry.grounded ?? entry.grab)
+ const id = await composer.addAttachment(grabToFile(prepared))
+ entry.id = id
+ if (id) remember(id, grabToPayload(prepared))
+ if (entry.grounded === null) return
+ drop(entry)
+ }
+
+ const ensureOnComposer = (entry: StagedEntry): Promise => {
+ const composer = port()
+ if (!composer || entry.id !== null) return Promise.resolve()
+ if (entry.placing) return entry.placing
+ entry.placing = putOnComposer(entry, composer)
+ return entry.placing
+ }
+
+ const settle = async (entry: StagedEntry): Promise => {
+ const composer = port()
+ if (!composer || entry.id === null || entry.grounded === null) return
+ const prepared = await fitted(entry.grounded)
+ const replaced = await composer.replaceAttachment(entry.id, grabToFile(prepared))
+ forget(entry.id)
+ if (replaced) remember(replaced, grabToPayload(prepared))
+ drop(entry)
+ }
+
+ const ground = async (entry: StagedEntry): Promise => {
+ entry.grounded = await deps.ground(entry.grab)
+ if (entry.grounded === null) {
+ if (entry.id !== null) drop(entry)
+ return
+ }
+ await settle(entry)
+ }
+
+ const placeThenGround = async (entry: StagedEntry): Promise => {
+ await ensureOnComposer(entry)
+ await ground(entry)
+ }
+
+ const stage = (grab: Grab): void => {
+ const entry: StagedEntry = {grab, id: null, grounded: null, placing: null}
+ setPending((current) => [...current, entry])
+ void placeThenGround(entry)
+ }
+
+ const staged = (): readonly Grab[] => {
+ const composer = port()
+ const known = payloads()
+ const placed = (composer?.attachments() ?? []).filter(isGrabAttachment).flatMap((attachment) => {
+ const payload = known.get(attachment.id)
+ return payload ? [toGrab(payload)] : []
+ })
+ const waiting = pending().filter((entry) => entry.id === null)
+ return [...placed, ...waiting.map((entry) => entry.grounded ?? entry.grab)]
+ }
+
+ const clear = (): void => {
+ setPending([])
+ const composer = port()
+ if (!composer) return
+ for (const attachment of composer.attachments().filter(isGrabAttachment))
+ void composer.removeAttachment(attachment.id)
+ }
+
+ const hydrate = async (attachment: AttachmentState, file: File): Promise => {
+ const payload = parseGrabPayload(await file.text())
+ const stillLive = (port()?.attachments() ?? []).some((entry) => entry.id === attachment.id)
+ if (payload && stillLive) remember(attachment.id, payload)
+ }
+
+ const reconcile = (attachments: readonly AttachmentState[]): void => {
+ const grabs = attachments.filter(isGrabAttachment)
+ const live = new Set(grabs.map((attachment) => attachment.id))
+ setPayloads((current) => new Map([...current].filter(([id]) => live.has(id))))
+ for (const attachment of grabs) {
+ const file = attachment.file
+ if (payloads().has(attachment.id) || !file) continue
+ void hydrate(attachment, file)
+ }
+ }
+
+ const connect = (next: ComposerGrabPort): void => {
+ setPort(() => next)
+ for (const entry of pending().filter((candidate) => candidate.id === null)) void ensureOnComposer(entry)
+ }
+
+ const disconnect = (): void => {
+ setPort(null)
+ }
+
+ return {stage, staged, clear, reconcile, connect, disconnect}
+}
diff --git a/apps/conciv/test/grab-staging-module.browser.test.ts b/apps/conciv/test/grab-staging-module.browser.test.ts
new file mode 100644
index 000000000..a35e78ffe
--- /dev/null
+++ b/apps/conciv/test/grab-staging-module.browser.test.ts
@@ -0,0 +1,260 @@
+import {createEffect, createRoot} from 'solid-js'
+import {expect, it} from 'vitest'
+import {GRAB_MIME, grabToFile, MAX_PAYLOAD_BYTES, parseGrabPayload} from '@conciv/grab/grab-attachment'
+import type {Grab} from '@conciv/grab'
+import type {AttachmentState} from '@conciv/ui-kit-chat'
+import {makeGrabStaging, type ComposerGrabPort, type GrabStaging} from '../src/pane/grab-staging.js'
+
+const GRAB: Grab = {
+ text: 'Payroll Deposit
at src/routes/index.tsx:12:9',
+ snippet: 'Payroll Deposit
',
+ preview: {kind: 'dom', html: 'Payroll Deposit clone
', width: 200, height: 40},
+ source: {componentName: 'Hero', filePath: 'src/routes/index.tsx', lineNumber: 12},
+ rect: {x: 0, y: 0, width: 200, height: 40},
+}
+
+const GROUNDED: Grab = {...GRAB, text: 'Payroll Deposit
at src/routes/hero.tsx:4:1'}
+
+type FakeComposer = ComposerGrabPort & {list: () => AttachmentState[]; nextAdd: () => Promise}
+
+function fakeComposer(): FakeComposer {
+ const entries: AttachmentState[] = []
+ const addWaiters: ((attachment: AttachmentState) => void)[] = []
+ let nextId = 0
+ const entryFor = (id: string, file: File): AttachmentState => ({
+ id,
+ type: 'document',
+ name: file.name,
+ contentType: file.type,
+ file,
+ status: {type: 'requires-action', reason: 'composer-send'},
+ })
+ return {
+ list: () => entries,
+ nextAdd: () => new Promise((resolve) => addWaiters.push(resolve)),
+ attachments: () => entries,
+ hasAttachment: (id) => entries.some((entry) => entry.id === id),
+ addAttachment: async (file) => {
+ nextId += 1
+ const id = `attachment-${nextId}`
+ const added = entryFor(id, file)
+ entries.push(added)
+ addWaiters.splice(0).forEach((resolve) => resolve(added))
+ return id
+ },
+ replaceAttachment: async (id, file) => {
+ const position = entries.findIndex((entry) => entry.id === id)
+ if (position < 0) return null
+ nextId += 1
+ const next = `attachment-${nextId}`
+ entries.splice(position, 1, entryFor(next, file))
+ return next
+ },
+ removeAttachment: async (id) => {
+ const position = entries.findIndex((entry) => entry.id === id)
+ if (position >= 0) entries.splice(position, 1)
+ },
+ }
+}
+
+async function bodyOf(attachment: AttachmentState): Promise {
+ if (!attachment.file) throw new Error('expected the attachment to carry a file')
+ return attachment.file.text()
+}
+
+async function grabTextOf(attachment: AttachmentState): Promise {
+ return parseGrabPayload(await bodyOf(attachment))?.text
+}
+
+function imageGrab(width: number, height: number): Grab {
+ const canvas = document.createElement('canvas')
+ canvas.width = width
+ canvas.height = height
+ const context = canvas.getContext('2d')
+ if (!context) throw new Error('expected a canvas context')
+ const pixels = context.createImageData(width, height)
+ let noise = 1
+ for (let index = 0; index < pixels.data.length; index += 1) {
+ noise = (noise * 1_103_515_245 + 12_345) % 2_147_483_648
+ pixels.data[index] = index % 4 === 3 ? 255 : noise % 256
+ }
+ context.putImageData(pixels, 0, 0)
+ return {...GRAB, preview: {kind: 'image', dataUrl: canvas.toDataURL('image/png'), width, height}}
+}
+
+function stagedWhen(staging: GrabStaging, count: number): Promise {
+ let release = () => {}
+ const settledStaged = new Promise((resolve) => {
+ createRoot((disposeRoot) => {
+ release = disposeRoot
+ createEffect(() => {
+ const value = staging.staged()
+ if (value.length === count) resolve(value)
+ })
+ })
+ })
+ return settledStaged.finally(() => release())
+}
+
+function neverGrounds(): Promise {
+ return Promise.resolve(null)
+}
+
+async function settled(): Promise {
+ for (let turn = 0; turn < 12; turn += 1) await Promise.resolve()
+}
+
+it('attaches optimistically and replaces the payload once grounding resolves', async () => {
+ const composer = fakeComposer()
+ const staging = makeGrabStaging({ground: async () => GROUNDED})
+ staging.connect(composer)
+
+ staging.stage(GRAB)
+ await settled()
+
+ expect(composer.list()).toHaveLength(1)
+ expect(await grabTextOf(composer.list()[0] ?? never())).toBe(GROUNDED.text)
+})
+
+it('keeps the optimistic attachment when grounding yields nothing', async () => {
+ const composer = fakeComposer()
+ const staging = makeGrabStaging({ground: neverGrounds})
+ staging.connect(composer)
+
+ staging.stage(GRAB)
+ await settled()
+
+ expect(composer.list()).toHaveLength(1)
+ expect(await grabTextOf(composer.list()[0] ?? never())).toBe(GRAB.text)
+})
+
+it('attaches a grab staged before the composer connected, carrying its grounded payload', async () => {
+ const composer = fakeComposer()
+ const staging = makeGrabStaging({ground: async () => GROUNDED})
+
+ staging.stage(GRAB)
+ await settled()
+ expect(composer.list()).toHaveLength(0)
+ expect(staging.staged().map((grab) => grab.text)).toEqual([GROUNDED.text])
+
+ staging.connect(composer)
+ await settled()
+
+ expect(composer.list()).toHaveLength(1)
+ expect(await grabTextOf(composer.list()[0] ?? never())).toBe(GROUNDED.text)
+})
+
+it('attaches a grab staged before the composer connected even when grounding yields nothing', async () => {
+ const composer = fakeComposer()
+ const staging = makeGrabStaging({ground: neverGrounds})
+
+ staging.stage(GRAB)
+ await settled()
+ staging.connect(composer)
+ await settled()
+
+ expect(composer.list()).toHaveLength(1)
+ expect(await grabTextOf(composer.list()[0] ?? never())).toBe(GRAB.text)
+})
+
+it('reports a staged grab reactively once its payload is known', async () => {
+ const composer = fakeComposer()
+ const staging = makeGrabStaging({ground: neverGrounds})
+ staging.connect(composer)
+ const seen: number[] = []
+ const dispose = createRoot((disposeRoot) => {
+ createEffect(() => seen.push(staging.staged().length))
+ return disposeRoot
+ })
+
+ staging.stage(GRAB)
+ await settled()
+
+ expect(seen.at(-1)).toBe(1)
+ dispose()
+})
+
+it('clears only the grab attachments', async () => {
+ const composer = fakeComposer()
+ const staging = makeGrabStaging({ground: neverGrounds})
+ staging.connect(composer)
+ await composer.addAttachment(new File(['hello'], 'notes.txt', {type: 'text/plain'}))
+ staging.stage(GRAB)
+ await settled()
+
+ staging.clear()
+ await settled()
+
+ expect(composer.list().map((entry) => entry.name)).toEqual(['notes.txt'])
+ expect(staging.staged()).toHaveLength(0)
+})
+
+it('hydrates a restored grab attachment and prunes one that is gone', async () => {
+ const composer = fakeComposer()
+ const staging = makeGrabStaging({ground: neverGrounds})
+ staging.connect(composer)
+ const restored = await composer.addAttachment(grabToFile(GRAB))
+ if (!restored) throw new Error('expected the restored attachment to be added')
+
+ staging.reconcile(composer.attachments())
+
+ expect((await stagedWhen(staging, 1)).map((grab) => grab.text)).toEqual([GRAB.text])
+
+ await composer.removeAttachment(restored)
+ staging.reconcile(composer.attachments())
+
+ expect(await stagedWhen(staging, 0)).toHaveLength(0)
+})
+
+it('does not hydrate a payload whose attachment was removed while it decoded', async () => {
+ const composer = fakeComposer()
+ const staging = makeGrabStaging({ground: neverGrounds})
+ staging.connect(composer)
+ const body = grabToFile(GRAB)
+ const restored = await composer.addAttachment(body)
+ if (!restored) throw new Error('expected the restored attachment to be added')
+
+ staging.reconcile(composer.attachments())
+ await composer.removeAttachment(restored)
+ await body.text()
+ await settled()
+
+ expect(staging.staged()).toHaveLength(0)
+})
+
+it('fits an oversized image preview into the payload budget instead of dropping it', async () => {
+ const composer = fakeComposer()
+ const oversized = imageGrab(1600, 1600)
+ const preview = oversized.preview
+ if (preview.kind !== 'image') throw new Error('expected an image preview')
+ expect(new TextEncoder().encode(preview.dataUrl).length).toBeGreaterThan(MAX_PAYLOAD_BYTES)
+ const staging = makeGrabStaging({ground: neverGrounds})
+ staging.connect(composer)
+ const added = composer.nextAdd()
+
+ staging.stage(oversized)
+
+ const payload = parseGrabPayload(await bodyOf(await added))
+ expect(payload?.preview?.kind).toBe('image')
+ expect(
+ payload?.preview?.kind === 'image' ? new TextEncoder().encode(payload.preview.dataUrl).length : 0,
+ ).toBeLessThanOrEqual(MAX_PAYLOAD_BYTES)
+})
+
+it('stages through a port that arrives and leaves again', async () => {
+ const composer = fakeComposer()
+ const staging: GrabStaging = makeGrabStaging({ground: neverGrounds})
+ staging.connect(composer)
+ staging.stage(GRAB)
+ await settled()
+
+ staging.disconnect()
+
+ expect(staging.staged()).toHaveLength(0)
+ expect(composer.list()).toHaveLength(1)
+ expect(composer.list()[0]?.contentType).toBe(GRAB_MIME)
+})
+
+function never(): never {
+ throw new Error('expected an attachment')
+}
diff --git a/packages/grab/src/grab-attachment.ts b/packages/grab/src/grab-attachment.ts
index e96c13b11..ffe7e24f9 100644
--- a/packages/grab/src/grab-attachment.ts
+++ b/packages/grab/src/grab-attachment.ts
@@ -9,7 +9,7 @@ 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)
+export const MAX_PAYLOAD_BYTES = Math.floor(MAX_PERSISTED_BASE64_CHARACTERS * BASE64_BYTES_PER_CHARACTER)
const ElementSourceSchema = z.object({
componentName: z.string().nullable(),
diff --git a/packages/page/src/index.ts b/packages/page/src/index.ts
index 7c1f1060d..71de28c75 100644
--- a/packages/page/src/index.ts
+++ b/packages/page/src/index.ts
@@ -5,6 +5,7 @@ import {makeDomPageDriver, type PageDriver} from './page-driver.js'
export {makeDomPageDriver, type PageDriver} from './page-driver.js'
export {grabApi} from './grab-api.js'
+export {fitImagePreview} from './grab-fit.js'
export {picking, cancelPick} from './react-grab/picking.js'
export {getReactGrabAdapter, type ReactGrabAdapter} from './react-grab/adapter.js'
export {describe, locate, installReactBridge, rootFibers} from './react-bridge.js'
From beb21b1f2df96bc93681c67f7037d29ee4e905a4 Mon Sep 17 00:00:00 2001
From: Omri Katz <9701896+omridevk@users.noreply.github.com>
Date: Sat, 15 Aug 2026 11:27:02 +0300
Subject: [PATCH 10/22] feat(conciv): #487 stage grabs as attachments and
delete the grab store
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
PaneGrabStore, GrabStrip and GrabReference are gone. GrabApi.stage/staged/clear
now operate on composer attachments through makeGrabStaging, so a grab has one
representation whether it came from the composer, an extension, or a restored
draft. use-pane-messaging drops its grab branch entirely; the composer's own
clear-and-restore already empties and restores staged grabs, so no post-send
clearing hook was added.
Retires #481's resizable grabs strip, per owner decision: the separator handle
and its two tests (resize through the shared separator handle, come back into
the flow once the panel has room again) go away, and the card dialog is the
replacement affordance for viewing a grab at full size. #481's actual promise —
staged grabs never push the composer input off-screen — is kept by two new
mechanisms, each covered by a test: the grab card clamps its own preview, and
the composer's staged-attachment region is a capped scroller.
The #478 behaviours are re-pinned on the attachment lifecycle: restored on
mount, cleared on send, restored when the server refuses, restored when the
transport throws, and no cross-restore between a failed turn and a queued one.
Co-Authored-By: Claude Fable 5
---
apps/conciv/src/app/pane-context.ts | 32 +------
apps/conciv/src/app/pane-provider.tsx | 6 +-
apps/conciv/src/extension/create-instances.ts | 21 ++++
apps/conciv/src/extension/pane-grab.ts | 10 +-
apps/conciv/src/pane/chat-pane.tsx | 39 +++-----
apps/conciv/src/pane/grab-reference.tsx | 96 -------------------
apps/conciv/src/pane/grab-strip.tsx | 64 -------------
apps/conciv/src/pane/pane-composer.tsx | 2 +-
apps/conciv/src/pane/use-pane-messaging.ts | 17 +---
apps/conciv/src/router.tsx | 20 +---
.../src/routes/panel.$sessionId.$view.tsx | 13 +--
apps/conciv/src/routes/panel.$sessionId.tsx | 10 +-
apps/conciv/test/chat-pane.browser.test.tsx | 70 ++++++++++----
.../test/grab-reference.browser.test.tsx | 45 ---------
apps/conciv/test/helpers/core-session.ts | 5 +-
apps/conciv/test/helpers/grab-fixtures.ts | 46 +++++++++
apps/conciv/test/helpers/pane-harness.tsx | 25 +++--
.../test/panel-min-height.browser.test.tsx | 44 ++++-----
.../page/src/client/cards/grab-card.tsx | 2 +-
19 files changed, 189 insertions(+), 378 deletions(-)
create mode 100644 apps/conciv/src/extension/create-instances.ts
delete mode 100644 apps/conciv/src/pane/grab-reference.tsx
delete mode 100644 apps/conciv/src/pane/grab-strip.tsx
delete mode 100644 apps/conciv/test/grab-reference.browser.test.tsx
create mode 100644 apps/conciv/test/helpers/grab-fixtures.ts
diff --git a/apps/conciv/src/app/pane-context.ts b/apps/conciv/src/app/pane-context.ts
index bfa8db8ef..fee08a602 100644
--- a/apps/conciv/src/app/pane-context.ts
+++ b/apps/conciv/src/app/pane-context.ts
@@ -1,30 +1,6 @@
-import {createContext, createSignal, useContext, type Accessor} from 'solid-js'
-import type {Grab, GrabProvider} from '@conciv/grab'
-
-export type StagedGrab = Grab | {text: string}
-
-export type PaneGrabStore = {
- grabs: Accessor
- stage: (grab: Grab) => void
- stageTexts: (texts: string[]) => void
- stageAll: (grabs: StagedGrab[]) => void
- replace: (grab: StagedGrab, next: StagedGrab) => void
- remove: (grab: StagedGrab) => void
- clear: () => void
-}
-
-export function makeGrabStore(): PaneGrabStore {
- const [grabs, setGrabs] = createSignal([])
- return {
- grabs,
- stage: (grab) => setGrabs((prev) => [...prev, grab]),
- stageTexts: (texts) => setGrabs(texts.map((text) => ({text}))),
- stageAll: (staged) => setGrabs((prev) => [...prev, ...staged.filter((grab) => !prev.includes(grab))]),
- replace: (grab, next) => setGrabs((prev) => prev.map((entry) => (entry === grab ? next : entry))),
- remove: (grab) => setGrabs((prev) => prev.filter((entry) => entry !== grab)),
- clear: () => setGrabs([]),
- }
-}
+import {createContext, useContext, type Accessor} from 'solid-js'
+import type {GrabProvider} from '@conciv/grab'
+import type {GrabStaging} from '../pane/grab-staging.js'
export type PendingAttachmentQueue = {
enqueue: (file: File) => void
@@ -38,7 +14,7 @@ export type PaneContextValue = {
setLockedFor: (id: string) => (locked: boolean) => void
slideClass: Accessor
resetSlide: () => void
- grabStore: PaneGrabStore
+ grabStaging: GrabStaging
grabProvider: GrabProvider | undefined
attachments: PendingAttachmentQueue
newSession: () => void
diff --git a/apps/conciv/src/app/pane-provider.tsx b/apps/conciv/src/app/pane-provider.tsx
index 936640180..ba5760b98 100644
--- a/apps/conciv/src/app/pane-provider.tsx
+++ b/apps/conciv/src/app/pane-provider.tsx
@@ -2,7 +2,9 @@ import {type JSX} from 'solid-js'
import {useQuery} from '@tanstack/solid-query'
import {useRouter} from '@tanstack/solid-router'
import {useAppData, useGrabProvider, useRpc} from './context.js'
-import {PaneContext, makeGrabStore, makePendingAttachmentQueue, type PaneContextValue} from './pane-context.js'
+import {PaneContext, makePendingAttachmentQueue, type PaneContextValue} from './pane-context.js'
+import {makeGrabStaging} from '../pane/grab-staging.js'
+import {resolveGrabSource} from '../pane/grab-source-resolve.js'
export function PaneProvider(props: {
sessionId: string
@@ -30,7 +32,7 @@ export function PaneProvider(props: {
setLockedFor: () => () => {},
slideClass: () => '',
resetSlide: () => {},
- grabStore: makeGrabStore(),
+ grabStaging: makeGrabStaging({ground: (grab) => resolveGrabSource(grab, (input) => rpc.page.symbolicate(input))}),
grabProvider,
attachments: makePendingAttachmentQueue(),
newSession: () => {
diff --git a/apps/conciv/src/extension/create-instances.ts b/apps/conciv/src/extension/create-instances.ts
new file mode 100644
index 000000000..8e9b8e09b
--- /dev/null
+++ b/apps/conciv/src/extension/create-instances.ts
@@ -0,0 +1,21 @@
+import type {AnyExtension} from '@conciv/extension'
+import type {ExtensionInstance} from './extension-slots.js'
+
+export function createInstances(extensions: AnyExtension[]): ExtensionInstance[] {
+ return extensions.flatMap((extension) => {
+ try {
+ const result = extension.__client?.()
+ return [
+ {
+ extension,
+ clientValue: result?.value ?? {},
+ effects: result?.effects ?? [],
+ dispose: result?.dispose ?? (() => {}),
+ },
+ ]
+ } catch (error) {
+ console.error(`[conciv] extension "${extension.name}" failed to mount`, error)
+ return []
+ }
+ })
+}
diff --git a/apps/conciv/src/extension/pane-grab.ts b/apps/conciv/src/extension/pane-grab.ts
index 9a0295ad8..0c13f8ba8 100644
--- a/apps/conciv/src/extension/pane-grab.ts
+++ b/apps/conciv/src/extension/pane-grab.ts
@@ -1,8 +1,8 @@
import type {GrabApi, GrabProvider} from '@conciv/grab'
import {grabApi as pageGrabApi} from '@conciv/page'
-import type {PaneGrabStore} from '../app/pane-context.js'
+import type {GrabStaging} from '../pane/grab-staging.js'
-export function makePaneGrabApi(store: PaneGrabStore, provider?: GrabProvider): GrabApi {
+export function makePaneGrabApi(staging: GrabStaging, provider?: GrabProvider): GrabApi {
const actions = provider?.() ?? pageGrabApi
return {
pick: actions.pick,
@@ -10,8 +10,8 @@ export function makePaneGrabApi(store: PaneGrabStore, provider?: GrabProvider):
cancel: actions.cancel,
isActive: actions.isActive,
grabbable: actions.grabbable,
- stage: store.stage,
- staged: () => store.grabs().flatMap((entry) => ('preview' in entry ? [entry] : [])),
- clear: store.clear,
+ stage: staging.stage,
+ staged: staging.staged,
+ clear: staging.clear,
}
}
diff --git a/apps/conciv/src/pane/chat-pane.tsx b/apps/conciv/src/pane/chat-pane.tsx
index 0340bc2ae..08e0ebb95 100644
--- a/apps/conciv/src/pane/chat-pane.tsx
+++ b/apps/conciv/src/pane/chat-pane.tsx
@@ -37,15 +37,12 @@ import {collectToolRenderers} from '@conciv/extension'
import {HostApiProvider} from '@conciv/extension/host'
import type {Grab} from '@conciv/grab'
import {paneAttachments} from './pane-attachments.js'
-import {resolveGrabSource} from './grab-source-resolve.js'
import {useAnnounce, useAppData, useConnected, useInstances, useRpc} from '../app/context.js'
import {usePanelComposerFocus} from '../app/panel-focus.js'
-import {usePane, type StagedGrab} from '../app/pane-context.js'
+import {usePane} from '../app/pane-context.js'
import {foldToolDurations} from './tool-durations.js'
import {ToolFallbackCard} from './tool-fallback-card.js'
import {useComposerTriggerSources} from './trigger-sources.js'
-import {GrabReference} from './grab-reference.js'
-import {GrabStrip} from './grab-strip.js'
import {CompactSpinner, ConversationSkeleton, Divider, ThinkingBubble} from './indicators.js'
import {ComposerActionsPending} from '../shell/pending.js'
import {EmptyStateSlot} from '../shell/empty-state.js'
@@ -96,10 +93,6 @@ function activeCallTitle(
return title
}
-function grabTexts(grabs: ReadonlyArray): string[] {
- return grabs.map((grab) => grab.text)
-}
-
type ComposerApi = {
addAttachment: (file: File) => Promise
}
@@ -108,12 +101,18 @@ function ComposerWiring(props: {onReady: (api: ComposerApi) => void}): JSX.Eleme
const pane = usePane()
const context = useComposerContext()
onMount(() => {
- const restored = context.grabs()
- if (restored.length > 0) pane.grabStore.stageTexts(restored)
+ pane.grabStaging.connect({
+ attachments: context.attachments,
+ addAttachment: context.addAttachment,
+ replaceAttachment: context.replaceAttachment,
+ removeAttachment: context.removeAttachment,
+ hasAttachment: context.hasAttachment,
+ })
props.onReady({addAttachment: context.addAttachment})
for (const file of pane.attachments.drain()) void context.addAttachment(file)
})
- createEffect(() => context.setGrabs(grabTexts(pane.grabStore.grabs())))
+ onCleanup(() => pane.grabStaging.disconnect())
+ createEffect(() => pane.grabStaging.reconcile(context.attachments()))
return <>>
}
@@ -167,7 +166,6 @@ export function ChatPane(props: {sessionId: string}): JSX.Element {
appData,
reachability,
notices,
- grabStore: pane.grabStore,
refetchMarkers: () => markers.refetch(),
})
@@ -240,17 +238,11 @@ export function ChatPane(props: {sessionId: string}): JSX.Element {
const PaneAttachment = (slotProps: {removable?: boolean}): JSX.Element => (
)
- const groundGrab = async (grab: Grab): Promise => {
- const grounded = await resolveGrabSource(grab, (input) => rpc.page.symbolicate(input))
- if (!grounded) return
- pane.grabStore.replace(grab, grounded)
- }
const stageGrab = (grab: Grab) => {
- pane.grabStore.stage(grab)
+ pane.grabStaging.stage(grab)
focusInput()
- void groundGrab(grab)
}
- const paneGrab = makePaneGrabApi(pane.grabStore, pane.grabProvider)
+ const paneGrab = makePaneGrabApi(pane.grabStaging, pane.grabProvider)
const dividersAt = (count: number): MarkerRow[] => (markers.data ?? []).filter((row) => row.afterTurn === count)
const dividersInRange = (start: number, end: number): MarkerRow[] =>
@@ -333,13 +325,6 @@ export function ChatPane(props: {sessionId: string}): JSX.Element {
- 0}>
-
-
- {(grab) => pane.grabStore.remove(grab)} />}
-
-
-
{(storage) => (
diff --git a/apps/conciv/src/pane/grab-reference.tsx b/apps/conciv/src/pane/grab-reference.tsx
deleted file mode 100644
index f68defa26..000000000
--- a/apps/conciv/src/pane/grab-reference.tsx
+++ /dev/null
@@ -1,96 +0,0 @@
-import {Show, type JSX} from 'solid-js'
-import X from 'lucide-solid/icons/x'
-import {TooltipIconButton} from '@conciv/ui-kit-system'
-import {sourceLabel, type GrabPreview, type Grab} from '@conciv/grab'
-
-function domPreview(preview: GrabPreview): Extract | null {
- return preview.kind === 'dom' ? preview : null
-}
-
-function imagePreview(preview: GrabPreview): Extract | null {
- return preview.kind === 'image' ? preview : null
-}
-
-function ScaledSnapshot(props: {preview: GrabPreview}): JSX.Element {
- return (
-
- {(preview) => (
-
- )}
-
- }
- >
- {(preview) => (
-
- )}
-
- )
-}
-
-function stagedGrab(grab: Grab | {text: string}): Grab | null {
- return 'preview' in grab ? grab : null
-}
-
-export function GrabReference(props: {grab: Grab | {text: string}; onRemove: () => void}): JSX.Element {
- return (
-
- props.onRemove()}
- >
-
-
- {props.grab.text}}
- >
- {(grab) => (
- <>
-
-
- {(source) => (
-
- {(label) => (
-
-
- ↳
- {' '}
- in {label()}
-
- )}
-
- )}
-
- >
- )}
-
-
- )
-}
diff --git a/apps/conciv/src/pane/grab-strip.tsx b/apps/conciv/src/pane/grab-strip.tsx
deleted file mode 100644
index 3944b5955..000000000
--- a/apps/conciv/src/pane/grab-strip.tsx
+++ /dev/null
@@ -1,64 +0,0 @@
-import {createResizable} from '@conciv/ui-kit-system'
-import {createSignal, type JSX, type ParentProps} from 'solid-js'
-
-const GRAB_STRIP_MIN = 48
-
-const GRABBER_HIT =
- 'flex items-center justify-center h-4 w-24 cursor-ns-resize self-center shrink-0 touch-none focus-visible:outline-none [&:hover>div]:opacity-100 [&:hover>div]:bg-pw-text-3 [&:focus-visible>div]:opacity-100 [&:focus-visible>div]:bg-pw-accent'
-const GRABBER_PILL =
- 'rounded-full bg-pw-line-2 h-1.5 w-9 opacity-0 transition-opacity duration-150 group-hover:opacity-100'
-
-export function GrabStrip(props: ParentProps<{class: string}>): JSX.Element {
- let scroller: HTMLDivElement | undefined
- let content: HTMLDivElement | undefined
- const resize = createResizable({
- initial: 288,
- min: GRAB_STRIP_MIN,
- max: () => content?.offsetHeight ?? Number.POSITIVE_INFINITY,
- grow: () => 'up',
- })
- const [sized, setSized] = createSignal(false)
- const beginResize = () => {
- if (scroller) resize.set(scroller.getBoundingClientRect().height)
- setSized(true)
- }
- return (
-
-
{
- beginResize()
- resize.onPointerDown(event)
- }}
- onKeyDown={(event) => {
- beginResize()
- resize.onKeyDown(event)
- }}
- >
-
-
-
{
- scroller = el
- }}
- class="min-h-0 overflow-y-auto"
- style={sized() ? {height: `${resize.size()}px`} : undefined}
- >
-
{
- content = el
- }}
- class={props.class}
- >
- {props.children}
-
-
-
- )
-}
diff --git a/apps/conciv/src/pane/pane-composer.tsx b/apps/conciv/src/pane/pane-composer.tsx
index 224929e54..c35eee69d 100644
--- a/apps/conciv/src/pane/pane-composer.tsx
+++ b/apps/conciv/src/pane/pane-composer.tsx
@@ -104,7 +104,7 @@ export function PaneComposer(props: PaneComposerProps): JSX.Element {
)}
-
+
(
}>
diff --git a/apps/conciv/src/pane/use-pane-messaging.ts b/apps/conciv/src/pane/use-pane-messaging.ts
index 94e7d526c..778a5d710 100644
--- a/apps/conciv/src/pane/use-pane-messaging.ts
+++ b/apps/conciv/src/pane/use-pane-messaging.ts
@@ -4,7 +4,6 @@ import type {RpcClient} from '@conciv/contract'
import type {MultimodalContent} from '@tanstack/ai-client'
import type {UiAnswerValue} from '@conciv/protocol/ui-types'
import type {AppData} from '../data/app-data.js'
-import type {StagedGrab} from '../app/pane-context.js'
import type {EngineNotices} from '../shell/notice-context.js'
import {checkSend, type SendVerdict} from './send-checks.js'
@@ -35,11 +34,6 @@ type PaneMessagingDeps = {
appData: AppData
reachability: EngineNotices['reachability']
notices: EngineNotices['notices']
- grabStore: {
- grabs: () => StagedGrab[]
- clear: () => void
- stageAll: (grabs: StagedGrab[]) => void
- }
refetchMarkers: () => Promise
}
@@ -87,16 +81,7 @@ export function usePaneMessaging(deps: PaneMessagingDeps): PaneMessaging {
reachable: deps.reachability.online(),
})
if (!verdict.ok) throw sendRejection(verdict)
- const staged = deps.grabStore.grabs()
- deps.grabStore.clear()
- const before = deps.chat.error()
- try {
- await deps.chat.sendMessage(content)
- } catch (failure) {
- deps.grabStore.stageAll(staged)
- throw failure
- }
- if (deps.chat.error() !== before) deps.grabStore.stageAll(staged)
+ await deps.chat.sendMessage(content)
}
const onSendError = (failure: unknown): void => {
diff --git a/apps/conciv/src/router.tsx b/apps/conciv/src/router.tsx
index c2939e936..c027ed57c 100644
--- a/apps/conciv/src/router.tsx
+++ b/apps/conciv/src/router.tsx
@@ -10,6 +10,7 @@ import {routeTree} from './routeTree.gen'
import {makeAppData, type AppData} from './data/app-data.js'
import type {ConcivSettings} from './data/settings.js'
import type {ExtensionInstance} from './extension/extension-slots.js'
+import {createInstances} from './extension/create-instances.js'
import highlight from './extensions/highlight.js'
import {PendingPane} from './shell/pending.js'
import {defaultErrorComponent} from './shell/default-error-component.js'
@@ -49,25 +50,6 @@ export type ConcivRouterConfig = {
connectionGeneration?: () => number
}
-function createInstances(extensions: AnyExtension[]): ExtensionInstance[] {
- return extensions.flatMap((extension) => {
- try {
- const result = extension.__client?.()
- return [
- {
- extension,
- clientValue: result?.value ?? {},
- effects: result?.effects ?? [],
- dispose: result?.dispose ?? (() => {}),
- },
- ]
- } catch (error) {
- console.error(`[conciv] extension "${extension.name}" failed to mount`, error)
- return []
- }
- })
-}
-
function disposeExtensionInstances(instances: ExtensionInstance[]): void {
for (const instance of instances) {
try {
diff --git a/apps/conciv/src/routes/panel.$sessionId.$view.tsx b/apps/conciv/src/routes/panel.$sessionId.$view.tsx
index 41294cfce..1dfadb24a 100644
--- a/apps/conciv/src/routes/panel.$sessionId.$view.tsx
+++ b/apps/conciv/src/routes/panel.$sessionId.$view.tsx
@@ -1,5 +1,5 @@
import {createFileRoute, useBlocker, useRouter} from '@tanstack/solid-router'
-import {For, Show, Suspense, createMemo, type JSX} from 'solid-js'
+import {Show, Suspense, createMemo, type JSX} from 'solid-js'
import {HostApiProvider} from '@conciv/extension/host'
import {MountedView} from '@conciv/extension/client'
import {useAppData, useConnectionGeneration, useInstances, useRpc} from '../app/context.js'
@@ -7,8 +7,6 @@ import {usePane} from '../app/pane-context.js'
import {collectViews} from '../extension/extension-views.js'
import {makePaneGrabApi} from '../extension/pane-grab.js'
import {appendDraft} from '../pane/draft-storage.js'
-import {GrabReference} from '../pane/grab-reference.js'
-import {GrabStrip} from '../pane/grab-strip.js'
export const Route = createFileRoute('/panel/$sessionId/$view')({component: PanelView})
@@ -45,7 +43,7 @@ function PanelView(): JSX.Element {
{(mount) => (
params().sessionId}
- grab={makePaneGrabApi(pane.grabStore, pane.grabProvider)}
+ grab={makePaneGrabApi(pane.grabStaging, pane.grabProvider)}
insert={(text) => void appendDraft(rpc, params().sessionId, text).catch(() => {})}
attach={(file) => pane.attachments.enqueue(file)}
newSession={newSession}
@@ -60,13 +58,6 @@ function PanelView(): JSX.Element {
}}
class={`flex flex-1 flex-col min-h-0 ${pane.slideClass()}`}
>
- 0}>
-
-
- {(grab) => pane.grabStore.remove(grab)} />}
-
-
-
diff --git a/apps/conciv/src/routes/panel.$sessionId.tsx b/apps/conciv/src/routes/panel.$sessionId.tsx
index 02d21800c..4fa56b4bc 100644
--- a/apps/conciv/src/routes/panel.$sessionId.tsx
+++ b/apps/conciv/src/routes/panel.$sessionId.tsx
@@ -8,7 +8,9 @@ import {For, Show, Suspense, createMemo, createSignal, type JSX} from 'solid-js'
import {Dynamic} from 'solid-js/web'
import {isSessionId} from '@conciv/protocol/chat-types'
import {useAnnounce, useAppData, useDisconnect, useGrabProvider, useInstances, useRpc} from '../app/context.js'
-import {PaneContext, makeGrabStore, makePendingAttachmentQueue, type PaneContextValue} from '../app/pane-context.js'
+import {PaneContext, makePendingAttachmentQueue, type PaneContextValue} from '../app/pane-context.js'
+import {makeGrabStaging} from '../pane/grab-staging.js'
+import {resolveGrabSource} from '../pane/grab-source-resolve.js'
import {SessionSelector} from '../composer/session-selector.js'
import {usePanelChrome} from '../app/panel-chrome.js'
import {ContextTracker} from '../pane/context-tracker.js'
@@ -83,7 +85,9 @@ function PanelSession(): JSX.Element {
announce('Started a new session')
}
- const grabStore = makeGrabStore()
+ const grabStaging = makeGrabStaging({
+ ground: (grab) => resolveGrabSource(grab, (input) => rpc.page.symbolicate(input)),
+ })
useBlocker({
shouldBlockFn: ({current, next}) =>
@@ -97,7 +101,7 @@ function PanelSession(): JSX.Element {
setLockedFor,
slideClass,
resetSlide: () => setSlideDir(null),
- grabStore,
+ grabStaging,
grabProvider,
attachments: makePendingAttachmentQueue(),
newSession: () => void newSession(),
diff --git a/apps/conciv/test/chat-pane.browser.test.tsx b/apps/conciv/test/chat-pane.browser.test.tsx
index bb9ee5b32..93e153c30 100644
--- a/apps/conciv/test/chat-pane.browser.test.tsx
+++ b/apps/conciv/test/chat-pane.browser.test.tsx
@@ -2,10 +2,21 @@ import './helpers/utilities.css'
import {afterAll, afterEach, beforeAll, expect, test} from 'vitest'
import {page, userEvent} from 'vitest/browser'
import type {RpcClient} from '@conciv/contract'
+import pageExtension from '@conciv/extension-page/client'
+import {GRAB_FILE_NAME} from '@conciv/grab/grab-attachment'
+import type {Grab} from '@conciv/grab'
import {ChatPane} from '../src/pane/chat-pane.js'
import {coreControl} from './helpers/core-control.js'
import {coreRpc, createSession, openTranscriptStream, runTurn, seedDraft, sendTurn} from './helpers/core-session.js'
-import {mountPane, type PaneMount} from './helpers/pane-harness.js'
+import {
+ grabProviderFor,
+ HERO_GRAB,
+ HERO_LABEL,
+ persistedGrab,
+ PRICING_GRAB,
+ PRICING_LABEL,
+} from './helpers/grab-fixtures.js'
+import {mountPane, type PaneMount, type PaneMountOptions} from './helpers/pane-harness.js'
import {trackedFaults} from './helpers/tracked-faults.js'
const SEND_PATH = ['chat', 'send']
@@ -35,23 +46,35 @@ async function newSession(): Promise<{rpc: RpcClient; sessionId: string}> {
return {rpc, sessionId: await createSession(rpc)}
}
-function mountChatPane(sessionId: string): PaneMount {
- const mount = mountPane({base: core.base, sessionId}, () => )
+function grabOptions(...grabs: Grab[]): Pick {
+ return {grabProvider: grabProviderFor(...grabs), extensions: [pageExtension]}
+}
+
+function mountChatPane(sessionId: string, options: Pick = {}): PaneMount {
+ const mount = mountPane({base: core.base, sessionId, ...options}, () => )
active.pane = mount
return mount
}
const input = () => page.getByRole('textbox', {name: 'Message the conciv agent'})
-const removeGrab = () => page.getByRole('button', {name: 'Remove grabbed element'})
+const removeGrab = () => page.getByRole('button', {name: `Remove ${GRAB_FILE_NAME}`})
+const grabButton = () => page.getByRole('button', {name: 'Select an element from the page'})
+const snapshot = () => page.getByTitle('Grabbed element snapshot')
const notifications = () => page.getByRole('region', {name: /Notifications/})
const stopButton = () => page.getByRole('button', {name: 'Stop generating'})
const skeleton = () => page.getByRole('status', {name: 'Loading conversation'})
+async function stageGrabThroughComposer(): Promise {
+ await expect.element(input()).toBeVisible()
+ await userEvent.click(grabButton())
+ await expect.element(snapshot()).toBeVisible()
+ await expect.element(page.getByText(HERO_LABEL)).toBeVisible()
+}
+
async function sendWithStagedGrab(): Promise {
- const {rpc, sessionId} = await newSession()
- await seedDraft(rpc, sessionId, {grabs: ['the grabbed hero section']})
- mountChatPane(sessionId)
- await expect.element(removeGrab()).toBeVisible()
+ const {sessionId} = await newSession()
+ mountChatPane(sessionId, grabOptions(HERO_GRAB))
+ await stageGrabThroughComposer()
await input().fill('explain the section I grabbed')
await userEvent.keyboard('{Enter}')
}
@@ -67,12 +90,16 @@ async function startStreamingRun(): Promise {
test('restores the server-side draft text and staged grabs when the pane mounts', async () => {
const {rpc, sessionId} = await newSession()
- await seedDraft(rpc, sessionId, {text: 'kept across the reload', grabs: ['a grabbed heading']})
+ await seedDraft(rpc, sessionId, {
+ text: 'kept across the reload',
+ attachments: [persistedGrab('grab-1', HERO_GRAB)],
+ })
- mountChatPane(sessionId)
+ mountChatPane(sessionId, grabOptions(HERO_GRAB))
await expect.element(input()).toHaveTextContent('kept across the reload')
- await expect.element(page.getByText('a grabbed heading')).toBeVisible()
+ await expect.element(snapshot()).toBeVisible()
+ await expect.element(page.getByText(HERO_LABEL)).toBeVisible()
})
test('a rejected send keeps the draft in the composer and tells the user why', async () => {
@@ -102,7 +129,7 @@ test('a send the server refuses puts the staged grab card back', async () => {
await expect.element(notifications()).toHaveTextContent(/Internal Server Error|could not be sent/)
await expect.element(removeGrab()).toBeVisible()
- await expect.element(page.getByText('the grabbed hero section', {exact: true})).toBeVisible()
+ await expect.element(page.getByText(HERO_LABEL).last()).toBeVisible()
})
test('a send that throws at the transport puts the staged grab card back', async () => {
@@ -111,32 +138,33 @@ test('a send that throws at the transport puts the staged grab card back', async
await expect.element(notifications()).toHaveTextContent(/could not be sent|fetch/)
await expect.element(removeGrab()).toBeVisible()
- await expect.element(page.getByText('the grabbed hero section', {exact: true})).toBeVisible()
+ await expect.element(page.getByText(HERO_LABEL).last()).toBeVisible()
})
test('a queued second send cannot cross-restore the grabs of the turn that failed', async () => {
const {rpc, sessionId} = await newSession()
- await seedDraft(rpc, sessionId, {grabs: ['the grabbed hero section']})
+ await seedDraft(rpc, sessionId, {attachments: [persistedGrab('grab-1', HERO_GRAB)]})
await coreControl.holdTurn()
- const mount = mountChatPane(sessionId)
+ mountChatPane(sessionId, grabOptions(PRICING_GRAB))
- await expect.element(page.getByText('the grabbed hero section', {exact: true})).toBeVisible()
+ await expect.element(page.getByText(HERO_LABEL)).toBeVisible()
await input().fill('turn A')
await userEvent.keyboard('{Enter}')
await expect.element(stopButton()).toBeVisible()
- mount.pane.grabStore.stageAll([{text: 'the grabbed pricing table'}])
- await expect.element(page.getByText('the grabbed pricing table', {exact: true})).toBeVisible()
+ await userEvent.click(grabButton())
+ await expect.element(page.getByText(PRICING_LABEL)).toBeVisible()
await input().fill('turn B')
await userEvent.keyboard('{Enter}')
await expect.element(page.getByRole('button', {name: 'Remove from queue'})).toBeVisible()
- await expect.element(page.getByText('the grabbed pricing table', {exact: true})).not.toBeInTheDocument()
+ await expect.element(page.getByText(PRICING_LABEL)).not.toBeInTheDocument()
await coreControl.scriptError('turn A failed')
await coreControl.releaseTurn()
- await expect.element(page.getByText('the grabbed hero section', {exact: true})).toBeVisible()
- await expect.element(page.getByText('the grabbed pricing table', {exact: true})).not.toBeInTheDocument()
+ await expect.element(removeGrab()).toBeVisible()
+ await expect.element(page.getByText(HERO_LABEL).last()).toBeVisible()
+ await expect.element(page.getByText(PRICING_LABEL)).not.toBeInTheDocument()
})
test('sending announces thinking and then the reply through the live region', async () => {
diff --git a/apps/conciv/test/grab-reference.browser.test.tsx b/apps/conciv/test/grab-reference.browser.test.tsx
deleted file mode 100644
index 7813259e2..000000000
--- a/apps/conciv/test/grab-reference.browser.test.tsx
+++ /dev/null
@@ -1,45 +0,0 @@
-import './helpers/utilities.css'
-import {render} from '@solidjs/testing-library'
-import {expect, test} from 'vitest'
-import {page} from 'vitest/browser'
-import type {Grab} from '@conciv/grab'
-import {makeImageHostGrab} from '@conciv/extension-testkit/host/grab'
-import {GrabReference} from '../src/pane/grab-reference.js'
-
-const IMAGE_DATA_URL =
- 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAQAAAAECAIAAAAmkwkpAAAAEElEQVR4nGP4z8AARwzEcQCukw/x0F8jngAAAABJRU5ErkJggg=='
-
-function mount(grab: Grab | {text: string}): HTMLElement {
- return render(() => {}} />).container
-}
-
-function domGrab(size: {width: number; height: number} = {width: 200, height: 40}): Grab {
- return {
- text: 'Payroll Deposit',
- 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},
- }
-}
-
-function previewBox(host: HTMLElement): HTMLElement | null | undefined {
- return host.querySelector('[data-pw-grab-scale]')?.parentElement
-}
-
-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()
-})
-
-test('grab reference renders the image preview arm as an img element', () => {
- const host = mount(makeImageHostGrab(IMAGE_DATA_URL))
- expect(host.querySelector('img')?.getAttribute('src')).toBe(IMAGE_DATA_URL)
-})
-
-test('a text-only grab has no preview box at all', () => {
- const host = mount({text: 'just the copied text'})
-
- expect(page.getByText('just the copied text').elements()).toHaveLength(1)
- expect(previewBox(host)).toBeUndefined()
-})
diff --git a/apps/conciv/test/helpers/core-session.ts b/apps/conciv/test/helpers/core-session.ts
index 4df9d492b..b8e74259c 100644
--- a/apps/conciv/test/helpers/core-session.ts
+++ b/apps/conciv/test/helpers/core-session.ts
@@ -1,7 +1,7 @@
import {EventType} from '@tanstack/ai'
-import {makeRpcClient, type RpcClient} from '@conciv/contract'
+import {makeRpcClient, type PersistedAttachment, type RpcClient} from '@conciv/contract'
-export type SeededDraft = {text?: string; grabs?: string[]}
+export type SeededDraft = {text?: string; grabs?: string[]; attachments?: PersistedAttachment[]}
export type TranscriptStream = {awaitTurnEnd: () => Promise; close: () => void}
@@ -22,6 +22,7 @@ export async function seedDraft(rpc: RpcClient, sessionId: string, draft: Seeded
selectionStart: text.length,
selectionEnd: text.length,
grabs: draft.grabs ?? [],
+ attachments: draft.attachments ?? [],
})
}
diff --git a/apps/conciv/test/helpers/grab-fixtures.ts b/apps/conciv/test/helpers/grab-fixtures.ts
new file mode 100644
index 000000000..0f50ddace
--- /dev/null
+++ b/apps/conciv/test/helpers/grab-fixtures.ts
@@ -0,0 +1,46 @@
+import {GRAB_FILE_NAME, GRAB_MIME, grabToPayload} from '@conciv/grab/grab-attachment'
+import type {Grab, GrabProvider} from '@conciv/grab'
+import type {PersistedAttachment} from '@conciv/contract'
+
+export const HERO_GRAB: Grab = {
+ text: 'Payroll Deposit
at src/routes/index.tsx:12:9',
+ snippet: 'Payroll Deposit
',
+ preview: {kind: 'dom', html: 'Payroll Deposit clone
', width: 200, height: 40},
+ source: {componentName: 'Hero', filePath: 'src/routes/index.tsx', lineNumber: 12},
+ rect: {x: 0, y: 0, width: 200, height: 40},
+}
+
+export const PRICING_GRAB: Grab = {
+ text: ' at src/routes/pricing.tsx:4:1',
+ snippet: '',
+ preview: {kind: 'dom', html: 'Pricing table clone
', width: 200, height: 40},
+ source: {componentName: 'Pricing', filePath: 'src/routes/pricing.tsx', lineNumber: 4},
+ rect: {x: 0, y: 0, width: 200, height: 40},
+}
+
+export const HERO_LABEL = 'Hero at src/routes/index.tsx:12'
+
+export const PRICING_LABEL = 'Pricing at src/routes/pricing.tsx:4'
+
+export function tallGrab(componentName: string, height: number): Grab {
+ return {
+ text: ` at src/routes/${componentName}.tsx:1:1`,
+ snippet: ``,
+ preview: {kind: 'dom', html: `${componentName} clone
`, width: 200, height},
+ source: {componentName, filePath: `src/routes/${componentName}.tsx`, lineNumber: 1},
+ rect: {x: 0, y: 0, width: 200, height},
+ }
+}
+
+export function persistedGrab(id: string, grab: Grab): PersistedAttachment {
+ const bytes = new TextEncoder().encode(JSON.stringify(grabToPayload(grab)))
+ let binary = ''
+ for (const byte of bytes) binary += String.fromCharCode(byte)
+ return {id, type: 'document', name: GRAB_FILE_NAME, contentType: GRAB_MIME, data: btoa(binary)}
+}
+
+export function grabProviderFor(...grabs: Grab[]): GrabProvider {
+ const queue = [...grabs]
+ const take = async (): Promise => (queue.length > 1 ? (queue.shift() ?? HERO_GRAB) : (queue[0] ?? HERO_GRAB))
+ return () => ({pick: take, comment: take, cancel: () => {}, isActive: () => false})
+}
diff --git a/apps/conciv/test/helpers/pane-harness.tsx b/apps/conciv/test/helpers/pane-harness.tsx
index bc8ffd8a1..53d5f9c4f 100644
--- a/apps/conciv/test/helpers/pane-harness.tsx
+++ b/apps/conciv/test/helpers/pane-harness.tsx
@@ -6,18 +6,23 @@ import '../../src/lib/api-base.js'
import {HostApiProvider} from '@conciv/extension/host'
import {AppContext, type AppContextValue} from '../../src/app/context.js'
import {EngineReachabilityContext, makeEngineReachability} from '../../src/app/reachability.js'
-import {
- PaneContext,
- makeGrabStore,
- makePendingAttachmentQueue,
- type PaneContextValue,
-} from '../../src/app/pane-context.js'
+import type {AnyExtension} from '@conciv/extension'
+import type {Grab, GrabProvider} from '@conciv/grab'
+import {PaneContext, makePendingAttachmentQueue, type PaneContextValue} from '../../src/app/pane-context.js'
+import {createInstances} from '../../src/extension/create-instances.js'
+import {makeGrabStaging} from '../../src/pane/grab-staging.js'
import {makeAppData, type AppData} from '../../src/data/app-data.js'
import {parseConcivSettings} from '../../src/data/settings.js'
import {makeLayerStack} from '../../src/shell/dialogs.js'
import {NoticeContextProvider, NoticeSurface} from '../../src/shell/notice-context.js'
-export type PaneMountOptions = {base: string; sessionId: string}
+export type PaneMountOptions = {
+ base: string
+ sessionId: string
+ grabProvider?: GrabProvider
+ extensions?: AnyExtension[]
+ ground?: (grab: Grab) => Promise
+}
export type PaneMount = {
dispose: () => void
@@ -53,7 +58,7 @@ export function mountPane(options: PaneMountOptions, view: (pane: PaneContextVal
layers: makeLayerStack(),
suppressed: () => undefined,
fabPosition: () => 'bottom-right',
- instances: [],
+ instances: createInstances(options.extensions ?? []),
connected: () => true,
arrivedFromConnect: () => false,
connectBind: async () => '',
@@ -68,8 +73,8 @@ export function mountPane(options: PaneMountOptions, view: (pane: PaneContextVal
setLockedFor: () => () => {},
slideClass: () => '',
resetSlide: () => {},
- grabStore: makeGrabStore(),
- grabProvider: undefined,
+ grabStaging: makeGrabStaging({ground: options.ground ?? (async () => null)}),
+ grabProvider: options.grabProvider,
attachments: makePendingAttachmentQueue(),
newSession: () => {},
}
diff --git a/apps/conciv/test/panel-min-height.browser.test.tsx b/apps/conciv/test/panel-min-height.browser.test.tsx
index 605975e01..64a866704 100644
--- a/apps/conciv/test/panel-min-height.browser.test.tsx
+++ b/apps/conciv/test/panel-min-height.browser.test.tsx
@@ -1,11 +1,18 @@
import './helpers/utilities.css'
import {afterAll, afterEach, beforeAll, expect, test} from 'vitest'
import {page} from 'vitest/browser'
+import pageExtension from '@conciv/extension-page/client'
import {coreControl} from './helpers/core-control.js'
import {coreRpc, createSession, seedDraft} from './helpers/core-session.js'
+import {persistedGrab, tallGrab} from './helpers/grab-fixtures.js'
import {createShellHarness} from './helpers/shell-harness.js'
-const STAGED = ['the grabbed hero section', 'the grabbed price row', 'the grabbed footer', 'the grabbed nav']
+const STAGED = [
+ persistedGrab('grab-1', tallGrab('Hero', 900)),
+ persistedGrab('grab-2', tallGrab('Pricing', 900)),
+ persistedGrab('grab-3', tallGrab('Footer', 900)),
+ persistedGrab('grab-4', tallGrab('Nav', 900)),
+]
const core = {base: ''}
const harness = createShellHarness(() => core.base)
@@ -24,8 +31,8 @@ afterEach(harness.dispose)
async function openPanel(): Promise {
const rpc = coreRpc(core.base)
const sessionId = await createSession(rpc)
- await seedDraft(rpc, sessionId, {grabs: STAGED})
- harness.mountShell(`/panel/${sessionId}?open=true`)
+ await seedDraft(rpc, sessionId, {attachments: STAGED})
+ harness.mountShell(`/panel/${sessionId}?open=true`, [pageExtension])
}
const input = () => page.getByRole('textbox', {name: 'Message the conciv agent'})
@@ -34,7 +41,8 @@ test('the composer stays reachable when the viewport clamps the panel below its
await page.viewport(1000, 400)
await openPanel()
- await expect.element(page.getByText('the grabbed hero section')).toBeVisible()
+ await expect.element(page.getByText('Hero at src/routes/Hero.tsx:1')).toBeVisible()
+ await expect.element(input()).toBeInViewport({ratio: 1})
await input().click()
await input().fill('still typeable at the smallest panel')
@@ -43,31 +51,13 @@ test('the composer stays reachable when the viewport clamps the panel below its
await expect.element(page.getByText('still typeable at the smallest panel')).toBeVisible()
}, 30_000)
-test('the grabs strip resizes through the shared separator handle', async () => {
+test('staged grab cards never push the composer out of the viewport as they pile up', async () => {
await page.viewport(1000, 900)
await openPanel()
- const handle = page.getByRole('separator', {name: 'Resize grabs height'})
- await expect.element(handle).toBeVisible()
- await expect.element(handle).toHaveAttribute('aria-valuenow', '288')
- await expect.element(page.getByText('the grabbed hero section')).toBeVisible()
- await expect.element(page.getByText('the grabbed nav')).toBeVisible()
-}, 30_000)
-
-test('the staged grabs come back into the flow once the panel has room again', async () => {
- await page.viewport(1000, 400)
- await openPanel()
-
- await expect.element(input()).toBeVisible()
+ await expect.element(page.getByText('Hero at src/routes/Hero.tsx:1')).toBeVisible()
+ await expect.element(page.getByText('Nav at src/routes/Nav.tsx:1')).toBeVisible()
- await page.viewport(1000, 900)
-
- await expect.element(page.getByText('the grabbed hero section')).toBeVisible()
- await expect.element(page.getByText('the grabbed nav')).toBeVisible()
- await page.getByRole('button', {name: 'Remove grabbed element'}).last().click()
- await expect.element(page.getByText('the grabbed nav')).not.toBeInTheDocument()
- await input().click()
- await input().fill('room to breathe')
- await page.getByRole('button', {name: 'Send message'}).click()
- await expect.element(page.getByText('room to breathe')).toBeVisible()
+ await expect.element(input()).toBeInViewport({ratio: 1})
+ await expect.element(page.getByRole('button', {name: 'Send message'})).toBeInViewport({ratio: 1})
}, 30_000)
diff --git a/packages/extensions/page/src/client/cards/grab-card.tsx b/packages/extensions/page/src/client/cards/grab-card.tsx
index 0926f1362..50198650c 100644
--- a/packages/extensions/page/src/client/cards/grab-card.tsx
+++ b/packages/extensions/page/src/client/cards/grab-card.tsx
@@ -12,7 +12,7 @@ type GrabPreview = NonNullable
const CARD =
'text-[0.6875rem] font-pw-mono p-3 border border-pw-line rounded-pw-md bg-pw-fill flex flex-col gap-2.5 items-start relative'
-const PREVIEW_SLOT = 'relative max-w-full'
+const PREVIEW_SLOT = 'relative max-w-full max-h-[13.75rem] overflow-hidden'
const OPEN_BUTTON = 'absolute inset-0 cursor-pointer'
const SOURCE_LINE = 'text-pw-text-2 flex gap-1.5 [word-break:break-all] items-center'
const AGENT_TEXT = 'text-pw-text-2 font-pw-mono text-xs whitespace-pre-wrap [word-break:break-all] m-0'
From b6cfc0045ed33d6eeebf334a897d05603b213d95 Mon Sep 17 00:00:00 2001
From: Omri Katz <9701896+omridevk@users.noreply.github.com>
Date: Sat, 15 Aug 2026 11:33:13 +0300
Subject: [PATCH 11/22] test(conciv): #487 prove a staged grab survives a panel
reload
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The fake core now stores what drafts/set writes and exposes nextDraftWrite, so
the reload test waits on the real persisted write instead of a sleep. The
regression test stages through the composer's own grab button, waits for that
write, disposes the pane and remounts against the same fake core: the snapshot
frame and the source label are both still there.
Playwright cannot enter a sandbox="" iframe (opaque origin), so the snapshot is
located by its iframe title rather than through frameLocator.
Also pins the GrabApi an extension receives — stage, staged and clear through
makePaneGrabApi, the object HostApiProvider hands to useGrab, with the terminal
extension unmodified.
Co-Authored-By: Claude Fable 5
---
apps/conciv/test/chat-pane.browser.test.tsx | 16 ++++++
.../conciv/test/grab-staging.browser.test.tsx | 53 +++++++++++++++++++
2 files changed, 69 insertions(+)
create mode 100644 apps/conciv/test/grab-staging.browser.test.tsx
diff --git a/apps/conciv/test/chat-pane.browser.test.tsx b/apps/conciv/test/chat-pane.browser.test.tsx
index 93e153c30..9dd7000f5 100644
--- a/apps/conciv/test/chat-pane.browser.test.tsx
+++ b/apps/conciv/test/chat-pane.browser.test.tsx
@@ -21,6 +21,7 @@ import {trackedFaults} from './helpers/tracked-faults.js'
const SEND_PATH = ['chat', 'send']
const SUBSCRIBE_PATH = ['chat', 'subscribe']
+const DRAFTS_SET_PATH = ['drafts', 'set']
const core = {base: ''}
const active: {pane: PaneMount | null} = {pane: null}
@@ -102,6 +103,21 @@ test('restores the server-side draft text and staged grabs when the pane mounts'
await expect.element(page.getByText(HERO_LABEL)).toBeVisible()
})
+test('a staged grab keeps its snapshot and source label across a panel reload', async () => {
+ const {sessionId} = await newSession()
+ const since = await coreControl.rpcMark()
+ const first = mountChatPane(sessionId, grabOptions(HERO_GRAB))
+ await stageGrabThroughComposer()
+
+ expect(await coreControl.awaitRpcCall(DRAFTS_SET_PATH, since)).toBe(200)
+ first.dispose()
+
+ mountChatPane(sessionId, {extensions: [pageExtension]})
+
+ await expect.element(snapshot()).toBeVisible()
+ await expect.element(page.getByText(HERO_LABEL)).toBeVisible()
+})
+
test('a rejected send keeps the draft in the composer and tells the user why', async () => {
const {sessionId} = await newSession()
await faults.install({kind: 'fail', path: SEND_PATH, status: 500})
diff --git a/apps/conciv/test/grab-staging.browser.test.tsx b/apps/conciv/test/grab-staging.browser.test.tsx
new file mode 100644
index 000000000..b691f1a1f
--- /dev/null
+++ b/apps/conciv/test/grab-staging.browser.test.tsx
@@ -0,0 +1,53 @@
+import './helpers/utilities.css'
+import {afterAll, afterEach, beforeAll, expect, test} from 'vitest'
+import {page} from 'vitest/browser'
+import pageExtension from '@conciv/extension-page/client'
+import {GRAB_FILE_NAME} from '@conciv/grab/grab-attachment'
+import {ChatPane} from '../src/pane/chat-pane.js'
+import {makePaneGrabApi} from '../src/extension/pane-grab.js'
+import {coreControl} from './helpers/core-control.js'
+import {coreRpc, createSession} from './helpers/core-session.js'
+import {HERO_GRAB, HERO_LABEL} from './helpers/grab-fixtures.js'
+import {mountPane, type PaneMount} from './helpers/pane-harness.js'
+
+const core = {base: ''}
+const active: {pane: PaneMount | null} = {pane: null}
+
+beforeAll(async () => {
+ const booted = await coreControl.bootCore({id: 'grab-staging', allowedOrigins: [window.location.origin]})
+ core.base = booted.base
+}, 60_000)
+
+afterAll(async () => {
+ await coreControl.closeCore()
+}, 30_000)
+
+afterEach(() => {
+ active.pane?.dispose()
+ active.pane = null
+})
+
+const input = () => page.getByRole('textbox', {name: 'Message the conciv agent'})
+const snapshot = () => page.getByTitle('Grabbed element snapshot')
+const removeGrab = () => page.getByRole('button', {name: `Remove ${GRAB_FILE_NAME}`})
+
+test('the grab api an extension receives can stage, read and clear, as the terminal does', async () => {
+ const sessionId = await createSession(coreRpc(core.base))
+ const mount = mountPane({base: core.base, sessionId, extensions: [pageExtension]}, () => (
+
+ ))
+ active.pane = mount
+ await expect.element(input()).toBeVisible()
+ const api = makePaneGrabApi(mount.pane.grabStaging, mount.pane.grabProvider)
+
+ api.stage(HERO_GRAB)
+
+ await expect.element(snapshot()).toBeVisible()
+ await expect.element(page.getByText(HERO_LABEL)).toBeVisible()
+ expect(api.staged().map((grab) => grab.text)).toEqual([HERO_GRAB.text])
+
+ api.clear()
+
+ await expect.element(removeGrab()).not.toBeInTheDocument()
+ expect(api.staged()).toHaveLength(0)
+})
From 204b443dbd605a99969463a0c5cfedb8c0e024ce Mon Sep 17 00:00:00 2001
From: Omri Katz <9701896+omridevk@users.noreply.github.com>
Date: Sat, 15 Aug 2026 11:54:28 +0300
Subject: [PATCH 12/22] fix(page): #487 scale the grab snapshot to fit its card
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A grabbed hero rendered at its natural size and was merely clipped, so the card
showed a cropped fragment with cut-off borders. The snapshot now scales down
proportionally: the iframe sits inside a foreignObject in an svg whose viewBox
is the preview's natural size, so the browser fits the whole thing to whatever
width and height the card allows. No measurement, no JS, no ResizeObserver.
This is the mechanism the deleted GrabReference used.
The sandbox is unchanged — sandbox="", srcdoc, CSP and pointer-events-none all
ride along inside the foreignObject, and both frame tests still pass: the
positive control still finds the rendered markup, and inline handlers in
captured markup still never run in the widget.
Sizing is now the caller's: the card passes max-h-[40cqh] (the panel is the
query container, as it was for the strip) and the dialog passes max-h-[70vh],
so full size lives in the dialog. Image previews take the same class and scale
the same way. The clip-clamp on the preview slot is gone — with the snapshot
scaled to fit there is nothing to crop, which is what removes the cut-off
border artifacts. The composer's staged-attachment region keeps its cap, since
per-card scaling still does not bound four cards.
Co-Authored-By: Claude Fable 5
---
.../page/src/client/cards/grab-card.tsx | 20 ++++++-----
.../src/client/cards/grab-snapshot-frame.tsx | 36 +++++++++----------
...grab-snapshot-frame.solid.browser.test.tsx | 6 ++--
3 files changed, 31 insertions(+), 31 deletions(-)
diff --git a/packages/extensions/page/src/client/cards/grab-card.tsx b/packages/extensions/page/src/client/cards/grab-card.tsx
index 50198650c..2487e3752 100644
--- a/packages/extensions/page/src/client/cards/grab-card.tsx
+++ b/packages/extensions/page/src/client/cards/grab-card.tsx
@@ -12,11 +12,13 @@ type GrabPreview = NonNullable
const CARD =
'text-[0.6875rem] font-pw-mono p-3 border border-pw-line rounded-pw-md bg-pw-fill flex flex-col gap-2.5 items-start relative'
-const PREVIEW_SLOT = 'relative max-w-full max-h-[13.75rem] overflow-hidden'
+const PREVIEW_SLOT = 'relative max-w-full'
const OPEN_BUTTON = 'absolute inset-0 cursor-pointer'
const SOURCE_LINE = 'text-pw-text-2 flex gap-1.5 [word-break:break-all] items-center'
const AGENT_TEXT = 'text-pw-text-2 font-pw-mono text-xs whitespace-pre-wrap [word-break:break-all] m-0'
-const SNAPSHOT_IMAGE = 'block w-auto h-auto max-w-full'
+const CARD_SNAPSHOT = 'block w-auto h-auto max-w-full max-h-[40cqh]'
+
+const DIALOG_SNAPSHOT = 'block w-auto h-auto max-w-full max-h-[70vh]'
const SNAPSHOT_LABEL = 'Grabbed element snapshot'
@@ -50,7 +52,7 @@ function imagePreview(preview: GrabPreview): Extract
{(image) => (
)}
}
>
- {(dom) => }
+ {(dom) => }
)
}
@@ -82,7 +84,7 @@ function GrabBody(props: {payload: GrabPayload; remove?: JSX.Element}): JSX.Elem
{props.payload.text}}>
{(preview) => (
-
+
-
-
-
+
+
+
+
+
>
)
}
diff --git a/packages/ui-kit-chat/test/attachment-only-user-message.browser.test.tsx b/packages/ui-kit-chat/test/attachment-only-user-message.browser.test.tsx
new file mode 100644
index 000000000..b780fbcc1
--- /dev/null
+++ b/packages/ui-kit-chat/test/attachment-only-user-message.browser.test.tsx
@@ -0,0 +1,44 @@
+import 'virtual:uno.css'
+import type {JSX} from 'solid-js'
+import {page} from 'vitest/browser'
+import {expect, it} from 'vitest'
+import {useChat} from '@tanstack/ai-solid'
+import type {MultimodalContent} from '@tanstack/ai-client'
+import {ChatProvider} from '../src/store/chat-context.js'
+import {storyConnection} from '../src/store/story-connection.js'
+import {Thread} from '../src/styled/thread.js'
+import {mountView} from './mount-view.js'
+
+const GIF = 'data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw=='
+
+function imagePart() {
+ return {type: 'image', source: {type: 'data', value: GIF, mimeType: 'image/gif'}} as const
+}
+
+function withText(): MultimodalContent {
+ return {content: [{type: 'text', content: 'here it is'}, imagePart()]}
+}
+
+function ThreadHost(props: {content: () => MultimodalContent}): JSX.Element {
+ const chat = useChat({connection: storyConnection({chunks: [], chunkDelay: 2})})
+ return (
+
+
+
+
+
+
+
+
+ )
+}
+
+it('a user message that carries text still renders it', async () => {
+ mountView(() => )
+
+ await page.getByRole('button', {name: 'send'}).click()
+
+ await expect.element(page.getByText('here it is'), {timeout: 2000}).toBeVisible()
+})
From 8dcebf9e943644857ebcfec79344930082e6bf3e Mon Sep 17 00:00:00 2001
From: Omri Katz <9701896+omridevk@users.noreply.github.com>
Date: Sat, 15 Aug 2026 13:52:49 +0300
Subject: [PATCH 15/22] fix(ui-kit-chat): #487 a model-only text part is not
visible text
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The empty user bubble survived the previous fix on the real engine. RCA against
the live dev app: the bubble is the user turn (data-role=user, data-pw-msg,
textContent length 0, USER_BUBBLE_CLASS), and the guard was reaching it — the
served module carries Message.If hasText — so hasText was returning true.
Why it was true: a grab-only send is expanded server-side into a text part
carrying the grounding string, flagged modelOnly, which is what the agent reads.
The claude harness persists it, so the replayed user turn genuinely has a
non-empty text part. DispatchPart then renders nothing for it (message.tsx:118
skips modelOnly), so the bubble painted around nothing. The fake core has no
expander, produces no such part, and could never reproduce this.
hasText now mirrors what the renderer actually draws: a text part counts only
when it is non-blank AND not modelOnly, the same pair grouping.ts already uses
to decide a part contributes nothing.
Verified on the real engine, both paths: the pre-existing replayed turn (sent
before any of this work, so the renderer guard fixes old messages too) and a
fresh grab-only send driven through the composer — zero user bubbles in the DOM
in both, with the grab card and its source line intact.
Co-Authored-By: Claude Fable 5
---
.../src/primitives/message/message.tsx | 3 +-
.../test/message-if-has-text.browser.test.tsx | 81 +++++++++++++++++++
2 files changed, 83 insertions(+), 1 deletion(-)
create mode 100644 packages/ui-kit-chat/test/message-if-has-text.browser.test.tsx
diff --git a/packages/ui-kit-chat/src/primitives/message/message.tsx b/packages/ui-kit-chat/src/primitives/message/message.tsx
index 2ba329b29..8d47789e8 100644
--- a/packages/ui-kit-chat/src/primitives/message/message.tsx
+++ b/packages/ui-kit-chat/src/primitives/message/message.tsx
@@ -198,7 +198,8 @@ const IF_PREDICATES: Record = {
assistant: (turn) => turn.role === 'assistant',
system: (turn) => turn.role === 'system',
hasContent: (turn) => turn.parts.length > 0,
- hasText: (turn) => turn.parts.some((part) => part.type === 'text' && part.content.trim() !== ''),
+ hasText: (turn) =>
+ turn.parts.some((part) => part.type === 'text' && part.content.trim() !== '' && !partIsModelOnly(part)),
last: (_turn, message) => message.isLast(),
lastOrHover: (turn, message, hovering) => message.isLast() || hovering === turn.key,
hasAttachments: (turn) => turn.parts.some((part) => part.type === 'image' || part.type === 'document'),
diff --git a/packages/ui-kit-chat/test/message-if-has-text.browser.test.tsx b/packages/ui-kit-chat/test/message-if-has-text.browser.test.tsx
new file mode 100644
index 000000000..7f7816d9d
--- /dev/null
+++ b/packages/ui-kit-chat/test/message-if-has-text.browser.test.tsx
@@ -0,0 +1,81 @@
+import 'virtual:uno.css'
+import {untrack, type JSX, type ParentProps} from 'solid-js'
+import {page} from 'vitest/browser'
+import {expect, it} from 'vitest'
+import {useChat} from '@tanstack/ai-solid'
+import type {MessagePart} from '@tanstack/ai-client'
+import {ChatProvider} from '../src/store/chat-context.js'
+import {storyConnection} from '../src/store/story-connection.js'
+import {Message} from '../src/primitives/message/message.js'
+import {MessageProvider} from '../src/primitives/message/message-context.js'
+import type {Turn} from '../src/store/grouping.js'
+import {mountView} from './mount-view.js'
+
+const GROUNDING = 'Server Functions at src/routes/index.tsx:44:9'
+
+function grabDocumentPart(): MessagePart {
+ return {
+ type: 'document',
+ source: {type: 'data', value: 'e30=', mimeType: 'application/vnd.conciv.grab+json'},
+ }
+}
+
+function modelOnlyText(): MessagePart {
+ const part: MessagePart = {type: 'text', content: GROUNDING}
+ return Object.assign(part, {metadata: {modelOnly: true}})
+}
+
+function visibleText(): MessagePart {
+ return {type: 'text', content: 'explain this'}
+}
+
+function turnOf(parts: MessagePart[]): Turn {
+ return {key: 'turn-1', role: 'user', parts, start: 0, end: 0}
+}
+
+function Host(props: ParentProps<{parts: MessagePart[]}>): JSX.Element {
+ const chat = useChat({connection: storyConnection(untrack(() => undefined))})
+ const turn = untrack(() => turnOf(props.parts))
+ return (
+
+ turn,
+ index: () => 0,
+ pairing: () => ({byCallId: new Map(), hiddenResultIds: new Set()}),
+ isLast: () => true,
+ }}
+ >
+ {props.children}
+
+
+ )
+}
+
+function mount(parts: MessagePart[]): void {
+ mountView(() => (
+
+
+ bubble drawn
+
+
+ ))
+}
+
+it('a grab-only turn whose only text is model-only draws no bubble', async () => {
+ mount([grabDocumentPart(), modelOnlyText()])
+
+ await expect.element(page.getByText('bubble drawn')).not.toBeInTheDocument()
+})
+
+it('a turn with visible text still draws its bubble', async () => {
+ mount([grabDocumentPart(), visibleText()])
+
+ await expect.element(page.getByText('bubble drawn')).toBeVisible()
+})
+
+it('a turn whose text is only whitespace draws no bubble', async () => {
+ mount([grabDocumentPart(), {type: 'text', content: ' '}])
+
+ await expect.element(page.getByText('bubble drawn')).not.toBeInTheDocument()
+})
From e6520a44ae397e85c65f37e4392f3fb08fa0f396 Mon Sep 17 00:00:00 2001
From: Omri Katz <9701896+omridevk@users.noreply.github.com>
Date: Sat, 15 Aug 2026 15:14:11 +0300
Subject: [PATCH 16/22] refactor(drafts): #487 remove the grabs channel now
that grabs are attachments
Co-Authored-By: Claude Fable 5
---
.changeset/grab-as-attachment.md | 5 +
apps/conciv/src/pane/draft-storage.ts | 5 +-
apps/conciv/test/chat-pane.browser.test.tsx | 11 +-
apps/conciv/test/commands/core-control.ts | 14 +-
apps/conciv/test/draft-storage.test.ts | 26 +-
apps/conciv/test/helpers/core-control.ts | 2 +-
apps/conciv/test/helpers/core-session.ts | 3 +-
packages/contract/src/rows.ts | 1 -
packages/contract/test/rows.test.ts | 6 +-
packages/core/src/chat/run.ts | 12 +-
packages/core/test/rpc/wire.it.test.ts | 10 +-
.../migration.sql | 1 +
.../snapshot.json | 685 ++++++++++++++++++
packages/db/src/schema.ts | 1 -
.../embed/tests/e2e/native-widget.it.test.ts | 6 +-
.../tests/e2e/navigation-hold.it.test.ts | 1 -
.../embed/tests/e2e/rpc-observer.it.test.ts | 2 +-
.../src/behaviors/composer-draft-storage.ts | 3 -
.../attachment/attachment.stories.tsx | 4 +-
.../primitives/composer/composer-context.tsx | 4 +-
.../src/primitives/composer/composer.tsx | 13 +-
.../styled/attachment-dispatch.stories.tsx | 4 +-
.../src/styled/attachment-ui.stories.tsx | 4 +-
.../test/composer-completion.browser.test.tsx | 29 +-
24 files changed, 746 insertions(+), 106 deletions(-)
create mode 100644 .changeset/grab-as-attachment.md
create mode 100644 packages/db/drizzle/20260815111148_drop_draft_grabs/migration.sql
create mode 100644 packages/db/drizzle/20260815111148_drop_draft_grabs/snapshot.json
diff --git a/.changeset/grab-as-attachment.md b/.changeset/grab-as-attachment.md
new file mode 100644
index 000000000..4cb046bae
--- /dev/null
+++ b/.changeset/grab-as-attachment.md
@@ -0,0 +1,5 @@
+---
+'@conciv/embed': patch
+---
+
+Grabs are attachments: the snapshot survives a panel reload, sent grabs render as a card in the transcript instead of raw HTML text, and composer attachments now persist in the session draft.
diff --git a/apps/conciv/src/pane/draft-storage.ts b/apps/conciv/src/pane/draft-storage.ts
index 371254c7b..70e49c975 100644
--- a/apps/conciv/src/pane/draft-storage.ts
+++ b/apps/conciv/src/pane/draft-storage.ts
@@ -16,7 +16,6 @@ const PersistedAttachmentSchema = z.object({
const PersistedDraftSchema = z.object({
text: z.string().catch(''),
- grabs: z.array(z.string()).catch([]),
attachments: z.array(PersistedAttachmentSchema).catch([]),
})
@@ -50,14 +49,13 @@ export async function appendDraft(rpc: RpcClient, sessionId: string, text: strin
text: nextText,
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: row.attachments}) : null
+ let cache = row ? JSON.stringify({text: row.text, quote: null, attachments: row.attachments}) : null
let selection: SelectionOffsets | null = null
const write = debounce(
(draft: PersistedDraft) => {
@@ -68,7 +66,6 @@ export async function makeDraftStorage(rpc: RpcClient, sessionId: string): Promi
text: draft.text,
selectionStart: offsets.start,
selectionEnd: offsets.end,
- grabs: draft.grabs,
attachments: draft.attachments,
})
.catch(() => {})
diff --git a/apps/conciv/test/chat-pane.browser.test.tsx b/apps/conciv/test/chat-pane.browser.test.tsx
index 9dd7000f5..5bdbf66a5 100644
--- a/apps/conciv/test/chat-pane.browser.test.tsx
+++ b/apps/conciv/test/chat-pane.browser.test.tsx
@@ -3,7 +3,7 @@ import {afterAll, afterEach, beforeAll, expect, test} from 'vitest'
import {page, userEvent} from 'vitest/browser'
import type {RpcClient} from '@conciv/contract'
import pageExtension from '@conciv/extension-page/client'
-import {GRAB_FILE_NAME} from '@conciv/grab/grab-attachment'
+import {GRAB_FILE_NAME, GRAB_MIME} from '@conciv/grab/grab-attachment'
import type {Grab} from '@conciv/grab'
import {ChatPane} from '../src/pane/chat-pane.js'
import {coreControl} from './helpers/core-control.js'
@@ -51,7 +51,10 @@ function grabOptions(...grabs: Grab[]): Pick = {}): PaneMount {
+function mountChatPane(
+ sessionId: string,
+ options: Pick = {},
+): PaneMount {
const mount = mountPane({base: core.base, sessionId, ...options}, () => )
active.pane = mount
return mount
@@ -109,7 +112,9 @@ test('a staged grab keeps its snapshot and source label across a panel reload',
const first = mountChatPane(sessionId, grabOptions(HERO_GRAB))
await stageGrabThroughComposer()
- expect(await coreControl.awaitRpcCall(DRAFTS_SET_PATH, since)).toBe(200)
+ expect(await coreControl.awaitRpcCall(DRAFTS_SET_PATH, since, {sessionId})).toBe(200)
+ const persisted = await coreRpc(core.base).drafts.get({sessionId})
+ expect(persisted?.attachments.map((attachment) => attachment.contentType)).toEqual([GRAB_MIME])
first.dispose()
mountChatPane(sessionId, {extensions: [pageExtension]})
diff --git a/apps/conciv/test/commands/core-control.ts b/apps/conciv/test/commands/core-control.ts
index 677a1b716..518152707 100644
--- a/apps/conciv/test/commands/core-control.ts
+++ b/apps/conciv/test/commands/core-control.ts
@@ -174,8 +174,18 @@ const rpcCallCount: BrowserCommand<[string[]]> = (ctx, path): number => observer
const rpcMark: BrowserCommand<[]> = (ctx): number => observerOf(ctx).mark()
-const awaitRpcCall: BrowserCommand<[string[], number]> = async (ctx, path, since): Promise => {
- const record = await observerOf(ctx).completed({path, since, timeout: AWAIT_RPC_TIMEOUT_MS})
+const awaitRpcCall: BrowserCommand<[string[], number, unknown?]> = async (
+ ctx,
+ path,
+ since,
+ input,
+): Promise => {
+ const record = await observerOf(ctx).completed({
+ path,
+ since,
+ timeout: AWAIT_RPC_TIMEOUT_MS,
+ ...(input === undefined ? {} : {input}),
+ })
return record.status
}
diff --git a/apps/conciv/test/draft-storage.test.ts b/apps/conciv/test/draft-storage.test.ts
index bd6582750..c09631805 100644
--- a/apps/conciv/test/draft-storage.test.ts
+++ b/apps/conciv/test/draft-storage.test.ts
@@ -8,7 +8,7 @@ import {proxyTo, type ProxyCore} from './helpers/proxy.js'
const DRAFTS_GET_PATH = '/rpc/drafts/get'
const DRAFTS_SET_PATH = '/rpc/drafts/set'
-type StoredDraft = {sessionId: string; text: string; selectionStart: number; selectionEnd: number; grabs: string[]}
+type StoredDraft = {sessionId: string; text: string; selectionStart: number; selectionEnd: number}
const test = baseTest.extend<{kit: CoreKit; core: ProxyCore; sessionId: string}>({
kit: [
@@ -30,8 +30,8 @@ const test = baseTest.extend<{kit: CoreKit; core: ProxyCore; sessionId: string}>
},
})
-function composerDraft(text: string, grabs: string[] = []): string {
- return JSON.stringify({text, quote: null, grabs, attachments: []})
+function composerDraft(text: string): string {
+ return JSON.stringify({text, quote: null, attachments: []})
}
async function storedDraft(rpc: RpcClient, sessionId: string): Promise {
@@ -42,19 +42,17 @@ async function storedDraft(rpc: RpcClient, sessionId: string): Promise {
- await seedDraft(kit.rpc, sessionId, {text: 'kept across the reload', grabs: ['a grabbed heading']})
+ await seedDraft(kit.rpc, sessionId, {text: 'kept across the reload'})
const draftStorage = await makeDraftStorage(makeRpcClient(core.base), sessionId)
expect(JSON.parse(draftStorage.storage.getItem('any') ?? '')).toEqual({
text: 'kept across the reload',
quote: null,
- grabs: ['a grabbed heading'],
attachments: [],
})
})
@@ -68,7 +66,7 @@ test('starts empty when the server has no draft', async ({core, sessionId}) => {
test('writes the composer draft back to the server with the caret at the end', async ({kit, core, sessionId}) => {
const draftStorage = await makeDraftStorage(makeRpcClient(core.base), sessionId)
- draftStorage.storage.setItem('any', composerDraft('a fresh draft', ['a heading']))
+ draftStorage.storage.setItem('any', composerDraft('a fresh draft'))
await core.awaitRequest(DRAFTS_SET_PATH)
@@ -77,7 +75,6 @@ test('writes the composer draft back to the server with the caret at the end', a
text: 'a fresh draft',
selectionStart: 13,
selectionEnd: 13,
- grabs: ['a heading'],
})
})
@@ -95,7 +92,6 @@ test('collapses a burst of writes into the last draft', async ({kit, core, sessi
text: 'abc',
selectionStart: 3,
selectionEnd: 3,
- grabs: [],
})
expect(core.requestCount(DRAFTS_SET_PATH)).toBe(1)
})
@@ -115,7 +111,6 @@ test('keeps the latest value readable even when the payload cannot be persisted'
text: 'a draft that parses',
selectionStart: 19,
selectionEnd: 19,
- grabs: [],
})
expect(core.requestCount(DRAFTS_SET_PATH)).toBe(1)
})
@@ -133,7 +128,6 @@ test('persists the noted caret offsets with the draft text', async ({kit, core,
text: 'say hello!',
selectionStart: 4,
selectionEnd: 4,
- grabs: [],
})
})
@@ -150,12 +144,11 @@ test('clamps noted offsets that fall beyond the persisted text', async ({kit, co
text: 'short',
selectionStart: 5,
selectionEnd: 5,
- grabs: [],
})
})
test('appends to the stored draft on a new line with the caret at the end', async ({kit, core, sessionId}) => {
- await seedDraft(kit.rpc, sessionId, {text: 'a first line', grabs: ['a heading']})
+ await seedDraft(kit.rpc, sessionId, {text: 'a first line'})
await appendDraft(makeRpcClient(core.base), sessionId, 'a second line')
@@ -164,7 +157,6 @@ test('appends to the stored draft on a new line with the caret at the end', asyn
text: 'a first line\na second line',
selectionStart: 26,
selectionEnd: 26,
- grabs: ['a heading'],
})
})
@@ -179,10 +171,7 @@ const PERSISTED = {
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]}),
- )
+ first.storage.setItem(sessionId, JSON.stringify({text: 'look at this', quote: null, attachments: [PERSISTED]}))
await core.awaitRequest(DRAFTS_SET_PATH)
expect((await kit.rpc.drafts.get({sessionId}))?.attachments).toEqual([PERSISTED])
@@ -211,6 +200,5 @@ test('survives a failed initial read and still accepts writes', async ({kit, cor
text: 'after the outage',
selectionStart: 16,
selectionEnd: 16,
- grabs: [],
})
})
diff --git a/apps/conciv/test/helpers/core-control.ts b/apps/conciv/test/helpers/core-control.ts
index 0f805a36f..b58704d14 100644
--- a/apps/conciv/test/helpers/core-control.ts
+++ b/apps/conciv/test/helpers/core-control.ts
@@ -16,7 +16,7 @@ declare module 'vitest/internal/browser' {
terminalLaunches: () => Promise
rpcCallCount: (path: string[]) => Promise
rpcMark: () => Promise
- awaitRpcCall: (path: string[], since: number) => Promise
+ awaitRpcCall: (path: string[], since: number, input?: unknown) => Promise
installFault: (spec: FaultSpec) => Promise
releaseFault: (handle: string) => Promise
faultPending: (handle: string) => Promise
diff --git a/apps/conciv/test/helpers/core-session.ts b/apps/conciv/test/helpers/core-session.ts
index b8e74259c..0e7c8563a 100644
--- a/apps/conciv/test/helpers/core-session.ts
+++ b/apps/conciv/test/helpers/core-session.ts
@@ -1,7 +1,7 @@
import {EventType} from '@tanstack/ai'
import {makeRpcClient, type PersistedAttachment, type RpcClient} from '@conciv/contract'
-export type SeededDraft = {text?: string; grabs?: string[]; attachments?: PersistedAttachment[]}
+export type SeededDraft = {text?: string; attachments?: PersistedAttachment[]}
export type TranscriptStream = {awaitTurnEnd: () => Promise; close: () => void}
@@ -21,7 +21,6 @@ export async function seedDraft(rpc: RpcClient, sessionId: string, draft: Seeded
text,
selectionStart: text.length,
selectionEnd: text.length,
- grabs: draft.grabs ?? [],
attachments: draft.attachments ?? [],
})
}
diff --git a/packages/contract/src/rows.ts b/packages/contract/src/rows.ts
index 97a1516fc..ae5fec623 100644
--- a/packages/contract/src/rows.ts
+++ b/packages/contract/src/rows.ts
@@ -33,7 +33,6 @@ export const DraftRowSchema = z.object({
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()
diff --git a/packages/contract/test/rows.test.ts b/packages/contract/test/rows.test.ts
index bc37338be..2b6bc97af 100644
--- a/packages/contract/test/rows.test.ts
+++ b/packages/contract/test/rows.test.ts
@@ -22,15 +22,15 @@ describe('row schemas', () => {
expect(() => MarkerRowSchema.parse({id: 'm1', sessionId: 'conciv_1', afterTurn: 2, kind: 'weird'})).toThrow()
})
- it('drafts are explicit about selection and grabs', () => {
+ it('drafts are explicit about selection and default their attachments', () => {
const draft = DraftRowSchema.parse({
sessionId: 'conciv_1',
text: 'hi',
selectionStart: 2,
selectionEnd: 2,
- grabs: [''],
updatedAt: 5,
})
- expect(draft.grabs).toEqual([''])
+ expect(draft.selectionEnd).toBe(2)
+ expect(draft.attachments).toEqual([])
})
})
diff --git a/packages/core/src/chat/run.ts b/packages/core/src/chat/run.ts
index d77fdb2c7..74602c972 100644
--- a/packages/core/src/chat/run.ts
+++ b/packages/core/src/chat/run.ts
@@ -451,15 +451,6 @@ export async function expandUserParts(content: UserContent, expanders: Attachmen
return expanded
}
-async function composeUserContent(db: ConcivDb, sessionId: string, content: UserContent): Promise {
- const rows = await db.select({grabs: drafts.grabs}).from(drafts).where(eq(drafts.sessionId, sessionId))
- const grabs = rows[0]?.grabs ?? []
- if (grabs.length === 0) return content
- const prefix = grabs.join('\n')
- if (typeof content === 'string') return content ? `${prefix}\n${content}` : prefix
- return [{type: 'text', content: `${prefix}\n`}, ...content]
-}
-
export type Send = (sessionId: string, runId: string, content: UserContent) => Promise
const RUN_ID_TAKEN_ERROR_NAME = 'RunIdTakenError'
@@ -477,8 +468,7 @@ export function isRunIdTakenError(error: unknown): error is Error {
async function prepareLaunchContent(deps: ChatDeps, sessionId: string, content: UserContent): Promise {
deps.onRunStart?.(sessionId)
await ensureRow(deps.db, sessionId, deps.harness.id, deps.cwd)
- const userContent = await composeUserContent(deps.db, sessionId, content)
- return expandUserParts(userContent, deps.attachmentExpanders)
+ return expandUserParts(content, deps.attachmentExpanders)
}
async function settleLiveRuns(deps: ChatDeps, sessionId: string): Promise {
diff --git a/packages/core/test/rpc/wire.it.test.ts b/packages/core/test/rpc/wire.it.test.ts
index 69c735db5..aef4ee6ac 100644
--- a/packages/core/test/rpc/wire.it.test.ts
+++ b/packages/core/test/rpc/wire.it.test.ts
@@ -71,7 +71,7 @@ describe('rpc over the wire (real app, real http, typed client)', () => {
expect(types).toContain(EventType.RUN_STARTED)
})
- it('send consumes the server-side draft: grabs prefix the turn, row cleared', async () => {
+ it('send consumes the server-side draft: the turn is the user text alone and the row is cleared', async () => {
const {kit} = await bootWire()
const sessionId = await kit.session()
const stream = await kit.attach(sessionId)
@@ -80,7 +80,6 @@ describe('rpc over the wire (real app, real http, typed client)', () => {
text: 'draft-text',
selectionStart: 0,
selectionEnd: 0,
- grabs: [''],
})
await kit.rpc.chat.send({runId: 'wire-5', sessionId, text: 'about the grabbed element'})
await stream.done({hangGuardMs: 10_000})
@@ -89,12 +88,11 @@ describe('rpc over the wire (real app, real http, typed client)', () => {
)
const firstPart = partsOf(visibleUser)[0]
const text = isRecord(firstPart) && typeof firstPart.content === 'string' ? firstPart.content : ''
- expect(text.startsWith('\n')).toBe(true)
- expect(text).toContain('about the grabbed element')
+ expect(text).toBe('about the grabbed element')
expect(await kit.rpc.drafts.get({sessionId})).toBeNull()
})
- it('send forwards multimodal content and keeps grab references as a text prefix', async () => {
+ it('send forwards multimodal content untouched by the draft row', async () => {
const {kit} = await bootWire()
const sessionId = await kit.session()
const stream = await kit.attach(sessionId)
@@ -103,7 +101,6 @@ describe('rpc over the wire (real app, real http, typed client)', () => {
text: 'draft-text',
selectionStart: 0,
selectionEnd: 0,
- grabs: [''],
})
await kit.rpc.chat.send({
runId: 'wire-12',
@@ -120,7 +117,6 @@ describe('rpc over the wire (real app, real http, typed client)', () => {
if (!isRecord(visibleUser) || !('parts' in visibleUser))
throw new Error('stream did not include the user message parts')
expect(visibleUser.parts).toEqual([
- {type: 'text', content: '\n'},
{type: 'text', content: 'what color is this? '},
{type: 'image', source: {type: 'data', mimeType: 'image/png', value: 'iVBORw0KGgo='}},
])
diff --git a/packages/db/drizzle/20260815111148_drop_draft_grabs/migration.sql b/packages/db/drizzle/20260815111148_drop_draft_grabs/migration.sql
new file mode 100644
index 000000000..1640a0151
--- /dev/null
+++ b/packages/db/drizzle/20260815111148_drop_draft_grabs/migration.sql
@@ -0,0 +1 @@
+ALTER TABLE `drafts` DROP COLUMN `grabs`;
\ No newline at end of file
diff --git a/packages/db/drizzle/20260815111148_drop_draft_grabs/snapshot.json b/packages/db/drizzle/20260815111148_drop_draft_grabs/snapshot.json
new file mode 100644
index 000000000..fe65b6db2
--- /dev/null
+++ b/packages/db/drizzle/20260815111148_drop_draft_grabs/snapshot.json
@@ -0,0 +1,685 @@
+{
+ "version": "7",
+ "dialect": "sqlite",
+ "id": "f20c4be1-5519-42e9-a8f3-d4727da4f28b",
+ "prevIds": ["bd003028-f108-43e2-a0eb-585be16e9020"],
+ "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": 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 1f90cf820..ae6796e9d 100644
--- a/packages/db/src/schema.ts
+++ b/packages/db/src/schema.ts
@@ -31,7 +31,6 @@ export const drafts = sqliteTable('drafts', {
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(),
})
diff --git a/packages/embed/tests/e2e/native-widget.it.test.ts b/packages/embed/tests/e2e/native-widget.it.test.ts
index 3a2d74728..1cb467f8f 100644
--- a/packages/embed/tests/e2e/native-widget.it.test.ts
+++ b/packages/embed/tests/e2e/native-widget.it.test.ts
@@ -126,9 +126,9 @@ test.describe('native widget bridge', () => {
}) => {
test.setTimeout(120_000)
const {page, bridge, observer} = await openNative(fixturePage)
- const stagedForModel = observer.completed({
+ const persistedGrab = observer.completed({
path: ['drafts', 'set'],
- input: /\[view\][\s\S]*PaymentCardCell/,
+ input: /application\/vnd\.conciv\.grab\+json/,
timeout: 30_000,
})
const picked = Promise.withResolvers()
@@ -148,7 +148,7 @@ test.describe('native widget bridge', () => {
await callNative(page, 'grabResult', {v: 1, seq: 3, requestId: pick?.requestId, grab: NEUTRAL_GRAB})
await expect(panel(page).getByText('PaymentCardCell')).toBeVisible({timeout: 30_000})
await expect(grabPreview(page)).toHaveAttribute('src', IMAGE_DATA_URL)
- expect(JSON.stringify((await stagedForModel).input)).toContain('PaymentCardCell')
+ expect(JSON.stringify((await persistedGrab).input)).toContain('Grabbed element')
})
test('ignores a grabResult whose requestId does not match the pending pick', async ({page: fixturePage}) => {
diff --git a/packages/embed/tests/e2e/navigation-hold.it.test.ts b/packages/embed/tests/e2e/navigation-hold.it.test.ts
index 415dcd4aa..61c5b3d75 100644
--- a/packages/embed/tests/e2e/navigation-hold.it.test.ts
+++ b/packages/embed/tests/e2e/navigation-hold.it.test.ts
@@ -43,7 +43,6 @@ test.describe('holdFirstNavigationWrite holds one websocket frame without stalli
text: 'sent while the navigation frame is held',
selectionStart: 0,
selectionEnd: 0,
- grabs: [],
})
expect(overtaking).toEqual({ok: true})
expect(await currentHref(suite.kit())).toBe('')
diff --git a/packages/embed/tests/e2e/rpc-observer.it.test.ts b/packages/embed/tests/e2e/rpc-observer.it.test.ts
index f75e5c369..fe156d029 100644
--- a/packages/embed/tests/e2e/rpc-observer.it.test.ts
+++ b/packages/embed/tests/e2e/rpc-observer.it.test.ts
@@ -49,7 +49,7 @@ async function openProbePage(page: Page): Promise<{page: Page; observer: ReturnT
}
function draft(sessionId: string, text: string): Record {
- return {sessionId, text, selectionStart: 0, selectionEnd: 0, grabs: []}
+ return {sessionId, text, selectionStart: 0, selectionEnd: 0}
}
test.describe('the shared rpc observer correlates calls on both transports', () => {
diff --git a/packages/ui-kit-chat/src/behaviors/composer-draft-storage.ts b/packages/ui-kit-chat/src/behaviors/composer-draft-storage.ts
index 63ffbeb89..a84a8ab3b 100644
--- a/packages/ui-kit-chat/src/behaviors/composer-draft-storage.ts
+++ b/packages/ui-kit-chat/src/behaviors/composer-draft-storage.ts
@@ -16,7 +16,6 @@ const PersistedAttachmentSchema = z.object({
const PersistedDraftSchema = z.object({
text: z.string().catch(''),
quote: z.string().nullable().catch(null),
- grabs: z.array(z.string()).catch([]),
attachments: z.array(PersistedAttachmentSchema).catch([]),
})
@@ -66,7 +65,6 @@ export function readComposerDraft(storage: WebStorage, key: string): ComposerDra
return {
draft: parsed.data.text,
quote: parsed.data.quote,
- grabs: parsed.data.grabs,
attachments: parsed.data.attachments.flatMap(toPendingAttachment),
}
}
@@ -101,7 +99,6 @@ export async function writeComposerDraft(storage: WebStorage, key: string, draft
const payload: PersistedComposerDraft = {
text: draft.draft,
quote: draft.quote,
- grabs: [...draft.grabs],
attachments: await encodeAttachments(draft.attachments),
}
try {
diff --git a/packages/ui-kit-chat/src/primitives/attachment/attachment.stories.tsx b/packages/ui-kit-chat/src/primitives/attachment/attachment.stories.tsx
index d3a94fba2..c222c19d9 100644
--- a/packages/ui-kit-chat/src/primitives/attachment/attachment.stories.tsx
+++ b/packages/ui-kit-chat/src/primitives/attachment/attachment.stories.tsx
@@ -40,13 +40,11 @@ function Harness(): JSX.Element {
setAttachments((prev) => prev.filter((attachment) => attachment.id !== id))
},
sendingAttachments: () => false,
- snapshotDraft: () => ({draft: '', attachments: [], quote: null, grabs: []}),
+ snapshotDraft: () => ({draft: '', attachments: [], quote: null}),
restoreDraft: () => {},
clearDraft: () => {},
quote: () => null,
setQuote: () => {},
- grabs: () => [],
- setGrabs: () => {},
editing: () => false,
setEditing: () => {},
dictating: () => false,
diff --git a/packages/ui-kit-chat/src/primitives/composer/composer-context.tsx b/packages/ui-kit-chat/src/primitives/composer/composer-context.tsx
index 98e06f81e..b3b9a0c4d 100644
--- a/packages/ui-kit-chat/src/primitives/composer/composer-context.tsx
+++ b/packages/ui-kit-chat/src/primitives/composer/composer-context.tsx
@@ -1,7 +1,7 @@
import {createContext, useContext, type Accessor} from 'solid-js'
import type {Attachment, AttachmentAdapter} from '../attachment/attachment-adapter.js'
-export type ComposerDraft = {draft: string; attachments: Attachment[]; quote: string | null; grabs: string[]}
+export type ComposerDraft = {draft: string; attachments: Attachment[]; quote: string | null}
export type ComposerContextValue = {
attachments: Accessor
@@ -16,8 +16,6 @@ export type ComposerContextValue = {
clearDraft: () => void
quote: Accessor
setQuote: (value: string | null) => void
- grabs: Accessor
- setGrabs: (values: string[]) => void
editing: Accessor
setEditing: (value: boolean) => void
dictating: Accessor
diff --git a/packages/ui-kit-chat/src/primitives/composer/composer.tsx b/packages/ui-kit-chat/src/primitives/composer/composer.tsx
index 6c45ee188..102200788 100644
--- a/packages/ui-kit-chat/src/primitives/composer/composer.tsx
+++ b/packages/ui-kit-chat/src/primitives/composer/composer.tsx
@@ -43,7 +43,6 @@ type FormProps = JSX.HTMLAttributes & {
type ComposerState = {
attachments: Attachment[]
quote: string | null
- grabs: string[]
editing: boolean
dictating: boolean
sendingAttachments: boolean
@@ -188,7 +187,6 @@ function Root(props: FormProps): JSX.Element {
const [state, setState] = createStore({
attachments: [],
quote: null,
- grabs: [],
editing: false,
dictating: false,
sendingAttachments: false,
@@ -260,27 +258,26 @@ function Root(props: FormProps): JSX.Element {
draft: chat.view.draft,
attachments: [...state.attachments],
quote: state.quote,
- grabs: [...state.grabs],
})
const restoreDraft = (original: ComposerDraft) => {
setState('attachments', (current) => {
const currentIds = new Set(current.map((value) => value.id))
return [...current, ...original.attachments.filter((value) => !currentIds.has(value.id))]
})
- if (chat.view.draft !== '' || state.quote !== null || state.grabs.length > 0) return
+ if (chat.view.draft !== '' || state.quote !== null) return
chat.setView('draft', original.draft)
- setState({quote: original.quote, grabs: original.grabs})
+ setState({quote: original.quote})
}
const clearDraft = () => {
chat.setView('draft', '')
- setState({attachments: [], quote: null, grabs: []})
+ setState({attachments: [], quote: null})
}
onMount(() => {
const storage = local.draftStorage
const restored = storage ? readComposerDraft(storage, draftKey()) : null
if (!restored) return
chat.setView('draft', restored.draft)
- setState({attachments: restored.attachments, quote: restored.quote, grabs: restored.grabs})
+ setState({attachments: restored.attachments, quote: restored.quote})
})
useComposerDraftPersistence({storage: () => local.draftStorage, key: draftKey, draft: snapshotDraft})
const markSendFailed = (error: unknown) => {
@@ -336,8 +333,6 @@ function Root(props: FormProps): JSX.Element {
clearDraft,
quote: () => state.quote,
setQuote: (value) => setState('quote', value),
- grabs: () => state.grabs,
- setGrabs: (values) => setState('grabs', values),
editing: () => state.editing,
setEditing: (value) => setState('editing', value),
dictating: () => state.dictating,
diff --git a/packages/ui-kit-chat/src/styled/attachment-dispatch.stories.tsx b/packages/ui-kit-chat/src/styled/attachment-dispatch.stories.tsx
index dce8bc881..6ea94d7cf 100644
--- a/packages/ui-kit-chat/src/styled/attachment-dispatch.stories.tsx
+++ b/packages/ui-kit-chat/src/styled/attachment-dispatch.stories.tsx
@@ -49,13 +49,11 @@ function Frame(): JSX.Element {
setAttachments((prev) => prev.filter((attachment) => attachment.id !== id))
},
sendingAttachments: () => false,
- snapshotDraft: () => ({draft: '', attachments: [], quote: null, grabs: []}),
+ snapshotDraft: () => ({draft: '', attachments: [], quote: null}),
restoreDraft: () => {},
clearDraft: () => {},
quote: () => null,
setQuote: () => {},
- grabs: () => [],
- setGrabs: () => {},
editing: () => false,
setEditing: () => {},
dictating: () => false,
diff --git a/packages/ui-kit-chat/src/styled/attachment-ui.stories.tsx b/packages/ui-kit-chat/src/styled/attachment-ui.stories.tsx
index 3705e911f..5fe20ae60 100644
--- a/packages/ui-kit-chat/src/styled/attachment-ui.stories.tsx
+++ b/packages/ui-kit-chat/src/styled/attachment-ui.stories.tsx
@@ -42,13 +42,11 @@ function Frame(): JSX.Element {
setAttachments((prev) => prev.filter((attachment) => attachment.id !== id))
},
sendingAttachments: () => false,
- snapshotDraft: () => ({draft: '', attachments: [], quote: null, grabs: []}),
+ snapshotDraft: () => ({draft: '', attachments: [], quote: null}),
restoreDraft: () => {},
clearDraft: () => {},
quote: () => null,
setQuote: () => {},
- grabs: () => [],
- setGrabs: () => {},
editing: () => false,
setEditing: () => {},
dictating: () => false,
diff --git a/packages/ui-kit-chat/test/composer-completion.browser.test.tsx b/packages/ui-kit-chat/test/composer-completion.browser.test.tsx
index 502d90b9b..4cdad531f 100644
--- a/packages/ui-kit-chat/test/composer-completion.browser.test.tsx
+++ b/packages/ui-kit-chat/test/composer-completion.browser.test.tsx
@@ -1,5 +1,5 @@
import 'virtual:uno.css'
-import {createSignal, For, Show, untrack, type Accessor, type JSX, type ParentProps} from 'solid-js'
+import {createSignal, Show, untrack, type Accessor, type JSX, type ParentProps} from 'solid-js'
import {page} from 'vitest/browser'
import {expect, it} from 'vitest'
import {useChat} from '@tanstack/ai-solid'
@@ -8,7 +8,6 @@ import {ChatProvider} from '../src/store/chat-context.js'
import {createTextChunks, storyConnection, type StoryConnectionOptions} from '../src/store/story-connection.js'
import {Composer as ComposerPrimitive} from '../src/primitives/composer/composer.js'
import {ComposerHandlersProvider, type ComposerHandlers} from '../src/primitives/composer/composer-handlers.js'
-import {useComposerContext} from '../src/primitives/composer/composer-context.js'
import {Error as ErrorPrimitive} from '../src/primitives/error/error.js'
import {mountView} from './mount-view.js'
@@ -35,23 +34,11 @@ function ChatHost(props: ParentProps<{connection?: StoryConnectionOptions; handl
)
}
-function GrabProbe(): JSX.Element {
- const context = useComposerContext()
- return (
-
- {(grab) => - grab: {grab}
}
-
- )
-}
-
-function ComposerHarness(props: {storage?: WebStorage; showGrabs?: boolean; showRefresh?: boolean}): JSX.Element {
+function ComposerHarness(props: {storage?: WebStorage; showRefresh?: boolean}): JSX.Element {
return (
-
-
-
Send
Refresh
@@ -63,11 +50,10 @@ function ComposerHarness(props: {storage?: WebStorage; showGrabs?: boolean; show
)
}
-function seeded(draft: {text?: string; quote?: string | null; grabs?: string[]}): string {
+function seeded(draft: {text?: string; quote?: string | null}): string {
return JSON.stringify({
text: draft.text ?? '',
quote: draft.quote ?? null,
- grabs: draft.grabs ?? [],
attachments: [],
})
}
@@ -88,19 +74,16 @@ it('persists the typed draft into the supplied storage', async () => {
.toBeVisible()
})
-it('restores a persisted draft, quote and grabs when the composer mounts', async () => {
- const probe = createProbeStorage(
- seeded({text: 'kept across the reload', quote: 'a quoted line', grabs: ['a grabbed heading']}),
- )
+it('restores a persisted draft and quote when the composer mounts', async () => {
+ const probe = createProbeStorage(seeded({text: 'kept across the reload', quote: 'a quoted line'}))
mountView(() => (
-
+
))
await expect.element(page.getByRole('textbox', {name: 'Message'})).toHaveValue('kept across the reload')
await expect.element(page.getByText('a quoted line')).toBeVisible()
- await expect.element(page.getByText('grab: a grabbed heading')).toBeVisible()
})
it('restores the draft when the send handler rejects', async () => {
From aee75f9169dab40047d541e41ac39e7fa9229065 Mon Sep 17 00:00:00 2001
From: Omri Katz <9701896+omridevk@users.noreply.github.com>
Date: Sat, 15 Aug 2026 17:05:30 +0300
Subject: [PATCH 17/22] fix(page): #487 correct fallback font metrics with
capture-time letter-spacing
A grab snapshot renders in a sandboxed srcdoc frame that carries no
@font-face, so any family the page loaded as a webfont is unresolvable
there and the text reflows inside boxes frozen at their captured pixel
widths. Measured on the reported page: the badge run is 212.66px live and
227.70px in the frame (+7.1%), the pill run 102.69px against 107.03px
(+4.2%), while every box matches to 0.01px.
Embedding the real faces is not possible. FontFace exposes no src, and a
cross-origin font stylesheet throws SecurityError on cssRules, which is
exactly what the reported page uses, so the bytes are unreachable.
size-adjust does not help either: src local() never resolves inside these
frames, in a plain one as much as a guarded one, so the descriptor has
nothing to attach to.
Capture now measures each text run twice, live in the host where the real
font is loaded and again in an offscreen frame that resolves fonts the way
the snapshot frame will, calibrates how much width one pixel of tracking
buys, and inlines the letter-spacing that reproduces the live advance. Line
breaks land where they landed on the page; glyph shapes stay the fallback's.
Runs whose family carries no webfont, and deltas under half a pixel, are
left alone.
Co-Authored-By: Claude Fable 5
---
packages/page/package.json | 1 +
.../page/src/react-grab/capture-element.ts | 21 +++-
.../page/src/react-grab/fallback-metrics.ts | 110 +++++++++++++++++
.../test/fallback-metrics.browser.test.ts | 116 ++++++++++++++++++
.../page/test/fixtures/inline-assets.d.ts | 4 +
pnpm-lock.yaml | 3 +
6 files changed, 249 insertions(+), 6 deletions(-)
create mode 100644 packages/page/src/react-grab/fallback-metrics.ts
create mode 100644 packages/page/test/fallback-metrics.browser.test.ts
create mode 100644 packages/page/test/fixtures/inline-assets.d.ts
diff --git a/packages/page/package.json b/packages/page/package.json
index eaf77b32f..3b6122058 100644
--- a/packages/page/package.json
+++ b/packages/page/package.json
@@ -42,6 +42,7 @@
},
"devDependencies": {
"@conciv/vitest-config": "workspace:*",
+ "@fontsource-variable/wix-madefor-text": "^5.2.8",
"@types/node": "^26.1.0",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
diff --git a/packages/page/src/react-grab/capture-element.ts b/packages/page/src/react-grab/capture-element.ts
index fbf2101fc..3b6e16d72 100644
--- a/packages/page/src/react-grab/capture-element.ts
+++ b/packages/page/src/react-grab/capture-element.ts
@@ -1,17 +1,21 @@
import type {DomPreview} from '@conciv/grab'
+import {correctFallbackMetrics, type TextRun} from './fallback-metrics.js'
-export function captureElement(el: Element): Promise {
+function nextFrame(): Promise {
return new Promise((resolve) => {
- requestAnimationFrame(() => resolve(captureSync(el)))
+ requestAnimationFrame(() => resolve())
})
}
-function captureSync(el: Element): DomPreview {
+export async function captureElement(el: Element): Promise {
+ await nextFrame()
const rect = el.getBoundingClientRect()
const clone = el.cloneNode(true)
const rules: string[] = []
+ const runs: TextRun[] = []
if (!(clone instanceof HTMLElement)) return {kind: 'dom', html: '', width: rect.width, height: rect.height}
- inlineComputedStyles(el, clone, rules)
+ inlineComputedStyles(el, clone, rules, runs)
+ await correctFallbackMetrics(runs)
neutralizeLayout(clone)
clone.removeAttribute('id')
@@ -28,7 +32,11 @@ function captureSync(el: Element): DomPreview {
const SKIP_PROPS = new Set(['cursor', 'pointer-events', 'user-select', '-webkit-user-select'])
-function inlineComputedStyles(src: Element, dst: HTMLElement, rules: string[]): void {
+function isTextRun(node: Element): boolean {
+ return node.children.length === 0 && (node.textContent ?? '').trim() !== ''
+}
+
+function inlineComputedStyles(src: Element, dst: HTMLElement, rules: string[], runs: TextRun[]): void {
const cs = getComputedStyle(src)
let cssText = ''
for (const prop of cs) {
@@ -37,12 +45,13 @@ function inlineComputedStyles(src: Element, dst: HTMLElement, rules: string[]):
}
dst.style.cssText = cssText
capturePseudo(src, dst, rules)
+ if (src instanceof HTMLElement && isTextRun(src)) runs.push({source: src, clone: dst})
const sk = src.children
const dk = dst.children
for (let i = 0; i < sk.length; i++) {
const childSrc = sk[i]
const childDst = dk[i]
- if (childSrc && childDst) inlineComputedStyles(childSrc, childDst as HTMLElement, rules)
+ if (childSrc && childDst) inlineComputedStyles(childSrc, childDst as HTMLElement, rules, runs)
}
}
diff --git a/packages/page/src/react-grab/fallback-metrics.ts b/packages/page/src/react-grab/fallback-metrics.ts
new file mode 100644
index 000000000..239c80398
--- /dev/null
+++ b/packages/page/src/react-grab/fallback-metrics.ts
@@ -0,0 +1,110 @@
+const MEASURED_PROPS = [
+ 'font-family',
+ 'font-size',
+ 'font-style',
+ 'font-weight',
+ 'font-stretch',
+ 'font-variant',
+ 'font-feature-settings',
+ 'font-variation-settings',
+ 'font-kerning',
+ 'font-optical-sizing',
+ 'font-synthesis-weight',
+ 'font-synthesis-style',
+ 'word-spacing',
+ 'text-transform',
+]
+
+const EPSILON_PX = 0.5
+
+const PROBE_STEP_PX = 1
+
+export type TextRun = {source: HTMLElement; clone: HTMLElement}
+
+type Measurer = {doc: Document; close: () => void}
+
+function unquoted(value: string): string {
+ return value.trim().replace(/^["']|["']$/g, '')
+}
+
+function familiesOf(value: string): string[] {
+ return value
+ .split(',')
+ .map(unquoted)
+ .filter((name) => name !== '')
+}
+
+function webfontFamilies(): Set {
+ const families = new Set()
+ document.fonts.forEach((face) => families.add(unquoted(face.family)))
+ return families
+}
+
+function letterSpacingOf(styles: CSSStyleDeclaration): number {
+ const parsed = Number.parseFloat(styles.letterSpacing)
+ return Number.isFinite(parsed) ? parsed : 0
+}
+
+function measurementCss(styles: CSSStyleDeclaration, letterSpacing: number): string {
+ const declarations = MEASURED_PROPS.map((prop) => `${prop}:${styles.getPropertyValue(prop)}`).join(';')
+ return `position:absolute;top:0;left:0;white-space:nowrap;visibility:hidden;${declarations};letter-spacing:${letterSpacing}px`
+}
+
+function measure(doc: Document, css: string, text: string): number {
+ const span = doc.createElement('span')
+ span.style.cssText = css
+ span.textContent = text
+ doc.body.appendChild(span)
+ const width = span.getBoundingClientRect().width
+ span.remove()
+ return width
+}
+
+async function openMeasurer(): Promise {
+ const frame = document.createElement('iframe')
+ frame.setAttribute('aria-hidden', 'true')
+ frame.setAttribute('tabindex', '-1')
+ frame.style.cssText = 'position:fixed;left:-10000px;top:0;width:800px;height:200px;border:0;visibility:hidden'
+ frame.srcdoc = ''
+ document.body.appendChild(frame)
+ await new Promise((resolve) => {
+ frame.addEventListener('load', () => resolve(), {once: true})
+ })
+ const doc = frame.contentDocument
+ if (!doc) {
+ frame.remove()
+ return null
+ }
+ return {doc, close: () => frame.remove()}
+}
+
+function correctionFor(measurer: Measurer, run: TextRun, text: string): number | null {
+ const styles = getComputedStyle(run.source)
+ if (!familiesOf(styles.fontFamily).some((family) => webfontFamilies().has(family))) return null
+ const base = letterSpacingOf(styles)
+ const css = measurementCss(styles, base)
+ const live = measure(document, css, text)
+ const fallback = measure(measurer.doc, css, text)
+ if (Math.abs(live - fallback) < EPSILON_PX) return null
+ const stepped = measure(measurer.doc, measurementCss(styles, base + PROBE_STEP_PX), text)
+ const slots = (stepped - fallback) / PROBE_STEP_PX
+ if (slots <= 0) return null
+ return base + (live - fallback) / slots
+}
+
+export async function correctFallbackMetrics(runs: readonly TextRun[]): Promise {
+ if (runs.length === 0 || document.fonts.size === 0) return
+ const measurer = await openMeasurer()
+ if (!measurer) return
+ try {
+ for (const run of runs) {
+ const text = run.source.textContent ?? ''
+ if (text.trim() === '') continue
+ const corrected = correctionFor(measurer, run, text)
+ if (corrected === null) continue
+ run.clone.style.letterSpacing = `${corrected}px`
+ }
+ } finally {
+ measurer.close()
+ }
+}
diff --git a/packages/page/test/fallback-metrics.browser.test.ts b/packages/page/test/fallback-metrics.browser.test.ts
new file mode 100644
index 000000000..d0c77f212
--- /dev/null
+++ b/packages/page/test/fallback-metrics.browser.test.ts
@@ -0,0 +1,116 @@
+import {afterEach, beforeAll, expect, test} from 'vitest'
+import pageFont from '@fontsource-variable/wix-madefor-text/files/wix-madefor-text-latin-wght-normal.woff2?inline'
+import {captureElement} from '../src/react-grab/capture-element.js'
+
+const CSP = "default-src 'none'; img-src data:; style-src 'unsafe-inline' data:; font-src data:"
+
+const PAGE_FAMILY = "'PageBrand', monospace"
+
+const BADGE = 'TanStack Start base template'
+
+const PILL = 'About This Starter'
+
+const frames: HTMLIFrameElement[] = []
+
+const hosts: HTMLElement[] = []
+
+beforeAll(async () => {
+ const style = document.createElement('style')
+ style.textContent = `@font-face{font-family:'PageBrand';src:url(${pageFont}) format('woff2');font-weight:100 900;font-display:block}`
+ document.head.appendChild(style)
+ await document.fonts.load('700 11px PageBrand')
+ await document.fonts.load('600 12px PageBrand')
+ await document.fonts.ready
+ if (!document.fonts.check('700 11px PageBrand')) throw new Error('the page webfont did not load')
+})
+
+afterEach(() => {
+ for (const frame of frames.splice(0)) frame.remove()
+ for (const host of hosts.splice(0)) host.remove()
+})
+
+async function renderSnapshot(html: string, width: number, height: number): Promise {
+ const frame = document.createElement('iframe')
+ frame.setAttribute('sandbox', 'allow-same-origin')
+ frame.width = String(Math.ceil(width))
+ frame.height = String(Math.ceil(height))
+ frame.srcdoc = `${html}`
+ document.body.appendChild(frame)
+ frames.push(frame)
+ await new Promise((resolve) => {
+ frame.addEventListener('load', () => resolve(), {once: true})
+ })
+ const doc = frame.contentDocument
+ if (!doc) throw new Error('the snapshot frame has no document')
+ await doc.fonts.ready
+ return doc
+}
+
+function mountReplica(): HTMLElement {
+ const host = document.createElement('div')
+ host.style.cssText = `position:absolute;top:0;left:0;font-family:${PAGE_FAMILY};background:#fff;padding:16px`
+ host.innerHTML = `
+ ${BADGE}
+ ${PILL}
+ `
+ document.body.appendChild(host)
+ hosts.push(host)
+ return host
+}
+
+function lineCount(root: ParentNode, role: string): number {
+ const element = root.querySelector(`[data-role="${role}"]`)
+ if (!element) throw new Error(`no element for role ${role}`)
+ const text = element.firstChild
+ if (!text) throw new Error(`no text node for role ${role}`)
+ const range = (element.ownerDocument ?? document).createRange()
+ range.selectNodeContents(element)
+ return range.getClientRects().length
+}
+
+function textWidth(root: ParentNode, role: string): number {
+ const element = root.querySelector(`[data-role="${role}"]`)
+ if (!element) throw new Error(`no element for role ${role}`)
+ const range = (element.ownerDocument ?? document).createRange()
+ range.selectNodeContents(element)
+ return [...range.getClientRects()].reduce((total, rect) => total + rect.width, 0)
+}
+
+test('a captured run keeps its line count when the frame falls back to another font', async () => {
+ const host = mountReplica()
+ await document.fonts.ready
+ expect(lineCount(host, 'badge')).toBe(1)
+ expect(lineCount(host, 'pill')).toBe(1)
+
+ const preview = await captureElement(host)
+ const snapshot = await renderSnapshot(preview.html, preview.width + 60, preview.height + 120)
+
+ expect(lineCount(snapshot, 'badge')).toBe(1)
+ expect(lineCount(snapshot, 'pill')).toBe(1)
+})
+
+test('a run the frame can render itself is left untouched', async () => {
+ const host = document.createElement('div')
+ host.style.cssText = 'position:absolute;top:0;left:0;font-family:monospace;padding:8px'
+ host.innerHTML = `${PILL}`
+ document.body.appendChild(host)
+ hosts.push(host)
+ await document.fonts.ready
+
+ const preview = await captureElement(host)
+
+ expect(preview.html).toContain('letter-spacing: normal')
+})
+
+test('a captured run keeps its advance width when the frame falls back to another font', async () => {
+ const host = mountReplica()
+ await document.fonts.ready
+ const liveBadge = textWidth(host, 'badge')
+ const livePill = textWidth(host, 'pill')
+
+ const preview = await captureElement(host)
+ const snapshot = await renderSnapshot(preview.html, preview.width + 60, preview.height + 120)
+
+ expect(textWidth(snapshot, 'badge')).toBeCloseTo(liveBadge, 0)
+ expect(textWidth(snapshot, 'pill')).toBeCloseTo(livePill, 0)
+})
diff --git a/packages/page/test/fixtures/inline-assets.d.ts b/packages/page/test/fixtures/inline-assets.d.ts
new file mode 100644
index 000000000..b6221a858
--- /dev/null
+++ b/packages/page/test/fixtures/inline-assets.d.ts
@@ -0,0 +1,4 @@
+declare module '*.woff2?inline' {
+ const dataUrl: string
+ export default dataUrl
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 1d1bef800..45925b000 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -3089,6 +3089,9 @@ importers:
'@conciv/vitest-config':
specifier: workspace:*
version: link:../vitest-config
+ '@fontsource-variable/wix-madefor-text':
+ specifier: ^5.2.8
+ version: 5.2.8
'@types/node':
specifier: ^26.1.0
version: 26.1.0
From e7435d39623c8f6fcd564fa3421874c5d06cbc3c Mon Sep 17 00:00:00 2001
From: Omri Katz <9701896+omridevk@users.noreply.github.com>
Date: Sat, 15 Aug 2026 18:22:30 +0300
Subject: [PATCH 18/22] fix(conciv): #487 invalidate a grab placement the
composer no longer wants
putOnComposer and settle held a composer reference across their awaits and
never rechecked it, so a placement that completed after clear() put the grab
straight back on the composer, and one that completed after disconnect() bound
an id from the detached composer and never reached the next one. Both now
recheck liveness after every await, derived from the state that already exists
-- the entry is still pending and the port is still the composer it started on
-- and roll back an attachment they added to a composer that moved on.
entry.placing also cached a rejected promise forever: one transient
addAttachment failure and every later ensureOnComposer returned the same
rejection, so the grab could never attach again, and the background chain left
the rejection unhandled. Placement now clears the slot on every outcome that
leaves the entry without an id, so the next connect retries it.
The module suite drops its fixed twelve-microtask settle helper: every wait is
now an explicit signal from the fake composer or the staged() signal itself.
Reported by CodeRabbit and Copilot on #513.
Co-Authored-By: Claude Fable 5
---
apps/conciv/src/pane/grab-staging.ts | 43 +++--
.../test/grab-staging-module.browser.test.ts | 148 +++++++++++++++---
2 files changed, 160 insertions(+), 31 deletions(-)
diff --git a/apps/conciv/src/pane/grab-staging.ts b/apps/conciv/src/pane/grab-staging.ts
index 56c9edd9f..d81baee04 100644
--- a/apps/conciv/src/pane/grab-staging.ts
+++ b/apps/conciv/src/pane/grab-staging.ts
@@ -3,7 +3,7 @@ import {
GRAB_MIME,
grabToFile,
grabToPayload,
- MAX_PAYLOAD_BYTES,
+ imagePreviewBudget,
parseGrabPayload,
type GrabPayload,
} from '@conciv/grab/grab-attachment'
@@ -49,8 +49,9 @@ function isGrabAttachment(attachment: AttachmentState): boolean {
}
async function fitted(grab: Grab): Promise {
- if (grab.preview.kind !== 'image') return grab
- return {...grab, preview: await fitImagePreview(grab.preview, MAX_PAYLOAD_BYTES)}
+ const preview = grab.preview
+ if (preview.kind !== 'image') return grab
+ return {...grab, preview: await fitImagePreview(preview, imagePreviewBudget(grab, preview))}
}
export function makeGrabStaging(deps: Deps): GrabStaging {
@@ -70,13 +71,25 @@ export function makeGrabStaging(deps: Deps): GrabStaging {
setPending((current) => current.filter((candidate) => candidate !== entry))
}
+ const isLive = (entry: StagedEntry, composer: ComposerGrabPort): boolean =>
+ port() === composer && pending().includes(entry)
+
const putOnComposer = async (entry: StagedEntry, composer: ComposerGrabPort): Promise => {
- const prepared = await fitted(entry.grounded ?? entry.grab)
- const id = await composer.addAttachment(grabToFile(prepared))
- entry.id = id
- if (id) remember(id, grabToPayload(prepared))
- if (entry.grounded === null) return
- drop(entry)
+ try {
+ const prepared = await fitted(entry.grounded ?? entry.grab)
+ if (!isLive(entry, composer)) return
+ const id = await composer.addAttachment(grabToFile(prepared))
+ if (!isLive(entry, composer)) {
+ if (id) void composer.removeAttachment(id)
+ return
+ }
+ entry.id = id
+ if (id) remember(id, grabToPayload(prepared))
+ if (entry.grounded === null) return
+ drop(entry)
+ } finally {
+ if (entry.id === null) entry.placing = null
+ }
}
const ensureOnComposer = (entry: StagedEntry): Promise => {
@@ -91,7 +104,12 @@ export function makeGrabStaging(deps: Deps): GrabStaging {
const composer = port()
if (!composer || entry.id === null || entry.grounded === null) return
const prepared = await fitted(entry.grounded)
+ if (!isLive(entry, composer)) return
const replaced = await composer.replaceAttachment(entry.id, grabToFile(prepared))
+ if (!isLive(entry, composer)) {
+ if (replaced) void composer.removeAttachment(replaced)
+ return
+ }
forget(entry.id)
if (replaced) remember(replaced, grabToPayload(prepared))
drop(entry)
@@ -107,14 +125,14 @@ export function makeGrabStaging(deps: Deps): GrabStaging {
}
const placeThenGround = async (entry: StagedEntry): Promise => {
- await ensureOnComposer(entry)
+ await ensureOnComposer(entry).catch(() => {})
await ground(entry)
}
const stage = (grab: Grab): void => {
const entry: StagedEntry = {grab, id: null, grounded: null, placing: null}
setPending((current) => [...current, entry])
- void placeThenGround(entry)
+ void placeThenGround(entry).catch(() => {})
}
const staged = (): readonly Grab[] => {
@@ -155,7 +173,8 @@ export function makeGrabStaging(deps: Deps): GrabStaging {
const connect = (next: ComposerGrabPort): void => {
setPort(() => next)
- for (const entry of pending().filter((candidate) => candidate.id === null)) void ensureOnComposer(entry)
+ for (const entry of pending().filter((candidate) => candidate.id === null))
+ void ensureOnComposer(entry).catch(() => {})
}
const disconnect = (): void => {
diff --git a/apps/conciv/test/grab-staging-module.browser.test.ts b/apps/conciv/test/grab-staging-module.browser.test.ts
index a35e78ffe..c226a6520 100644
--- a/apps/conciv/test/grab-staging-module.browser.test.ts
+++ b/apps/conciv/test/grab-staging-module.browser.test.ts
@@ -15,11 +15,24 @@ const GRAB: Grab = {
const GROUNDED: Grab = {...GRAB, text: 'Payroll Deposit
at src/routes/hero.tsx:4:1'}
-type FakeComposer = ComposerGrabPort & {list: () => AttachmentState[]; nextAdd: () => Promise}
+type FakeComposer = ComposerGrabPort & {
+ list: () => AttachmentState[]
+ nextAdd: () => Promise
+ nextRemove: () => Promise
+ nextAddStart: () => Promise
+ nextReplace: () => Promise
+ holdAdds: () => () => void
+ failNextAdd: () => void
+}
function fakeComposer(): FakeComposer {
const entries: AttachmentState[] = []
const addWaiters: ((attachment: AttachmentState) => void)[] = []
+ const removeWaiters: ((id: string) => void)[] = []
+ const startWaiters: (() => void)[] = []
+ const replaceWaiters: ((attachment: AttachmentState) => void)[] = []
+ const gate: {open: Promise | null; release: () => void} = {open: null, release: () => {}}
+ const failure = {pending: 0}
let nextId = 0
const entryFor = (id: string, file: File): AttachmentState => ({
id,
@@ -32,9 +45,30 @@ function fakeComposer(): FakeComposer {
return {
list: () => entries,
nextAdd: () => new Promise((resolve) => addWaiters.push(resolve)),
+ nextRemove: () => new Promise((resolve) => removeWaiters.push(resolve)),
+ nextAddStart: () => new Promise((resolve) => startWaiters.push(resolve)),
+ nextReplace: () => new Promise((resolve) => replaceWaiters.push(resolve)),
+ holdAdds: () => {
+ gate.open = new Promise((resolve) => {
+ gate.release = resolve
+ })
+ return () => {
+ gate.open = null
+ gate.release()
+ }
+ },
+ failNextAdd: () => {
+ failure.pending += 1
+ },
attachments: () => entries,
hasAttachment: (id) => entries.some((entry) => entry.id === id),
addAttachment: async (file) => {
+ startWaiters.splice(0).forEach((resolve) => resolve())
+ if (failure.pending > 0) {
+ failure.pending -= 1
+ throw new Error('the adapter refused the attachment')
+ }
+ if (gate.open) await gate.open
nextId += 1
const id = `attachment-${nextId}`
const added = entryFor(id, file)
@@ -47,12 +81,15 @@ function fakeComposer(): FakeComposer {
if (position < 0) return null
nextId += 1
const next = `attachment-${nextId}`
- entries.splice(position, 1, entryFor(next, file))
+ const swapped = entryFor(next, file)
+ entries.splice(position, 1, swapped)
+ replaceWaiters.splice(0).forEach((resolve) => resolve(swapped))
return next
},
removeAttachment: async (id) => {
const position = entries.findIndex((entry) => entry.id === id)
if (position >= 0) entries.splice(position, 1)
+ removeWaiters.splice(0).forEach((resolve) => resolve(id))
},
}
}
@@ -100,17 +137,14 @@ function neverGrounds(): Promise {
return Promise.resolve(null)
}
-async function settled(): Promise {
- for (let turn = 0; turn < 12; turn += 1) await Promise.resolve()
-}
-
it('attaches optimistically and replaces the payload once grounding resolves', async () => {
const composer = fakeComposer()
const staging = makeGrabStaging({ground: async () => GROUNDED})
staging.connect(composer)
+ const replaced = composer.nextReplace()
staging.stage(GRAB)
- await settled()
+ await replaced
expect(composer.list()).toHaveLength(1)
expect(await grabTextOf(composer.list()[0] ?? never())).toBe(GROUNDED.text)
@@ -120,9 +154,10 @@ it('keeps the optimistic attachment when grounding yields nothing', async () =>
const composer = fakeComposer()
const staging = makeGrabStaging({ground: neverGrounds})
staging.connect(composer)
+ const added = composer.nextAdd()
staging.stage(GRAB)
- await settled()
+ await added
expect(composer.list()).toHaveLength(1)
expect(await grabTextOf(composer.list()[0] ?? never())).toBe(GRAB.text)
@@ -133,12 +168,12 @@ it('attaches a grab staged before the composer connected, carrying its grounded
const staging = makeGrabStaging({ground: async () => GROUNDED})
staging.stage(GRAB)
- await settled()
+ expect(await stagedWhen(staging, 1)).toHaveLength(1)
expect(composer.list()).toHaveLength(0)
- expect(staging.staged().map((grab) => grab.text)).toEqual([GROUNDED.text])
+ const added = composer.nextAdd()
staging.connect(composer)
- await settled()
+ await added
expect(composer.list()).toHaveLength(1)
expect(await grabTextOf(composer.list()[0] ?? never())).toBe(GROUNDED.text)
@@ -149,9 +184,11 @@ it('attaches a grab staged before the composer connected even when grounding yie
const staging = makeGrabStaging({ground: neverGrounds})
staging.stage(GRAB)
- await settled()
+ expect(await stagedWhen(staging, 1)).toHaveLength(1)
+
+ const added = composer.nextAdd()
staging.connect(composer)
- await settled()
+ await added
expect(composer.list()).toHaveLength(1)
expect(await grabTextOf(composer.list()[0] ?? never())).toBe(GRAB.text)
@@ -168,7 +205,7 @@ it('reports a staged grab reactively once its payload is known', async () => {
})
staging.stage(GRAB)
- await settled()
+ await stagedWhen(staging, 1)
expect(seen.at(-1)).toBe(1)
dispose()
@@ -179,11 +216,13 @@ it('clears only the grab attachments', async () => {
const staging = makeGrabStaging({ground: neverGrounds})
staging.connect(composer)
await composer.addAttachment(new File(['hello'], 'notes.txt', {type: 'text/plain'}))
+ const added = composer.nextAdd()
staging.stage(GRAB)
- await settled()
+ await added
+ const removed = composer.nextRemove()
staging.clear()
- await settled()
+ await removed
expect(composer.list().map((entry) => entry.name)).toEqual(['notes.txt'])
expect(staging.staged()).toHaveLength(0)
@@ -217,7 +256,6 @@ it('does not hydrate a payload whose attachment was removed while it decoded', a
staging.reconcile(composer.attachments())
await composer.removeAttachment(restored)
await body.text()
- await settled()
expect(staging.staged()).toHaveLength(0)
})
@@ -243,10 +281,12 @@ it('fits an oversized image preview into the payload budget instead of dropping
it('stages through a port that arrives and leaves again', async () => {
const composer = fakeComposer()
- const staging: GrabStaging = makeGrabStaging({ground: neverGrounds})
+ const staging: GrabStaging = makeGrabStaging({ground: async () => GROUNDED})
staging.connect(composer)
+ const replaced = composer.nextReplace()
staging.stage(GRAB)
- await settled()
+ await replaced
+ await stagedWhen(staging, 1)
staging.disconnect()
@@ -258,3 +298,73 @@ it('stages through a port that arrives and leaves again', async () => {
function never(): never {
throw new Error('expected an attachment')
}
+
+it('a clear while a placement is in flight does not put the grab back on the composer', async () => {
+ const composer = fakeComposer()
+ const release = composer.holdAdds()
+ const staging = makeGrabStaging({ground: neverGrounds})
+ staging.connect(composer)
+ const started = composer.nextAddStart()
+
+ staging.stage(GRAB)
+ await started
+ const added = composer.nextAdd()
+ const removed = composer.nextRemove()
+ staging.clear()
+ release()
+ await added
+ await removed
+
+ expect(composer.list()).toHaveLength(0)
+ expect(staging.staged()).toHaveLength(0)
+})
+
+it('a placement that lands after disconnect is redirected to the composer connected next', async () => {
+ const first = fakeComposer()
+ const second = fakeComposer()
+ const release = first.holdAdds()
+ const staging = makeGrabStaging({ground: neverGrounds})
+ staging.connect(first)
+ const started = first.nextAddStart()
+
+ staging.stage(GRAB)
+ await started
+ const firstAdded = first.nextAdd()
+ const firstRemoved = first.nextRemove()
+ staging.disconnect()
+ release()
+ await firstAdded
+ await firstRemoved
+
+ const secondAdded = second.nextAdd()
+ staging.connect(second)
+ await secondAdded
+
+ expect(first.list()).toHaveLength(0)
+ expect(second.list()).toHaveLength(1)
+})
+
+it('a grab whose placement fails can still be placed on the next connection', async () => {
+ const composer = fakeComposer()
+ composer.failNextAdd()
+ const grounding: {reached: () => void} = {reached: () => {}}
+ const groundingReached = new Promise((resolve) => {
+ grounding.reached = resolve
+ })
+ const staging = makeGrabStaging({
+ ground: async () => {
+ grounding.reached()
+ return null
+ },
+ })
+ staging.connect(composer)
+
+ staging.stage(GRAB)
+ await groundingReached
+
+ const added = composer.nextAdd()
+ staging.connect(composer)
+ await added
+
+ expect(composer.list()).toHaveLength(1)
+})
From ddf167274b50c0e9a5e4dabe30d6010ba3a35ef8 Mon Sep 17 00:00:00 2001
From: Omri Katz <9701896+omridevk@users.noreply.github.com>
Date: Sat, 15 Aug 2026 18:22:30 +0300
Subject: [PATCH 19/22] fix(grab): #487 bound the grab payload at its
boundaries
parseGrabPayload accepted a valid body of any size, so a persisted or forged
grab could carry an unbounded payload into composer state past
MAX_PAYLOAD_BYTES; it now rejects an oversized body before parsing it.
The image preview arm accepted any string as dataUrl, and the card renders it
into an img src, so a forged payload could point the widget at a remote host.
The schema now requires the data:image/ scheme.
imagePreviewBudget reserves the rest of the serialized payload before an image
is fitted. Fitting against the whole budget left no room for text, source and
rect, so an image just under the limit pushed the payload over it and
grabToPayload dropped the preview it had just fitted.
Reported by CodeRabbit and Copilot on #513.
Co-Authored-By: Claude Fable 5
---
packages/grab/src/grab-attachment.ts | 15 ++++-
packages/grab/test/grab-attachment.test.ts | 64 +++++++++++++++++++++-
2 files changed, 76 insertions(+), 3 deletions(-)
diff --git a/packages/grab/src/grab-attachment.ts b/packages/grab/src/grab-attachment.ts
index ffe7e24f9..bb0cf259e 100644
--- a/packages/grab/src/grab-attachment.ts
+++ b/packages/grab/src/grab-attachment.ts
@@ -11,6 +11,8 @@ const BASE64_BYTES_PER_CHARACTER = 3 / 4
export const MAX_PAYLOAD_BYTES = Math.floor(MAX_PERSISTED_BASE64_CHARACTERS * BASE64_BYTES_PER_CHARACTER)
+const IMAGE_DATA_URL_PREFIX = 'data:image/'
+
const ElementSourceSchema = z.object({
componentName: z.string().nullable(),
filePath: z.string(),
@@ -21,7 +23,12 @@ const ElementRectSchema = z.object({x: z.number(), y: z.number(), width: z.numbe
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()}),
+ z.object({
+ kind: z.literal('image'),
+ dataUrl: z.string().startsWith(IMAGE_DATA_URL_PREFIX),
+ width: z.number(),
+ height: z.number(),
+ }),
])
const GrabPayloadSchema = z.object({
@@ -69,6 +76,11 @@ function shrunkToBudget(payload: GrabPayload): GrabPayload {
return truncatedTo(payload, codePoints, low)
}
+export function imagePreviewBudget(grab: Grab, preview: Extract): number {
+ const envelope = payloadOf(grab, {...preview, dataUrl: ''})
+ return Math.max(0, MAX_PAYLOAD_BYTES - payloadBytes(envelope))
+}
+
export function grabToPayload(grab: Grab): GrabPayload {
const full = payloadOf(grab, grab.preview)
if (withinBudget(full)) return full
@@ -84,6 +96,7 @@ export function grabToFile(grab: Grab): File {
}
export function parseGrabPayload(raw: string): GrabPayload | null {
+ if (new TextEncoder().encode(raw).length > MAX_PAYLOAD_BYTES) return null
try {
const parsed = GrabPayloadSchema.safeParse(JSON.parse(raw))
return parsed.success ? parsed.data : null
diff --git a/packages/grab/test/grab-attachment.test.ts b/packages/grab/test/grab-attachment.test.ts
index bfebec15b..d4a084cce 100644
--- a/packages/grab/test/grab-attachment.test.ts
+++ b/packages/grab/test/grab-attachment.test.ts
@@ -1,6 +1,14 @@
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'
+import {
+ GRAB_FILE_NAME,
+ GRAB_MIME,
+ grabToFile,
+ grabToPayload,
+ imagePreviewBudget,
+ MAX_PAYLOAD_BYTES,
+ parseGrabPayload,
+} from '../src/grab-attachment.js'
+import type {Grab, GrabPreview} from '../src/grab.js'
const GRAB: Grab = {
text: 'Start simple
at src/routes/index.tsx:12:9',
@@ -61,4 +69,56 @@ describe('grab attachment payload', () => {
expect(parseGrabPayload('{"nope":true}')).toBeNull()
expect(parseGrabPayload('not json at all')).toBeNull()
})
+
+ it('refuses an image preview whose source is not a data url', () => {
+ const forged = JSON.stringify({
+ text: GRAB.text,
+ source: null,
+ rect: null,
+ preview: {kind: 'image', dataUrl: 'https://tracker.test/pixel.png', width: 1, height: 1},
+ })
+
+ expect(parseGrabPayload(forged)).toBeNull()
+ })
+
+ it('refuses an image preview that smuggles a script scheme', () => {
+ const forged = JSON.stringify({
+ text: GRAB.text,
+ source: null,
+ rect: null,
+ preview: {kind: 'image', dataUrl: 'javascript:alert(1)', width: 1, height: 1},
+ })
+
+ expect(parseGrabPayload(forged)).toBeNull()
+ })
+
+ it('refuses a body larger than the payload budget before parsing it', () => {
+ const oversized = JSON.stringify({
+ text: 'x'.repeat(MAX_PAYLOAD_BYTES + 1),
+ source: null,
+ rect: null,
+ preview: null,
+ })
+
+ expect(oversized.length).toBeGreaterThan(MAX_PAYLOAD_BYTES)
+ expect(parseGrabPayload(oversized)).toBeNull()
+ })
+
+ it('reserves the non-image payload overhead when budgeting an image preview', () => {
+ const preview: Extract = {kind: 'image', dataUrl: '', width: 40, height: 20}
+ const wordy: Grab = {...GRAB, text: 'x'.repeat(50_000), preview}
+
+ expect(imagePreviewBudget(wordy, preview)).toBeLessThanOrEqual(MAX_PAYLOAD_BYTES - 50_000)
+ })
+
+ it('keeps an image preview that was fitted to the reserved budget', () => {
+ const preview: Extract = {kind: 'image', dataUrl: '', width: 40, height: 20}
+ const wordy: Grab = {...GRAB, text: 'x'.repeat(50_000), preview}
+ const budget = imagePreviewBudget(wordy, preview)
+ const fitted: Grab = {...wordy, preview: {...preview, dataUrl: 'd'.repeat(budget)}}
+ const wholeBudget: Grab = {...wordy, preview: {...preview, dataUrl: 'd'.repeat(MAX_PAYLOAD_BYTES)}}
+
+ expect(grabToPayload(fitted).preview?.kind).toBe('image')
+ expect(grabToPayload(wholeBudget).preview).toBeNull()
+ })
})
From 6bcbf0840a4222392c6724f32c6647cc29a865f7 Mon Sep 17 00:00:00 2001
From: Omri Katz <9701896+omridevk@users.noreply.github.com>
Date: Sat, 15 Aug 2026 18:22:52 +0300
Subject: [PATCH 20/22] fix(page): #487 capture svg grabs and batch the metric
probes
Both capture paths gated the clone on HTMLElement, so grabbing an svg element
produced an empty preview. Element is the right bound for markup and inline
styling, and SVGElement carries the same style surface, so the widening needs
no cast.
correctFallbackMetrics appended, measured and removed a probe span per run and
rebuilt the webfont family set for every one of them. On a subtree with many
text leaves that is hundreds of forced layouts on the page the user is picking
from. The families are now collected once and the probes are appended in one
pass, read in a second, and dropped together.
Reported by CodeRabbit and Copilot on #513.
Co-Authored-By: Claude Fable 5
---
packages/extension-testkit/src/host/grab.ts | 2 +-
.../page/src/react-grab/capture-element.ts | 21 +++--
.../page/src/react-grab/fallback-metrics.ts | 87 +++++++++++++------
.../page/test/capture-element.browser.test.ts | 21 +++++
4 files changed, 96 insertions(+), 35 deletions(-)
diff --git a/packages/extension-testkit/src/host/grab.ts b/packages/extension-testkit/src/host/grab.ts
index f85c75838..7254cb31d 100644
--- a/packages/extension-testkit/src/host/grab.ts
+++ b/packages/extension-testkit/src/host/grab.ts
@@ -29,7 +29,7 @@ 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 : ''
+ const html = clone instanceof Element ? clone.outerHTML : ''
return {
text: element.textContent ?? '',
preview: {kind: 'dom', html, width: box.width, height: box.height},
diff --git a/packages/page/src/react-grab/capture-element.ts b/packages/page/src/react-grab/capture-element.ts
index 3b6e16d72..7ddde83a7 100644
--- a/packages/page/src/react-grab/capture-element.ts
+++ b/packages/page/src/react-grab/capture-element.ts
@@ -1,5 +1,5 @@
import type {DomPreview} from '@conciv/grab'
-import {correctFallbackMetrics, type TextRun} from './fallback-metrics.js'
+import {correctFallbackMetrics, isStyledElement, type StyledElement, type TextRun} from './fallback-metrics.js'
function nextFrame(): Promise {
return new Promise((resolve) => {
@@ -13,7 +13,7 @@ export async function captureElement(el: Element): Promise {
const clone = el.cloneNode(true)
const rules: string[] = []
const runs: TextRun[] = []
- if (!(clone instanceof HTMLElement)) return {kind: 'dom', html: '', width: rect.width, height: rect.height}
+ if (!isStyledElement(clone)) return {kind: 'dom', html: '', width: rect.width, height: rect.height}
inlineComputedStyles(el, clone, rules, runs)
await correctFallbackMetrics(runs)
neutralizeLayout(clone)
@@ -36,27 +36,30 @@ function isTextRun(node: Element): boolean {
return node.children.length === 0 && (node.textContent ?? '').trim() !== ''
}
-function inlineComputedStyles(src: Element, dst: HTMLElement, rules: string[], runs: TextRun[]): void {
- const cs = getComputedStyle(src)
+function computedCssText(cs: CSSStyleDeclaration): string {
let cssText = ''
for (const prop of cs) {
if (SKIP_PROPS.has(prop) || prop.startsWith('--')) continue
cssText += `${prop}:${cs.getPropertyValue(prop)};`
}
- dst.style.cssText = cssText
+ return cssText
+}
+
+function inlineComputedStyles(src: Element, dst: StyledElement, rules: string[], runs: TextRun[]): void {
+ dst.style.cssText = computedCssText(getComputedStyle(src))
capturePseudo(src, dst, rules)
- if (src instanceof HTMLElement && isTextRun(src)) runs.push({source: src, clone: dst})
+ if (isStyledElement(src) && isTextRun(src)) runs.push({source: src, clone: dst})
const sk = src.children
const dk = dst.children
for (let i = 0; i < sk.length; i++) {
const childSrc = sk[i]
const childDst = dk[i]
- if (childSrc && childDst) inlineComputedStyles(childSrc, childDst as HTMLElement, rules, runs)
+ if (childSrc && childDst && isStyledElement(childDst)) inlineComputedStyles(childSrc, childDst, rules, runs)
}
}
let pseudoSeq = 0
-function capturePseudo(src: Element, dst: HTMLElement, rules: string[]): void {
+function capturePseudo(src: Element, dst: StyledElement, rules: string[]): void {
for (const pseudo of ['::before', '::after']) {
const pcs = getComputedStyle(src, pseudo)
const content = pcs.content
@@ -69,7 +72,7 @@ function capturePseudo(src: Element, dst: HTMLElement, rules: string[]): void {
}
}
-function neutralizeLayout(root: HTMLElement): void {
+function neutralizeLayout(root: StyledElement): void {
root.style.position = 'static'
root.style.margin = '0'
root.style.top = 'auto'
diff --git a/packages/page/src/react-grab/fallback-metrics.ts b/packages/page/src/react-grab/fallback-metrics.ts
index 239c80398..4937f5196 100644
--- a/packages/page/src/react-grab/fallback-metrics.ts
+++ b/packages/page/src/react-grab/fallback-metrics.ts
@@ -19,7 +19,17 @@ const EPSILON_PX = 0.5
const PROBE_STEP_PX = 1
-export type TextRun = {source: HTMLElement; clone: HTMLElement}
+export type StyledElement = HTMLElement | SVGElement
+
+export function isStyledElement(node: Node): node is StyledElement {
+ return node instanceof HTMLElement || node instanceof SVGElement
+}
+
+export type TextRun = {source: StyledElement; clone: StyledElement}
+
+type Candidate = {run: TextRun; base: number; css: string; steppedCss: string; text: string}
+
+type Probe = {live: HTMLElement; fallback: HTMLElement; stepped: HTMLElement}
type Measurer = {doc: Document; close: () => void}
@@ -50,14 +60,11 @@ function measurementCss(styles: CSSStyleDeclaration, letterSpacing: number): str
return `position:absolute;top:0;left:0;white-space:nowrap;visibility:hidden;${declarations};letter-spacing:${letterSpacing}px`
}
-function measure(doc: Document, css: string, text: string): number {
+function probeSpan(doc: Document, css: string, text: string): HTMLElement {
const span = doc.createElement('span')
span.style.cssText = css
span.textContent = text
- doc.body.appendChild(span)
- const width = span.getBoundingClientRect().width
- span.remove()
- return width
+ return span
}
async function openMeasurer(): Promise {
@@ -78,33 +85,63 @@ async function openMeasurer(): Promise {
return {doc, close: () => frame.remove()}
}
-function correctionFor(measurer: Measurer, run: TextRun, text: string): number | null {
- const styles = getComputedStyle(run.source)
- if (!familiesOf(styles.fontFamily).some((family) => webfontFamilies().has(family))) return null
- const base = letterSpacingOf(styles)
- const css = measurementCss(styles, base)
- const live = measure(document, css, text)
- const fallback = measure(measurer.doc, css, text)
- if (Math.abs(live - fallback) < EPSILON_PX) return null
- const stepped = measure(measurer.doc, measurementCss(styles, base + PROBE_STEP_PX), text)
- const slots = (stepped - fallback) / PROBE_STEP_PX
- if (slots <= 0) return null
- return base + (live - fallback) / slots
+function candidatesOf(runs: readonly TextRun[]): Candidate[] {
+ const webfonts = webfontFamilies()
+ return runs.flatMap((run) => {
+ const text = run.source.textContent ?? ''
+ if (text.trim() === '') return []
+ const styles = getComputedStyle(run.source)
+ if (!familiesOf(styles.fontFamily).some((family) => webfonts.has(family))) return []
+ const base = letterSpacingOf(styles)
+ return [
+ {
+ run,
+ base,
+ text,
+ css: measurementCss(styles, base),
+ steppedCss: measurementCss(styles, base + PROBE_STEP_PX),
+ },
+ ]
+ })
}
export async function correctFallbackMetrics(runs: readonly TextRun[]): Promise {
if (runs.length === 0 || document.fonts.size === 0) return
+ const candidates = candidatesOf(runs)
+ if (candidates.length === 0) return
const measurer = await openMeasurer()
if (!measurer) return
+ const hostHolder = document.createElement('div')
+ hostHolder.style.cssText = 'position:absolute;top:0;left:0;width:0;height:0;overflow:hidden'
+ const frameHolder = measurer.doc.createElement('div')
+ const probes: Probe[] = candidates.map((candidate) => ({
+ live: probeSpan(document, candidate.css, candidate.text),
+ fallback: probeSpan(measurer.doc, candidate.css, candidate.text),
+ stepped: probeSpan(measurer.doc, candidate.steppedCss, candidate.text),
+ }))
+ for (const probe of probes) {
+ hostHolder.appendChild(probe.live)
+ frameHolder.appendChild(probe.fallback)
+ frameHolder.appendChild(probe.stepped)
+ }
+ document.body.appendChild(hostHolder)
+ measurer.doc.body.appendChild(frameHolder)
try {
- for (const run of runs) {
- const text = run.source.textContent ?? ''
- if (text.trim() === '') continue
- const corrected = correctionFor(measurer, run, text)
- if (corrected === null) continue
- run.clone.style.letterSpacing = `${corrected}px`
- }
+ const widths = probes.map((probe) => ({
+ live: probe.live.getBoundingClientRect().width,
+ fallback: probe.fallback.getBoundingClientRect().width,
+ stepped: probe.stepped.getBoundingClientRect().width,
+ }))
+ candidates.forEach((candidate, index) => {
+ const measured = widths[index]
+ if (!measured) return
+ if (Math.abs(measured.live - measured.fallback) < EPSILON_PX) return
+ const slots = (measured.stepped - measured.fallback) / PROBE_STEP_PX
+ if (slots <= 0) return
+ candidate.run.clone.style.letterSpacing = `${candidate.base + (measured.live - measured.fallback) / slots}px`
+ })
} finally {
+ hostHolder.remove()
measurer.close()
}
}
diff --git a/packages/page/test/capture-element.browser.test.ts b/packages/page/test/capture-element.browser.test.ts
index 379e9fe2f..bd6f80d74 100644
--- a/packages/page/test/capture-element.browser.test.ts
+++ b/packages/page/test/capture-element.browser.test.ts
@@ -65,3 +65,24 @@ describe('captureElement', () => {
expect(clone instanceof HTMLElement ? clone.style.margin : '').toBe('0px')
})
})
+
+describe('captureElement with svg', () => {
+ it('keeps the markup of a grabbed svg element', async () => {
+ const host = document.createElement('div')
+ host.innerHTML = `
+
+
+ logo
+
+ `
+ document.body.appendChild(host)
+ const svg = host.querySelector('svg')
+ if (!svg) throw new Error('fixture missing')
+
+ const preview = await captureElement(svg)
+
+ expect(preview.html).toContain('
Date: Sat, 15 Aug 2026 18:22:52 +0300
Subject: [PATCH 21/22] fix(ui-kit-chat): #487 give document attachments
collision-resistant ids
createDocumentAttachmentAdapter numbered documents from a module-level counter
that resets to zero on every page load. Now that a grab attachment is persisted
in the draft row with its id, a restored document-1 collided with the id handed
to the first grab staged after a reload, and the composer upserts by id. The
adapter reuses the attachmentId helper the primitive adapters already share.
Reported by Copilot on #513.
Co-Authored-By: Claude Fable 5
---
.../src/primitives/attachment/attachment-adapter.ts | 2 +-
packages/ui-kit-chat/src/styled/attachment-dispatch.tsx | 5 ++---
2 files changed, 3 insertions(+), 4 deletions(-)
diff --git a/packages/ui-kit-chat/src/primitives/attachment/attachment-adapter.ts b/packages/ui-kit-chat/src/primitives/attachment/attachment-adapter.ts
index 83cc1a893..28ba0510e 100644
--- a/packages/ui-kit-chat/src/primitives/attachment/attachment-adapter.ts
+++ b/packages/ui-kit-chat/src/primitives/attachment/attachment-adapter.ts
@@ -41,7 +41,7 @@ export type AttachmentAdapter = {
let fallbackId = 0
-function attachmentId(): string {
+export function attachmentId(): string {
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) return crypto.randomUUID()
fallbackId += 1
return `attachment-${fallbackId}`
diff --git a/packages/ui-kit-chat/src/styled/attachment-dispatch.tsx b/packages/ui-kit-chat/src/styled/attachment-dispatch.tsx
index 0dcc884b8..352a5d8f6 100644
--- a/packages/ui-kit-chat/src/styled/attachment-dispatch.tsx
+++ b/packages/ui-kit-chat/src/styled/attachment-dispatch.tsx
@@ -5,6 +5,7 @@ import {Attachment, useAttachment} from '../primitives/attachment/attachment.js'
import {AttachmentUI} from './attachment-ui.js'
import {FOCUS} from './classes.js'
import {
+ attachmentId,
fileToDataSource,
type Attachment as AttachmentState,
type AttachmentAdapter,
@@ -46,13 +47,11 @@ export function AttachmentByMime(props: {cards: readonly AttachmentCardSlot[]; r
)
}
-let documentId = 0
-
export function createDocumentAttachmentAdapter(mime: string): AttachmentAdapter {
return {
accept: mime,
add: async ({file}) => ({
- id: `document-${(documentId += 1)}`,
+ id: `document-${attachmentId()}`,
type: 'document',
name: file.name,
contentType: mime,
From 81d98ac4f34e8b4afa8ca377c745c3078f7ff8cb Mon Sep 17 00:00:00 2001
From: Omri Katz <9701896+omridevk@users.noreply.github.com>
Date: Sat, 15 Aug 2026 18:22:52 +0300
Subject: [PATCH 22/22] fix(conciv): #487 register the page extension in the
standalone entry
Grab support now rides the page extension's attachment registration, and the
standalone panel entry built its router without extensions, so the composer
adapter did not accept the grab mime there and a restored grab had no card.
The embed boot path already prepends the built-in page extension; the
standalone entry now matches it.
The pane test harness also created extension instances it never disposed, so a
mounted extension outlived the test that made it. It disposes them alongside
the render, as the router does.
Reported by Copilot and CodeRabbit on #513.
Co-Authored-By: Claude Fable 5
---
apps/conciv/src/entry-standalone.tsx | 2 ++
apps/conciv/test/helpers/pane-harness.tsx | 4 ++-
.../pane-harness-disposal.browser.test.tsx | 25 +++++++++++++++++++
3 files changed, 30 insertions(+), 1 deletion(-)
create mode 100644 apps/conciv/test/pane-harness-disposal.browser.test.tsx
diff --git a/apps/conciv/src/entry-standalone.tsx b/apps/conciv/src/entry-standalone.tsx
index 45984986c..b3f1cc768 100644
--- a/apps/conciv/src/entry-standalone.tsx
+++ b/apps/conciv/src/entry-standalone.tsx
@@ -1,6 +1,7 @@
import {render} from 'solid-js/web'
import {RouterProvider, createBrowserHistory} from '@tanstack/solid-router'
import {makeBrowserRpcClient} from '@conciv/contract'
+import pageExtension from '@conciv/extension-page/client'
import {parseConcivSettings} from './data/settings.js'
import {createConcivRouter} from './router.js'
@@ -14,6 +15,7 @@ function start(): void {
history: createBrowserHistory(),
environment: {rootNode: document, document},
settings,
+ extensions: [pageExtension],
apiBase: () => apiBase,
})
const root = document.getElementById('app')
diff --git a/apps/conciv/test/helpers/pane-harness.tsx b/apps/conciv/test/helpers/pane-harness.tsx
index 53d5f9c4f..f74384488 100644
--- a/apps/conciv/test/helpers/pane-harness.tsx
+++ b/apps/conciv/test/helpers/pane-harness.tsx
@@ -46,6 +46,7 @@ export function mountPane(options: PaneMountOptions, view: (pane: PaneContextVal
window.__CONCIV_API_BASE__ = options.base
browserRpcConnection(options.base, 'fetch')
const queryClient = new QueryClient()
+ const instances = createInstances(options.extensions ?? [])
const data = makeAppData(rpc, queryClient)
const [announced, setAnnounced] = createSignal([])
const app: AppContextValue = {
@@ -58,7 +59,7 @@ export function mountPane(options: PaneMountOptions, view: (pane: PaneContextVal
layers: makeLayerStack(),
suppressed: () => undefined,
fabPosition: () => 'bottom-right',
- instances: createInstances(options.extensions ?? []),
+ instances,
connected: () => true,
arrivedFromConnect: () => false,
connectBind: async () => '',
@@ -104,6 +105,7 @@ export function mountPane(options: PaneMountOptions, view: (pane: PaneContextVal
return {
dispose: () => {
mounted.unmount()
+ for (const instance of instances) instance.dispose()
reachabilityRoot.dispose()
closeBrowserRpcConnection(options.base)
delete window.__CONCIV_API_BASE__
diff --git a/apps/conciv/test/pane-harness-disposal.browser.test.tsx b/apps/conciv/test/pane-harness-disposal.browser.test.tsx
new file mode 100644
index 000000000..5ee5c29c9
--- /dev/null
+++ b/apps/conciv/test/pane-harness-disposal.browser.test.tsx
@@ -0,0 +1,25 @@
+import './helpers/utilities.css'
+import {expect, test} from 'vitest'
+import {defineExtension} from '@conciv/extension'
+import {mountPane} from './helpers/pane-harness.js'
+
+test('disposing a mounted pane disposes every extension instance it created', () => {
+ const order: string[] = []
+ const first = defineExtension({name: 'pane-harness-disposal-a'}).client(() => ({
+ value: {},
+ dispose: () => order.push('a'),
+ }))
+ const second = defineExtension({name: 'pane-harness-disposal-b'}).client(() => ({
+ value: {},
+ dispose: () => order.push('b'),
+ }))
+ const mount = mountPane({base: 'http://127.0.0.1:9', sessionId: 'conciv_1', extensions: [first, second]}, () => (
+ pane
+ ))
+
+ expect(order).toEqual([])
+
+ mount.dispose()
+
+ expect(order).toEqual(['a', 'b'])
+})