Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
2cb4c81
refactor(grab): #487 carry the dom preview as markup instead of a liv…
omridevk Aug 14, 2026
c3040eb
feat(drafts): #487 persist composer attachments in the session draft row
omridevk Aug 14, 2026
6bd3f53
feat(grab): #487 grab attachment payload codec and sourceLabel move
omridevk Aug 14, 2026
efacef2
feat(page): #487 refit oversized image previews to the payload budget
omridevk Aug 14, 2026
72f9025
feat(page): #487 sandboxed grab snapshot frame
omridevk Aug 15, 2026
744d699
feat(page): #487 grab attachment card and registration
omridevk Aug 15, 2026
9685e1e
feat(page): #487 expand grab attachments into grounding text
omridevk Aug 15, 2026
3404f22
feat(ui-kit-chat): #487 atomic composer attachment replace with stabl…
omridevk Aug 15, 2026
89d49ba
feat(conciv): #487 grab staging module over composer attachments
omridevk Aug 15, 2026
beb21b1
feat(conciv): #487 stage grabs as attachments and delete the grab store
omridevk Aug 15, 2026
b6cfc00
test(conciv): #487 prove a staged grab survives a panel reload
omridevk Aug 15, 2026
204b443
fix(page): #487 scale the grab snapshot to fit its card
omridevk Aug 15, 2026
bcfb285
fix(page): #487 paint the snapshot frame on the card's own surface
omridevk Aug 15, 2026
05aa193
fix(ui-kit-chat): #487 no empty user bubble for an attachment-only me…
omridevk Aug 15, 2026
8dcebf9
fix(ui-kit-chat): #487 a model-only text part is not visible text
omridevk Aug 15, 2026
e6520a4
refactor(drafts): #487 remove the grabs channel now that grabs are at…
omridevk Aug 15, 2026
aee75f9
fix(page): #487 correct fallback font metrics with capture-time lette…
omridevk Aug 15, 2026
e7435d3
fix(conciv): #487 invalidate a grab placement the composer no longer …
omridevk Aug 15, 2026
ddf1672
fix(grab): #487 bound the grab payload at its boundaries
omridevk Aug 15, 2026
6bcbf08
fix(page): #487 capture svg grabs and batch the metric probes
omridevk Aug 15, 2026
ee547ab
fix(ui-kit-chat): #487 give document attachments collision-resistant ids
omridevk Aug 15, 2026
81d98ac
fix(conciv): #487 register the page extension in the standalone entry
omridevk Aug 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/grab-as-attachment.md
Original file line number Diff line number Diff line change
@@ -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.
32 changes: 4 additions & 28 deletions apps/conciv/src/app/pane-context.ts
Original file line number Diff line number Diff line change
@@ -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<StagedGrab[]>
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<StagedGrab[]>([])
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
Expand All @@ -38,7 +14,7 @@ export type PaneContextValue = {
setLockedFor: (id: string) => (locked: boolean) => void
slideClass: Accessor<string>
resetSlide: () => void
grabStore: PaneGrabStore
grabStaging: GrabStaging
grabProvider: GrabProvider | undefined
attachments: PendingAttachmentQueue
newSession: () => void
Expand Down
6 changes: 4 additions & 2 deletions apps/conciv/src/app/pane-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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: () => {
Expand Down
2 changes: 2 additions & 0 deletions apps/conciv/src/entry-standalone.tsx
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -14,6 +15,7 @@ function start(): void {
history: createBrowserHistory(),
environment: {rootNode: document, document},
settings,
extensions: [pageExtension],
apiBase: () => apiBase,
})
const root = document.getElementById('app')
Expand Down
21 changes: 21 additions & 0 deletions apps/conciv/src/extension/create-instances.ts
Original file line number Diff line number Diff line change
@@ -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 []
}
})
}
10 changes: 5 additions & 5 deletions apps/conciv/src/extension/pane-grab.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
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,
comment: actions.comment,
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,
}
}
41 changes: 13 additions & 28 deletions apps/conciv/src/pane/chat-pane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -96,24 +93,26 @@ function activeCallTitle(
return title
}

function grabTexts(grabs: ReadonlyArray<StagedGrab>): string[] {
return grabs.map((grab) => grab.text)
}

type ComposerApi = {
addAttachment: (file: File) => Promise<void>
addAttachment: (file: File) => Promise<string | null>
}

function ComposerWiring(props: {onReady: (api: ComposerApi) => void}): JSX.Element {
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 <></>
}

Expand Down Expand Up @@ -167,7 +166,6 @@ export function ChatPane(props: {sessionId: string}): JSX.Element {
appData,
reachability,
notices,
grabStore: pane.grabStore,
refetchMarkers: () => markers.refetch(),
})

Expand Down Expand Up @@ -240,17 +238,11 @@ export function ChatPane(props: {sessionId: string}): JSX.Element {
const PaneAttachment = (slotProps: {removable?: boolean}): JSX.Element => (
<AttachmentByMime cards={attachments().cards} removable={slotProps.removable} />
)
const groundGrab = async (grab: Grab): Promise<void> => {
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[] =>
Expand Down Expand Up @@ -333,13 +325,6 @@ export function ChatPane(props: {sessionId: string}): JSX.Element {
<ExtensionSurface name="status" instances={instances} />
<ExtensionSurface name="footer" instances={instances} />
</div>
<Show when={pane.grabStore.grabs().length > 0}>
<GrabStrip class="flex flex-col">
<For each={pane.grabStore.grabs()}>
{(grab) => <GrabReference grab={grab} onRemove={() => pane.grabStore.remove(grab)} />}
</For>
</GrabStrip>
</Show>
<Suspense>
<Show when={draftStorage()}>
{(storage) => (
Expand Down
16 changes: 12 additions & 4 deletions apps/conciv/src/pane/draft-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,17 @@ 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<typeof PersistedDraftSchema>
Expand Down Expand Up @@ -41,13 +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<PaneDraftStorage> {
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, attachments: row.attachments}) : null
Comment on lines 56 to +58

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in ee547ab, by making the ids collision-resistant rather than regenerating on restore.

You are right that the existing regression test cannot catch this — it remounts within one page, so the module-level counter never resets. createDocumentAttachmentAdapter now uses the attachmentId() helper that the primitive adapters in attachment-adapter.ts already share (crypto.randomUUID() with a counter fallback for non-secure contexts), so a restored document-<uuid> cannot collide with a freshly staged one.

Regenerating on restore was the alternative; reusing the existing helper fixes it for every document adapter rather than only the grab path, and keeps one id scheme in the package.

let selection: SelectionOffsets | null = null
const write = debounce(
(draft: PersistedDraft) => {
Expand All @@ -58,7 +66,7 @@ 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(() => {})
},
Expand Down
97 changes: 0 additions & 97 deletions apps/conciv/src/pane/grab-reference.tsx

This file was deleted.

14 changes: 0 additions & 14 deletions apps/conciv/src/pane/grab-source-label.ts

This file was deleted.

Loading
Loading