diff --git a/README.md b/README.md index c9d35747..362e46a7 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,178 @@ The desktop app can also connect or pair with Cave through a least-privilege native adapter and render bounded, read-only canonical chat data. Explicit demo routes remain available for design exploration. +## Conversation chapters + +Open **Ongoing** in a conversation to navigate UTC-day chapters without +changing its original transcript. This is an exact-conversation view, not a +merge of conversations that share a familiar name. **This device** notes stay +separate, and the Cave source stays read-only. + +Switching familiars restores the exact conversation you last selected, including +an available message anchor. References and unsent drafts stay in memory, scoped +to the live source, writer, familiar ID, and conversation ID. Changing sources +never copies a draft. Reloading clears drafts and navigation preferences, not +durably saved local notes. A missing remembered conversation or message shows an +unavailable notice rather than silently choosing a newer conversation. + +The installed frozen SDK does not yet expose `listConversationChapters`. +Production Chat says so and offers navigation over already-loaded messages +only, marking partial history rather than prefetching bodies. The additive +DEVELOPMENT read port can consume typed producer headers after a separately +qualified SDK/native capability is admitted. It keeps the eight-page ceiling, +bounded memory-only query cache, revision checks, and source-switch isolation. +Malformed or cross-conversation chapter headers and repeated anchors are rejected. +It does not replace `vendor/opencoven-sdk` bytes or either conformance lock. + +## Local save recovery + +If local storage cannot be read during startup, **Retry local storage** retries +opening it without starting Cave or substituting an empty successful session. +Memory-only fallback applies only when the IndexedDB API is genuinely absent. +If a present API denies or fails an open, or an older window blocks its upgrade, +startup stays at the retry gate. Close the older windows before retrying. +Retries do not queue additional opens behind an already-blocked request; once +unblocked, that abandoned upgrade is aborted and closed without migrating in the +background. A subsequent explicit retry opens the retained history. + +Conversation creation and ordinary message saves distinguish a confirmed commit +followed by a failed history refresh from an unconfirmed commit acknowledgement. +Neither outcome invites a second write. **Reconcile local save** reads the exact +allocated record and refreshes history without resubmitting it. A committed save +whose exact record is available clears the matching draft. Only a no-commit +outcome permits retrying the retained draft. A known successful commit remains +committed even if its record is later deleted or discarded: commit outcome and +current availability are separate facts. Recovery keeps the unavailable saved +text or conversation title copyable until **Dismiss unavailable save**; it never +resubmits or recreates deleted data. Read failures stay visible and keep the retry +blocked. If an +unavailable note prevents determining an unconfirmed message's outcome, it is +not treated as an unsaved message. +Recovery content remains read-only and copyable while the outcome is unresolved. +A backend without a shared revision cannot prove an unconfirmed save absent: +the save stays blocked, even if a read returns no record. A later exact positive +receipt can resolve it, and a known commit remains committed if its content is +missing. The default atomic IndexedDB and memory providers retain revision-based +absence reconciliation; no background retry loop or alternate write is added. + +Pending save receipts and completed reconciliation results are scoped to the +live local store. They survive navigation within this app session, not reload +or restart. After restarting, inspect saved conversations and messages before +submitting again; there is no cross-session exactly-once guarantee. + +## Retained local side notes + +You can explore a separate note without changing your parent conversation: + +1. Select **This device**, open a conversation, and choose **New retained side note**. +2. Save messages in the empty side note. Your parent draft stays separate. +3. Select individual messages, choose **Review Bring back**, and edit the excerpt. +4. Choose **Bring back reviewed excerpt** to save exactly that text to the original + local parent as an inert user note. + +**Close note** retains its messages. **Reopen note** permits more writing. +**Discard note** requires confirmation and removes the note's local messages; +previously reviewed imports remain in the parent. A minimal creation tombstone +prevents a retried create operation from recreating a discarded note. +An uncertain creation retries with its original key. Once a replay confirms that +the note was discarded, the next explicit **New retained side note** starts a +fresh creation request. +Pending creation keys and acknowledgements survive navigation in exact +source/writer/familiar/parent-scoped session memory. **Retry retained side note +creation** replays that request instead of starting another one. Late +acknowledgements cannot clear a newer request or navigate another parent. +These keys do **not** survive reload or restart: inspect the durable retained-note +list and open any already-created note before choosing to create another. +There is no cross-session exactly-once creation guarantee or durable pending outbox. + +An attempted review keeps its operation key, selected message IDs, edited text, +and local branch preconditions in source- and writer-scoped memory. Returning +to the parent or switching sources does not cancel it. After an uncertain save, +retry the unchanged review to reconcile the result before editing again. +Ordinary cancellation is disabled while the result is uncertain, so it cannot +release the original key and enable a duplicate import. The immutable excerpt +remains selectable and copyable, including while a save is pending; navigation +does not release its retry identity. +If a stale branch is definitively rejected before committing, **Review again** +captures current branch preconditions and a new operation key while retaining +your edited excerpt. An uncertain operation never becomes editable under its +old key. Failed side-note metadata reads offer **Retry local side notes** without +enabling mutations against unknown metadata. +If the exact source is missing, the rejected review stays available as a +read-only, copyable excerpt. **Choose available messages** retains that text +while preparing a new selection with a new key. If the note is gone, a definitively +rejected review can be dismissed after copying; uncertain acknowledgements cannot +be edited, canceled, or reselected. +If the selected note itself becomes unavailable, its exact stored review remains +in a read-only recovery panel with **Cancel unavailable review**. That panel +cannot import, reselect, or navigate another familiar. Cancellation is also +disabled there for uncertain or in-flight reviews: the original key and excerpt +remain retained and copyable, without offering reconciliation against an +unavailable target. Only an unattempted or definitively rejected review can be +explicitly dismissed; dismissal neither undoes a commit nor authorizes resubmission. +Fresh or reselected reviews require every selected message to be loaded. If +navigation resets the loaded pages, load the missing page or use **Clear message +selection** to choose a new exact selection; edited excerpts are retained. +Already-captured reviews retry their unchanged payload without requiring the +source page to be loaded again. + +**Pending reviews do not survive reload or restart.** Saved imports do. If you +reload after an uncertain save, inspect the parent before starting another import; +the app cannot recover that pending review's key across restarts. + +Side notes, lineage, and import receipts use the existing `ChatStore` and +IndexedDB transactions. Repeated operation keys reconcile to the same result; +changed payloads with reused keys are rejected. Competing windows are checked +at commit, and failed imports leave no partial parent record. Imports neither +merge the transcript nor execute instructions, generate replies, copy attachments, +or write to memory services. +The public import API requires validated parent/side branch preconditions; +unprepared selections cannot bypass review admission. Already-committed exact +receipts still reconcile before checking later branch changes. Legacy persisted +receipts lacking stored preconditions may also replay read-only after mandatory +request validation and exact key, parent, side, source-ID and excerpt matching. +This does not prove their historical branch snapshot or authorize a new write, +and their provenance is never rewritten. Modern receipts still reject changed +preconditions. + +The version-2 IndexedDB upgrade preserves existing records and adds operation-key +indexes plus an atomic shared mutation revision. Warm writes read only the exact +conversation preconditions and indexed operation receipts; unchanged history is +not scanned. Initial hydration and refresh after another window's writes still +load history. A successful open carries the revision sampled **before** its +initial snapshot into the store, avoiding a duplicate scan on the first write. +Concurrent changes still force refresh; a backend without revisions or a failed +initial snapshot cannot mark an unchecked snapshot fresh. Revision-read failures +during opening surface rather than silently trusting the history. +Close older app windows if they block the database upgrade. +Successful writes recheck the shared revision after their local update and +reconcile detected competing commits before notifying observers. This is not a +continuous subscription to other windows. A detected refresh compares revisions +before and after its snapshot, retries at most three snapshots, and fails +explicitly without publishing an unstable snapshot if history keeps changing. +Known root commits retain their confirmed-save recovery; keyed import/creation +replays notify observers after recovery without writing again. Commits after the +final sample still require a later refresh; no instantaneous cross-window view +is promised. The memory-only backend also uses +keyed preconditions and operation-key counts, maintained across overwrites, +deletions and discarded-note tombstones. Admission touches only the requested +records and changed rows; loading snapshots and initial hydration still scan +history. +App owns the local store subscription independently of transient panels, so +late committed writes refresh the active local transcript, sidebar and side-note +metadata without another click. These notifications neither navigate another +conversation nor refetch an active Cave source. + +These are local-only notes with **no connected familiar**, not Cave-backed side +chats. If durable storage is unavailable, the UI discloses memory-only custody. +There is no Temporary or provider-deletion guarantee. The frozen Cave source +has no side-chat writer, so it shows an unavailable notice rather than an enabled +no-op or a local fallback for canonical content. + +The installed Cave SDK omits import provenance. The source-level notice states +that limitation; canonical messages remain read-only text without inferred +import markers or changed roles. + ## Security boundaries - The main window can invoke only the reviewed `app_identity`, diff --git a/e2e/app.smoke.spec.ts b/e2e/app.smoke.spec.ts index c7c657a5..f6976124 100644 --- a/e2e/app.smoke.spec.ts +++ b/e2e/app.smoke.spec.ts @@ -1,9 +1,9 @@ import { expect, test } from '@playwright/test'; -test('preserves the local demo routes alongside the default app', async ({ page }) => { +test('preserves the local demo routes alongside the default app', async ({ page, baseURL }) => { await page.goto('/?demo=chat'); - await expect(page).toHaveURL('http://127.0.0.1:4174/?demo=chat'); + await expect(page).toHaveURL(new URL('/?demo=chat', baseURL).href); await expect(page.getByRole('complementary', { name: 'Conversations sidebar' })).toBeVisible(); await expect(page.getByRole('region', { name: 'Held action' })).toBeVisible(); // Exact: the composer also has a "Send options" caret beside Send. @@ -12,19 +12,19 @@ test('preserves the local demo routes alongside the default app', async ({ page await page.goto('/?demo=messages'); - await expect(page).toHaveURL('http://127.0.0.1:4174/?demo=messages'); + await expect(page).toHaveURL(new URL('/?demo=messages', baseURL).href); await expect(page.getByRole('complementary', { name: 'Conversations' })).toBeVisible(); await expect(page.getByRole('button', { name: 'Send' })).toBeVisible(); await page.goto('/?demo=minimal'); - await expect(page).toHaveURL('http://127.0.0.1:4174/?demo=minimal'); + await expect(page).toHaveURL(new URL('/?demo=minimal', baseURL).href); await expect(page.getByText('Chats', { exact: true })).toBeVisible(); await expect(page.getByText('Familiars', { exact: true })).toBeVisible(); await page.goto('/?demo=familiars-reads'); - await expect(page).toHaveURL('http://127.0.0.1:4174/?demo=familiars-reads'); + await expect(page).toHaveURL(new URL('/?demo=familiars-reads', baseURL).href); await expect(page.getByRole('complementary', { name: 'Conversations sidebar' })).toBeVisible(); await expect(page.getByRole('button', { name: /Q3 pricing evidence map/ })).toBeVisible(); // Stage 1 has no send capability yet; the composer notice says so rather diff --git a/e2e/app.tauri-mock.spec.ts b/e2e/app.tauri-mock.spec.ts index 2270d48a..ecd534bf 100644 --- a/e2e/app.tauri-mock.spec.ts +++ b/e2e/app.tauri-mock.spec.ts @@ -1,14 +1,20 @@ import { expect, test } from '@playwright/test'; +import { localContinuityJourney } from './helpers/local-continuity'; declare global { interface Window { __mockInvokeCallCounts?: Record; + __recordMockNativeCommand?: (command: string) => Promise; } } test('renders the Phase 1 read-only happy path through the mocked Tauri boundary', async ({ page, }) => { + const invokedCommands: string[] = []; + await page.exposeFunction('__recordMockNativeCommand', (command: string) => { + invokedCommands.push(command); + }); await page.addInitScript(() => { const capabilities = [ 'health', @@ -79,6 +85,13 @@ test('renders the Phase 1 read-only happy path through the mocked Tauri boundary }, }; const NATIVE_HANDLE = 'mock-native-handle'; + const NEXT_FAMILIARS = 'ZmFtaWxpYXJzLXBhZ2UtMg'; + const laterConversation = { + id: 'conversation-51', + familiarId: 'familiar-51', + title: 'Later-page familiar thread', + updatedAt: '2026-08-25T00:00:00.000Z', + }; function isPlainObject(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); @@ -153,6 +166,7 @@ test('renders the Phase 1 read-only happy path through the mocked Tauri boundary configurable: true, value: { invoke(command: string, args?: unknown) { + void window.__recordMockNativeCommand?.(command); callCounts[command] = (callCounts[command] ?? 0) + 1; switch (command) { @@ -172,27 +186,48 @@ test('renders the Phase 1 read-only happy path through the mocked Tauri boundary access: 'chat:read', health, }); - case 'cave_list_familiars': + case 'cave_list_familiars': { + const laterPage = + isPlainObject(args) && + isPlainObject(args.page) && + args.page.cursor === NEXT_FAMILIARS; assertOperationArgs(command, args, { handle: NATIVE_HANDLE, - page: { limit: 50 }, + page: { limit: 50, ...(laterPage ? { cursor: NEXT_FAMILIARS } : {}) }, }); return Promise.resolve({ ...health, data: { - familiars: [ - { - id: 'familiar-1', - displayName: 'Mara', - role: 'Guide', - }, - ], - }, - cursor: { - current: 'cursor-familiars', - hasMore: false, + familiars: laterPage + ? [ + { + id: 'familiar-51', + displayName: 'Familiar 51', + role: 'Guide', + }, + ] + : [ + { + id: 'familiar-1', + displayName: 'Mara', + role: 'Guide', + }, + ...Array.from({ length: 49 }, (_, index) => ({ + id: `familiar-${index + 2}`, + displayName: `Familiar ${index + 2}`, + role: 'Guide', + })), + ], }, + cursor: laterPage + ? { current: NEXT_FAMILIARS, hasMore: false } + : { + current: 'cursor-familiars', + hasMore: true, + next: NEXT_FAMILIARS, + }, }); + } case 'cave_list_projects': assertOperationArgs(command, args, { handle: NATIVE_HANDLE, @@ -231,6 +266,7 @@ test('renders the Phase 1 read-only happy path through the mocked Tauri boundary title: 'Mocked native thread', updatedAt: '2026-08-25T00:00:00.000Z', }, + laterConversation, ], }, cursor: { @@ -238,49 +274,88 @@ test('renders the Phase 1 read-only happy path through the mocked Tauri boundary hasMore: false, }, }); - case 'cave_get_conversation': + case 'cave_get_conversation': { + const later = isPlainObject(args) && args.conversationId === laterConversation.id; assertOperationArgs(command, args, { handle: NATIVE_HANDLE, - conversationId: 'conversation-1', + conversationId: later ? laterConversation.id : 'conversation-1', }); return Promise.resolve({ ...health, data: { - conversation: { - id: 'conversation-1', - familiarId: 'familiar-1', - title: 'Mocked native thread', - updatedAt: '2026-08-25T00:00:00.000Z', - }, + conversation: later + ? laterConversation + : { + id: 'conversation-1', + familiarId: 'familiar-1', + title: 'Mocked native thread', + updatedAt: '2026-08-25T00:00:00.000Z', + }, }, }); - case 'cave_list_conversation_messages': + } + case 'cave_list_conversation_messages': { + const later = isPlainObject(args) && args.conversationId === laterConversation.id; assertOperationArgs(command, args, { handle: NATIVE_HANDLE, - conversationId: 'conversation-1', + conversationId: later ? laterConversation.id : 'conversation-1', page: { limit: 50 }, }); return Promise.resolve({ ...health, data: { - messages: [ - { - id: 'message-1', - conversationId: 'conversation-1', - parentId: null, - role: 'assistant', - text: 'Hello from mocked Cave.', - createdAt: '2026-08-25T00:00:00.000Z', - attachmentCount: 0, - toolCount: 0, - }, - ], + messages: later + ? [ + { + id: 'message-51', + conversationId: laterConversation.id, + parentId: null, + role: 'assistant', + text: 'Exact later-page familiar transcript.', + createdAt: laterConversation.updatedAt, + attachmentCount: 0, + toolCount: 0, + }, + ] + : [ + { + id: 'message-1', + conversationId: 'conversation-1', + parentId: null, + role: 'assistant', + text: 'Hello from mocked Cave.', + createdAt: '2026-08-25T00:00:00.000Z', + attachmentCount: 0, + toolCount: 0, + }, + { + id: 'message-2', + conversationId: 'conversation-1', + parentId: 'message-1', + role: 'user', + text: 'Keep the original thread. Pick up where we left off.', + createdAt: '2026-08-26T09:00:00.000Z', + attachmentCount: 0, + toolCount: 0, + }, + { + id: 'message-3', + conversationId: 'conversation-1', + parentId: 'message-2', + role: 'assistant', + text: 'Same conversation, a new day. The earlier chapter stays exactly where it was.', + createdAt: '2026-08-26T09:01:00.000Z', + attachmentCount: 0, + toolCount: 0, + }, + ], }, cursor: { current: 'cursor-messages', hasMore: false, }, }); + } default: return Promise.reject(new Error(`Unhandled mock Tauri command: ${command}`)); } @@ -305,7 +380,35 @@ test('renders the Phase 1 read-only happy path through the mocked Tauri boundary await expect(page.getByRole('combobox', { name: 'Familiar' })).toHaveValue('familiar-1'); await expect(page.locator('.chat-shell__familiar-meta')).toHaveText('Guide'); await expect(page.getByText('Read-only chat')).toBeVisible(); + await expect( + page.getByText( + 'Import provenance is unavailable through this installed SDK. Messages are displayed as read-only text.', + ), + ).toBeVisible(); + await expect(page.locator('.chat-shell__message-role')).toHaveText([ + 'assistant', + 'user', + 'assistant', + ]); + await expect(page.getByText(/Reviewed local excerpt/)).toHaveCount(0); await expect(page.getByText('Cave connection requires the desktop app.')).toHaveCount(0); + await page.getByRole('button', { name: /Ongoing/ }).click(); + await expect(page.getByText(/Full chapter index unsupported/)).toBeVisible(); + await expect(page.getByRole('button', { name: '2026-08-26 2 loaded turns' })).toBeVisible(); + await page.getByRole('button', { name: '2026-08-26 2 loaded turns' }).click(); + await expect(page.locator('.chat-shell__message').nth(1)).toBeFocused(); + await expect(page.getByRole('textbox')).toHaveCount(0); + + const screenshotPath = process.env.CONTINUITY_SCREENSHOT_PATH; + if (screenshotPath) { + await page.setViewportSize({ width: 1360, height: 960 }); + await page.screenshot({ path: screenshotPath, fullPage: true }); + await page.setViewportSize({ width: 390, height: 844 }); + await page.screenshot({ + path: screenshotPath.replace(/\.png$/, '-narrow.png'), + fullPage: true, + }); + } const callCounts = await page.evaluate(() => window.__mockInvokeCallCounts ?? {}); const expectedCommands = [ @@ -322,4 +425,28 @@ test('renders the Phase 1 read-only happy path through the mocked Tauri boundary for (const command of expectedCommands) { expect(callCounts[command], `expected ${command} to be invoked exactly once`).toBe(1); } + + const familiar = page.getByRole('combobox', { name: 'Familiar' }); + await expect(familiar.locator('option')).toHaveCount(50); + await page.getByRole('button', { name: 'Load more familiars' }).click(); + await familiar.selectOption('familiar-51'); + await expect(page.getByRole('heading', { name: 'Later-page familiar thread' })).toBeVisible(); + await expect(page.getByText('Exact later-page familiar transcript.')).toBeVisible(); + await page.getByRole('button', { name: 'This device' }).click(); + await page.getByRole('button', { name: 'Coven Cave' }).click(); + await expect(page.getByRole('heading', { name: 'Later-page familiar thread' })).toBeVisible(); + await expect(page.getByText('Exact later-page familiar transcript.')).toBeVisible(); + await expect(familiar).toHaveValue('familiar-51'); + await expect(familiar.locator('option:checked')).toHaveText( + 'Saved familiar familiar-51 — not loaded', + ); + await page.getByRole('button', { name: 'Load more familiars' }).click(); + await expect(familiar.locator('option:checked')).toHaveText('Familiar 51 — Guide'); + await expect(familiar.locator('option')).toHaveCount(51); + await expect(page.getByText('Exact later-page familiar transcript.')).toBeVisible(); + + const beforeLocalJourney = [...invokedCommands]; + await page.getByRole('button', { name: 'This device' }).click(); + await localContinuityJourney({ page, visit: false, screenshotSuffix: 'tauri-mock-local' }); + expect(invokedCommands).toEqual(beforeLocalJourney); }); diff --git a/e2e/helpers/local-continuity.ts b/e2e/helpers/local-continuity.ts new file mode 100644 index 00000000..de892425 --- /dev/null +++ b/e2e/helpers/local-continuity.ts @@ -0,0 +1,232 @@ +import { expect, type Page } from '@playwright/test'; + +export async function loseNextWriteAcknowledgement(page: Page) { + await page.evaluate(() => { + const original = IDBDatabase.prototype.transaction; + let dropNextAcknowledgement = true; + IDBDatabase.prototype.transaction = function (...args) { + const transaction = Reflect.apply(original, this, args) as IDBTransaction; + if (args[1] === 'readwrite' && dropNextAcknowledgement) { + dropNextAcknowledgement = false; + // Commit really succeeds; only its acknowledgement is lost. + Object.defineProperty(transaction, 'oncomplete', { + configurable: true, + set() { + transaction.addEventListener( + 'complete', + () => { + transaction.onerror?.call(transaction, new Event('error')); + }, + { once: true }, + ); + }, + }); + } + return transaction; + }; + }); +} + +export async function localContinuityJourney({ + page, + visit = true, + screenshotSuffix = 'local', + viewport = { width: 390, height: 844 }, +}: { + page: Page; + visit?: boolean; + screenshotSuffix?: string; + viewport?: { width: number; height: number }; +}) { + const mutations: string[] = []; + page.on('request', (request) => { + if (request.method() !== 'GET') mutations.push(`${request.method()} ${request.url()}`); + }); + await page.setViewportSize(viewport); + if (visit) await page.goto('/'); + await page.getByRole('button', { name: 'New', exact: true }).click(); + await expect(page.getByRole('heading', { name: 'New conversation' })).toBeVisible(); + await page.getByRole('textbox', { name: 'Message', exact: true }).fill('unsent parent'); + await page.getByRole('button', { name: 'New retained side note' }).click(); + await expect(page.getByRole('button', { name: 'Close note', exact: true })).toBeVisible(); + await page.getByRole('textbox', { name: 'Message', exact: true }).fill('retained raw source'); + await page.getByRole('textbox', { name: 'Message', exact: true }).press('Enter'); + await page.getByRole('checkbox', { name: /retained raw source/ }).check(); + await page.getByRole('button', { name: 'Review Bring back', exact: true }).click(); + await page.getByRole('textbox', { name: 'Reviewed excerpt' }).fill('Edited excerpt only'); + await expect( + page.getByText( + /Pending reviews survive navigation in this app session only, not reload or restart/, + ), + ).toBeVisible(); + await loseNextWriteAcknowledgement(page); + await page.getByRole('button', { name: 'Bring back reviewed excerpt' }).click(); + await expect( + page.getByText( + 'The import result could not be confirmed. Retry the unchanged review with the same operation key.', + ), + ).toBeVisible(); + await expect(page.getByRole('textbox', { name: 'Reviewed excerpt' })).toHaveValue( + 'Edited excerpt only', + ); + await expect(page.getByRole('button', { name: 'Cancel review', exact: true })).toBeDisabled(); + const uncertainExcerpt = page.getByRole('textbox', { name: 'Reviewed excerpt' }); + await expect(uncertainExcerpt).toBeEnabled(); + await expect(uncertainExcerpt).toHaveAttribute('readonly', ''); + expect( + await uncertainExcerpt.evaluate((element) => { + if (!(element instanceof HTMLTextAreaElement)) throw new Error('Missing review textarea'); + element.focus(); + element.select(); + return element.value.slice(element.selectionStart, element.selectionEnd); + }), + ).toBe('Edited excerpt only'); + const firstReceipt = await importReceipts(page); + expect(firstReceipt).toHaveLength(1); + expect(firstReceipt[0]?.text).toBe('Edited excerpt only'); + expect(firstReceipt[0]?.operationKey).toBeTruthy(); + await page.getByRole('button', { name: 'Return to parent', exact: true }).click(); + await expect(page.getByRole('heading', { name: 'New conversation', exact: true })).toBeVisible(); + await page.getByRole('button', { name: /Retained side note · open/ }).click(); + await expect(page.getByRole('textbox', { name: 'Reviewed excerpt' })).toHaveValue( + 'Edited excerpt only', + ); + await expect(page.getByRole('checkbox', { name: /retained raw source/ })).toBeChecked(); + await expect(page.getByRole('button', { name: 'Cancel review', exact: true })).toBeDisabled(); + const reviewBounds = await page.evaluate(() => { + const history = document.querySelector('.chat-shell__thread-body'); + const composer = document.querySelector('.chat-composer'); + if (!history || !composer) throw new Error('Local review regions are missing'); + return { + documentWidth: document.documentElement.scrollWidth, + documentHeight: document.documentElement.scrollHeight, + historyWidth: history.clientWidth, + historyScrollWidth: history.scrollWidth, + historyHeight: history.clientHeight, + composerBottom: composer.getBoundingClientRect().bottom, + }; + }); + expect(reviewBounds.documentWidth).toBeLessThanOrEqual(viewport.width); + expect(reviewBounds.documentHeight).toBeLessThanOrEqual(viewport.height); + expect(reviewBounds.historyScrollWidth).toBeLessThanOrEqual(reviewBounds.historyWidth); + expect(reviewBounds.historyHeight).toBeGreaterThan(0); + expect(reviewBounds.composerBottom).toBeLessThanOrEqual(viewport.height); + if (process.env.CONTINUITY_SCREENSHOT_PATH) { + await page.screenshot({ + path: process.env.CONTINUITY_SCREENSHOT_PATH.replace( + /\.png$/, + `-${screenshotSuffix}-review-retry-narrow.png`, + ), + fullPage: true, + }); + } + await page.getByRole('button', { name: 'Bring back reviewed excerpt' }).click(); + await expect(page.getByRole('textbox', { name: 'Reviewed excerpt' })).toHaveCount(0); + expect(await importReceipts(page)).toEqual(firstReceipt); + await page.getByRole('button', { name: 'Close note', exact: true }).click(); + await expect(page.getByText('Edited excerpt only', { exact: true })).toBeVisible(); + await expect(page.getByRole('textbox', { name: 'Message', exact: true })).toHaveValue( + 'unsent parent', + ); + await page.reload(); + await expect(page.getByText('Edited excerpt only', { exact: true })).toBeVisible(); + expect(await importReceipts(page)).toEqual(firstReceipt); + // Draft custody is explicitly in-memory, not silently persisted. + await expect(page.getByRole('textbox', { name: 'Message', exact: true })).toHaveValue(''); + await expect(page.getByText('retained raw source', { exact: true })).toHaveCount(0); + const retained = page.getByRole('button', { name: /Retained side note · closed/ }); + await retained.click(); + await expect(page.getByRole('textbox', { name: 'Message', exact: true })).toBeDisabled(); + await page.getByRole('button', { name: 'Reopen note' }).click(); + await expect(page.getByRole('textbox', { name: 'Message', exact: true })).toBeEnabled(); + await page.getByRole('button', { name: /Ongoing/ }).focus(); + await page.keyboard.press('Enter'); + await expect(page.getByRole('navigation', { name: 'UTC chapters' })).toBeVisible(); + await page.getByRole('navigation', { name: 'UTC chapters' }).getByRole('button').first().focus(); + await page.keyboard.press('Escape'); + await expect(page.getByRole('button', { name: /Ongoing/ })).toBeFocused(); + await expect(page.getByRole('navigation', { name: 'UTC chapters' })).toHaveCount(0); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe( + true, + ); + if (process.env.CONTINUITY_SCREENSHOT_PATH) { + await page.screenshot({ + path: process.env.CONTINUITY_SCREENSHOT_PATH.replace( + /\.png$/, + `-${screenshotSuffix}-retained-narrow.png`, + ), + fullPage: true, + }); + await page.setViewportSize({ width: 1360, height: 960 }); + await page.screenshot({ + path: process.env.CONTINUITY_SCREENSHOT_PATH.replace( + /\.png$/, + `-${screenshotSuffix}-retained-wide.png`, + ), + fullPage: true, + }); + await page.setViewportSize(viewport); + } + await page.getByRole('button', { name: 'Discard note…' }).click(); + await page.getByRole('button', { name: 'Discard local messages' }).click(); + await expect(page.getByText('Edited excerpt only', { exact: true })).toBeVisible(); + await page.reload(); + await expect(page.getByText('Edited excerpt only', { exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: /Retained side note ·/ })).toHaveCount(0); + expect(mutations).toEqual([]); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe( + true, + ); + if (process.env.CONTINUITY_SCREENSHOT_PATH) { + await page.screenshot({ + path: process.env.CONTINUITY_SCREENSHOT_PATH.replace( + /\.png$/, + `-${screenshotSuffix}-narrow.png`, + ), + fullPage: true, + }); + } +} + +async function importReceipts(page: Page) { + return page.evaluate(async () => { + const database = await new Promise((resolve, reject) => { + const request = indexedDB.open('opencoven-chat'); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + try { + return await new Promise<{ id: string; operationKey: string; text: string }[]>( + (resolve, reject) => { + const request = database + .transaction('messages', 'readonly') + .objectStore('messages') + .getAll(); + request.onsuccess = () => { + const rows = request.result as { + id: string; + text: string; + broughtBack?: { operationKey: string }; + }[]; + resolve( + rows.flatMap((row) => + row.broughtBack + ? [ + { + id: row.id, + operationKey: row.broughtBack.operationKey, + text: row.text, + }, + ] + : [], + ), + ); + }; + request.onerror = () => reject(request.error); + }, + ); + } finally { + database.close(); + } + }); +} diff --git a/e2e/local-continuity.spec.ts b/e2e/local-continuity.spec.ts new file mode 100644 index 00000000..f37f0a30 --- /dev/null +++ b/e2e/local-continuity.spec.ts @@ -0,0 +1,74 @@ +import { expect, test } from '@playwright/test'; +import { localContinuityJourney } from './helpers/local-continuity'; + +for (const viewport of [ + { width: 1180, height: 780 }, + { width: 820, height: 600 }, + { width: 390, height: 844 }, + { width: 320, height: 568 }, + { width: 844, height: 390 }, +]) { + test(`local retained notes and reviewed import retries survive reload at ${viewport.width}x${viewport.height}`, async ({ + page, + }) => { + await localContinuityJourney({ page, viewport }); + }); +} + +test('a parent changed in another window cannot silently accept an earlier reviewed branch', async ({ + page, + context, +}) => { + await page.goto('/'); + await page.getByRole('button', { name: 'New', exact: true }).click(); + await page.getByRole('button', { name: 'New retained side note' }).click(); + await expect(page.getByRole('button', { name: 'Close note', exact: true })).toBeVisible(); + await page.getByRole('textbox', { name: 'Message', exact: true }).fill('Original side text'); + await page.getByRole('button', { name: 'Send', exact: true }).click(); + await page.getByRole('checkbox', { name: /Original side text/ }).check(); + await page.getByRole('button', { name: 'Review Bring back', exact: true }).click(); + await page.getByRole('textbox', { name: 'Reviewed excerpt' }).fill('Earlier reviewed excerpt'); + + const other = await context.newPage(); + await other.goto('/'); + await expect(other.getByRole('heading', { name: 'New conversation', exact: true })).toBeVisible(); + await other + .getByRole('textbox', { name: 'Message', exact: true }) + .fill('Other window parent edit'); + await other.getByRole('button', { name: 'Send', exact: true }).click(); + await expect( + other + .getByRole('list', { name: 'Messages' }) + .getByText('Other window parent edit', { exact: true }), + ).toBeVisible(); + + await page.getByRole('button', { name: 'Bring back reviewed excerpt', exact: true }).click(); + await expect(page.getByText(/The reviewed local branch changed/)).toBeVisible(); + await expect(page.getByRole('button', { name: 'Review again', exact: true })).toBeVisible(); + await expect(page.getByRole('textbox', { name: 'Reviewed excerpt' })).toHaveAttribute( + 'readonly', + '', + ); + await page.getByRole('button', { name: 'Return to parent', exact: true }).click(); + await expect(page.getByText('Other window parent edit', { exact: true })).toBeVisible(); + await expect(page.getByText('Earlier reviewed excerpt', { exact: true })).toHaveCount(0); + await page.getByRole('button', { name: /Retained side note · open/ }).click(); + await page.getByRole('button', { name: 'Review again', exact: true }).click(); + await page.getByRole('textbox', { name: 'Reviewed excerpt' }).fill('Fresh reviewed excerpt'); + await page.getByRole('button', { name: 'Bring back reviewed excerpt', exact: true }).click(); + await expect(page.getByRole('textbox', { name: 'Reviewed excerpt' })).toHaveCount(0); + await page.getByRole('button', { name: 'Return to parent', exact: true }).click(); + await expect( + page + .getByRole('list', { name: 'Messages' }) + .getByText('Fresh reviewed excerpt', { exact: true }), + ).toBeVisible(); + await page.reload(); + await expect(page.getByText('Other window parent edit', { exact: true })).toBeVisible(); + await expect(page.getByText('Earlier reviewed excerpt', { exact: true })).toHaveCount(0); + await expect( + page + .getByRole('list', { name: 'Messages' }) + .getByText('Fresh reviewed excerpt', { exact: true }), + ).toHaveCount(1); +}); diff --git a/e2e/local-startup.spec.ts b/e2e/local-startup.spec.ts new file mode 100644 index 00000000..dc1e3004 --- /dev/null +++ b/e2e/local-startup.spec.ts @@ -0,0 +1,189 @@ +import { expect, type Page, test } from '@playwright/test'; + +declare global { + interface Window { + __legacyStartupHolder?: IDBDatabase; + __startupOpenCount: number; + __startupOpenErrors: number; + __startupClosedConnections: number; + __startupNativeCalls: number; + } +} + +async function holdLegacyHistory(page: Page) { + await page.route( + '**/', + (route) => + route.fulfill({ + contentType: 'text/html', + body: '', + }), + { times: 1 }, + ); + await page.goto('/'); + await page.evaluate( + () => + new Promise((resolve, reject) => { + const request = indexedDB.open('opencoven-chat', 1); + request.onerror = () => reject(request.error); + request.onupgradeneeded = () => { + const db = request.result; + db.createObjectStore('meta', { keyPath: 'key' }).put({ key: 'schemaVersion', value: 1 }); + const conversations = db.createObjectStore('conversations', { keyPath: 'id' }); + conversations.createIndex('by_updated', ['updatedAt', 'id']); + const messages = db.createObjectStore('messages', { keyPath: 'id' }); + messages.createIndex('by_conversation', ['conversationId', 'createdAt', 'id']); + const timestamp = '2026-09-11T00:00:00.000Z'; + conversations.put({ + id: 'saved-parent', + familiarId: 'local', + title: 'Retained startup history', + createdAt: timestamp, + updatedAt: timestamp, + }); + messages.put({ + id: 'saved-message', + conversationId: 'saved-parent', + parentId: null, + role: 'user', + text: 'History must not disappear', + createdAt: timestamp, + }); + }; + request.onsuccess = () => { + window.__legacyStartupHolder = request.result; + resolve(); + }; + }), + ); +} + +async function instrumentOpening(page: Page, failFirst = false) { + await page.addInitScript((fail) => { + window.__startupOpenCount = 0; + window.__startupOpenErrors = 0; + window.__startupClosedConnections = 0; + window.__startupNativeCalls = 0; + Object.defineProperty(window, '__TAURI_INTERNALS__', { + configurable: true, + value: { + invoke: () => { + window.__startupNativeCalls += 1; + return Promise.reject(new Error('Unexpected native startup invocation')); + }, + }, + }); + const open = IDBFactory.prototype.open; + const close = IDBDatabase.prototype.close; + IDBFactory.prototype.open = function (...args) { + window.__startupOpenCount += 1; + if (fail && window.__startupOpenCount === 1) { + throw new DOMException('Injected present-factory open denial', 'SecurityError'); + } + const request = Reflect.apply(open, this, args) as IDBOpenDBRequest; + request.addEventListener('error', () => { + window.__startupOpenErrors += 1; + }); + return request; + }; + IDBDatabase.prototype.close = function () { + window.__startupClosedConnections += 1; + return Reflect.apply(close, this, []); + }; + }, failFirst); +} + +test('a blocked legacy upgrade keeps history, bounds retries and aborts its abandoned open before explicit retry', async ({ + page, + context, +}) => { + const holder = await context.newPage(); + await holdLegacyHistory(holder); + await instrumentOpening(page); + await page.goto('/'); + await expect(page.getByRole('alert')).toContainText('Local chat storage could not be opened'); + await expect(page.getByRole('button', { name: 'New', exact: true })).toHaveCount(0); + for (let retry = 0; retry < 3; retry += 1) { + await page.getByRole('button', { name: 'Retry local storage' }).click(); + await expect(page.getByRole('alert')).toBeVisible(); + } + expect(await page.evaluate(() => window.__startupOpenCount)).toBe(1); + await holder.evaluate(() => window.__legacyStartupHolder?.close()); + await expect.poll(() => page.evaluate(() => window.__startupOpenErrors)).toBe(1); + expect(await page.evaluate(() => window.__startupClosedConnections)).toBe(1); + const abandoned = await holder.evaluate( + () => + new Promise((resolve, reject) => { + const request = indexedDB.open('opencoven-chat', 1); + request.onerror = () => reject(request.error); + request.onsuccess = () => { + const db = request.result; + const tx = db.transaction(['meta', 'conversations', 'messages']); + const schema = tx.objectStore('meta').get('schemaVersion'); + const message = tx.objectStore('messages').get('saved-message'); + const operations = tx.objectStore('conversations').indexNames.contains('by_operation'); + tx.onabort = () => { + db.close(); + reject(tx.error); + }; + tx.oncomplete = () => { + resolve({ + version: db.version, + schema: schema.result.value, + text: message.result.text, + operations, + }); + db.close(); + }; + }; + }), + ); + expect(abandoned).toEqual({ + version: 1, + schema: 1, + text: 'History must not disappear', + operations: false, + }); + await expect(page.getByRole('button', { name: 'Retry local storage' })).toBeVisible(); + await page.getByRole('button', { name: 'Retry local storage' }).click(); + await expect(page.getByRole('heading', { name: 'Retained startup history' })).toBeVisible(); + await expect( + page.getByRole('list', { name: 'Messages' }).getByText('History must not disappear'), + ).toBeVisible(); + expect(await page.evaluate(() => window.__startupOpenCount)).toBe(2); + expect(await page.evaluate(() => window.__startupNativeCalls)).toBe(0); + await holder.close(); +}); + +test('a present-factory open error uses the retry gate and a successful retry restores retained history without Cave', async ({ + page, + context, +}) => { + const holder = await context.newPage(); + await holdLegacyHistory(holder); + await holder.evaluate(() => window.__legacyStartupHolder?.close()); + await instrumentOpening(page, true); + await page.goto('/'); + await expect(page.getByRole('alert')).toContainText('Local chat storage could not be opened'); + await expect(page.getByRole('button', { name: 'New', exact: true })).toHaveCount(0); + await page.getByRole('button', { name: 'Retry local storage' }).click(); + await expect(page.getByRole('heading', { name: 'Retained startup history' })).toBeVisible(); + await expect( + page.getByRole('list', { name: 'Messages' }).getByText('History must not disappear'), + ).toBeVisible(); + expect(await page.evaluate(() => window.__startupOpenCount)).toBe(2); + expect(await page.evaluate(() => window.__startupNativeCalls)).toBe(0); + await holder.close(); +}); + +test('a genuinely absent IndexedDB API retains the disclosed memory-only mode', async ({ + page, +}) => { + await page.addInitScript(() => { + Object.defineProperty(window, 'indexedDB', { configurable: true, value: undefined }); + }); + await page.goto('/'); + await page.getByRole('button', { name: 'New', exact: true }).click(); + await expect(page.getByText(/these messages are kept in memory only/)).toBeVisible(); + await expect(page.getByRole('button', { name: 'Retry local storage' })).toHaveCount(0); +}); diff --git a/e2e/local-storage.spec.ts b/e2e/local-storage.spec.ts new file mode 100644 index 00000000..041a5a88 --- /dev/null +++ b/e2e/local-storage.spec.ts @@ -0,0 +1,1121 @@ +import { expect, type Page, test } from '@playwright/test'; +import { loseNextWriteAcknowledgement } from './helpers/local-continuity'; + +declare global { + interface Window { + __chatStorageReads: { full: number; writeFull: number; keyed: number; indexed: number }; + __resumeLocalRevisionRead?: () => void; + __restoreRootReads?: () => void; + } +} + +async function seedLegacyHistory(page: Page, count: number, sideCount = 1) { + await page.route( + '**/', + (route) => route.fulfill({ contentType: 'text/html', body: '' }), + { times: 1 }, + ); + await page.goto('/'); + await page.evaluate( + async ({ size, sideCount }) => { + await new Promise((resolve, reject) => { + const request = indexedDB.open('opencoven-chat', 1); + request.onupgradeneeded = () => { + const db = request.result; + db.createObjectStore('meta', { keyPath: 'key' }).put({ key: 'schemaVersion', value: 1 }); + db.createObjectStore('conversations', { keyPath: 'id' }).createIndex('by_updated', [ + 'updatedAt', + 'id', + ]); + db.createObjectStore('messages', { keyPath: 'id' }).createIndex('by_conversation', [ + 'conversationId', + 'createdAt', + 'id', + ]); + }; + request.onerror = () => reject(request.error); + request.onsuccess = () => { + const db = request.result; + const tx = db.transaction(['conversations', 'messages'], 'readwrite'); + tx.onabort = () => { + db.close(); + reject(tx.error); + }; + tx.oncomplete = () => { + db.close(); + resolve(); + }; + const timestamp = '2026-09-01T00:00:00.000Z'; + const base = { familiarId: 'local', createdAt: timestamp, updatedAt: timestamp }; + const conversations = tx.objectStore('conversations'); + conversations.put({ ...base, id: 'parent', title: 'Legacy parent', revision: 1 }); + for (const [id, state] of [ + ['side', 'open'], + ['history', 'closed'], + ['discarded', 'discarded'], + ]) { + conversations.put({ + ...base, + id, + title: `Legacy ${id}`, + revision: 1, + side: { parentConversationId: 'parent', operationKey: `create-${id}`, state }, + }); + } + const messages = tx.objectStore('messages'); + messages.put({ + id: 'source', + conversationId: 'side', + parentId: null, + role: 'user', + text: 'Legacy source', + createdAt: timestamp, + }); + for (let index = 2; index <= sideCount; index += 1) { + messages.put({ + id: `side-source-${index}`, + conversationId: 'side', + parentId: index === 2 ? 'source' : `side-source-${index - 1}`, + role: 'user', + text: `Side source ${index}`, + createdAt: new Date(Date.parse(timestamp) + index).toISOString(), + }); + } + messages.put({ + id: 'imported', + conversationId: 'parent', + parentId: null, + role: 'user', + text: 'Legacy imported excerpt', + createdAt: timestamp, + broughtBack: { + operationKey: 'legacy-import', + sideConversationId: 'side', + sourceMessageIds: ['source'], + }, + }); + for (let i = 0; i < size; i += 1) { + messages.put({ + id: `history-${i}`, + conversationId: 'history', + parentId: i ? `history-${i - 1}` : null, + role: 'user', + text: `Unrelated retained history ${i}`, + createdAt: new Date(Date.parse(timestamp) + i).toISOString(), + }); + } + }; + }); + }, + { size: count, sideCount }, + ); + await page.goto('/'); + await expect(page.getByRole('heading', { name: 'Legacy parent', exact: true })).toBeVisible(); + await expect( + page.getByRole('list', { name: 'Messages' }).getByText('Legacy imported excerpt'), + ).toBeVisible(); +} + +async function send(page: Page, text: string) { + await page.getByRole('textbox', { name: 'Message', exact: true }).fill(text); + await page.getByRole('button', { name: 'Send', exact: true }).click(); + await expect( + page.getByRole('list', { name: 'Messages' }).getByText(text, { exact: true }), + ).toBeVisible(); +} + +test('version-one IndexedDB migrates receipts and discard tombstones without rewriting history', async ({ + page, +}) => { + await seedLegacyHistory(page, 50); + const result = await page.evaluate( + () => + new Promise((resolve, reject) => { + const request = indexedDB.open('opencoven-chat'); + request.onerror = () => reject(request.error); + request.onsuccess = () => { + const db = request.result; + if (db.version !== 2) { + resolve({ version: db.version }); + db.close(); + return; + } + const tx = db.transaction(['meta', 'conversations', 'messages']); + const receipt = tx.objectStore('messages').index('by_operation').get('legacy-import'); + const tombstone = tx + .objectStore('conversations') + .index('by_operation') + .get('create-discarded'); + const count = tx.objectStore('messages').count(); + const schema = tx.objectStore('meta').get('schemaVersion'); + const revision = tx.objectStore('meta').get('mutationRevision'); + tx.onabort = () => { + db.close(); + reject(tx.error); + }; + tx.oncomplete = () => { + resolve({ + version: db.version, + receipt: receipt.result, + tombstone: tombstone.result, + count: count.result, + schema: schema.result, + revision: revision.result, + }); + db.close(); + }; + }; + }), + ); + expect(result).toMatchObject({ + version: 2, + count: 52, + schema: { value: 2 }, + revision: { value: 0 }, + receipt: { + id: 'imported', + text: 'Legacy imported excerpt', + broughtBack: { operationKey: 'legacy-import' }, + }, + tombstone: { id: 'discarded', side: { state: 'discarded', operationKey: 'create-discarded' } }, + }); + await send(page, 'After migration'); + await page.reload(); + await expect( + page.getByRole('list', { name: 'Messages' }).getByText('After migration'), + ).toBeVisible(); + await expect( + page.getByRole('list', { name: 'Messages' }).getByText('Legacy imported excerpt'), + ).toBeVisible(); +}); + +for (const size of [50, 20_000]) { + test(`first and warm durable writes use bounded reads with ${size} unrelated retained messages`, async ({ + page, + }) => { + await seedLegacyHistory(page, size); + await page.evaluate(() => { + window.__chatStorageReads = { full: 0, writeFull: 0, keyed: 0, indexed: 0 }; + const getAll = IDBObjectStore.prototype.getAll; + IDBObjectStore.prototype.getAll = function (...args) { + window.__chatStorageReads.full += 1; + if (this.transaction.mode === 'readwrite') window.__chatStorageReads.writeFull += 1; + return Reflect.apply(getAll, this, args); + }; + const get = IDBObjectStore.prototype.get; + IDBObjectStore.prototype.get = function (...args) { + window.__chatStorageReads.keyed += 1; + return Reflect.apply(get, this, args); + }; + const indexedGet = IDBIndex.prototype.get; + IDBIndex.prototype.get = function (...args) { + window.__chatStorageReads.indexed += 1; + return Reflect.apply(indexedGet, this, args); + }; + }); + await send(page, 'First bounded append'); + await send(page, 'Second bounded append'); + await page.getByRole('button', { name: /^Legacy side/ }).click(); + await page.getByRole('checkbox', { name: /Legacy source/ }).check(); + await page.getByRole('button', { name: 'Review Bring back', exact: true }).click(); + await page.getByRole('textbox', { name: 'Reviewed excerpt' }).fill('Bounded reviewed import'); + await page.getByRole('button', { name: 'Bring back reviewed excerpt', exact: true }).click(); + await expect(page.getByRole('textbox', { name: 'Reviewed excerpt' })).toHaveCount(0); + const reads = await page.evaluate(() => window.__chatStorageReads); + await test.info().attach('durable-read-counts', { + body: JSON.stringify({ retainedMessages: size, ...reads }), + contentType: 'application/json', + }); + expect(reads.full).toBe(0); + expect(reads.writeFull).toBe(0); + expect(reads.indexed).toBe(2); + expect(reads.keyed).toBe(14); + await page.getByRole('button', { name: 'Return to parent', exact: true }).click(); + await expect( + page.getByRole('list', { name: 'Messages' }).getByText('Bounded reviewed import'), + ).toBeVisible(); + }); +} + +async function prepareFixedReview(page: Page) { + await page.getByRole('button', { name: /^Legacy side/ }).click(); + await page.getByRole('checkbox', { name: /Legacy source/ }).check(); + await page.evaluate(() => { + const original = crypto.randomUUID.bind(crypto); + let first = true; + crypto.randomUUID = () => { + if (!first) return original(); + first = false; + return '11111111-1111-4111-8111-111111111111'; + }; + }); + await page.getByRole('button', { name: 'Review Bring back', exact: true }).click(); + await page.getByRole('textbox', { name: 'Reviewed excerpt' }).fill('Atomic reviewed excerpt'); +} + +async function durableSummary(page: Page) { + return page.evaluate( + () => + new Promise<{ count: number; revision: number; imported: number }>((resolve, reject) => { + const request = indexedDB.open('opencoven-chat'); + request.onerror = () => reject(request.error); + request.onsuccess = () => { + const db = request.result; + const tx = db.transaction(['meta', 'messages']); + const count = tx.objectStore('messages').count(); + const revision = tx.objectStore('meta').get('mutationRevision'); + const imported = tx + .objectStore('messages') + .index('by_operation') + .count('11111111-1111-4111-8111-111111111111'); + tx.onabort = () => { + db.close(); + reject(tx.error); + }; + tx.oncomplete = () => { + resolve({ + count: count.result, + revision: revision.result.value, + imported: imported.result, + }); + db.close(); + }; + }; + }), + ); +} + +for (const interference of ['parent', 'side', 'creation-key', 'import-key'] as const) { + test(`transaction admission rejects a racing ${interference} change atomically`, async ({ + page, + }) => { + await seedLegacyHistory(page, 50); + await prepareFixedReview(page); + await page.evaluate((kind) => { + const original = IDBDatabase.prototype.transaction; + let first = true; + IDBDatabase.prototype.transaction = function (...args) { + if (args[1] === 'readwrite' && first) { + first = false; + // Queue the competing writer after UI review/refresh, before admission. + const competing = Reflect.apply(original, this, args) as IDBTransaction; + const conversations = competing.objectStore('conversations'); + const key = '11111111-1111-4111-8111-111111111111'; + const get = conversations.get(kind === 'side' ? 'side' : 'parent'); + get.onsuccess = () => { + if (kind === 'parent' || kind === 'side') { + conversations.put({ ...get.result, revision: get.result.revision + 1 }); + } else if (kind === 'creation-key') { + conversations.put({ + ...get.result, + id: 'competing-creation', + title: 'Competing discarded note', + side: { parentConversationId: 'parent', operationKey: key, state: 'discarded' }, + }); + } else { + conversations.put({ + ...get.result, + id: 'competing-parent', + title: 'Competing parent', + }); + competing.objectStore('messages').put({ + id: 'competing-import', + conversationId: 'competing-parent', + parentId: null, + role: 'user', + text: 'Another operation payload', + createdAt: get.result.createdAt, + broughtBack: { + operationKey: key, + sideConversationId: 'side', + sourceMessageIds: ['source'], + }, + }); + } + }; + competing.objectStore('meta').put({ key: 'mutationRevision', value: 1 }); + } + return Reflect.apply(original, this, args) as IDBTransaction; + }; + }, interference); + await page.getByRole('button', { name: 'Bring back reviewed excerpt', exact: true }).click(); + await expect(page.getByText(/conflicts with its earlier request/)).toBeVisible(); + await expect(page.getByRole('textbox', { name: 'Reviewed excerpt' })).toHaveAttribute( + 'readonly', + '', + ); + await expect(page.getByRole('button', { name: 'Review again', exact: true })).toHaveCount(0); + expect(await durableSummary(page)).toEqual({ + count: interference === 'import-key' ? 53 : 52, + revision: 1, + imported: interference === 'import-key' ? 1 : 0, + }); + await page.getByRole('button', { name: 'Return to parent', exact: true }).click(); + await expect( + page.getByRole('list', { name: 'Messages' }).getByText('Atomic reviewed excerpt'), + ).toHaveCount(0); + }); +} + +test('an aborted import rolls back its message, conversation and shared revision before unchanged-key retry', async ({ + page, +}) => { + await seedLegacyHistory(page, 50); + await prepareFixedReview(page); + await page.evaluate(() => { + const original = IDBObjectStore.prototype.put; + let first = true; + IDBObjectStore.prototype.put = function (...args) { + const request = Reflect.apply(original, this, args) as IDBRequest; + if (this.name === 'messages' && first) { + first = false; + request.addEventListener('success', () => this.transaction.abort(), { once: true }); + } + return request; + }; + }); + await page.getByRole('button', { name: 'Bring back reviewed excerpt', exact: true }).click(); + await expect(page.getByText(/The import result could not be confirmed/)).toBeVisible(); + expect(await durableSummary(page)).toEqual({ count: 52, revision: 0, imported: 0 }); + await expect(page.getByRole('textbox', { name: 'Reviewed excerpt' })).toHaveAttribute( + 'readonly', + '', + ); + await page.getByRole('button', { name: 'Bring back reviewed excerpt', exact: true }).click(); + await expect(page.getByRole('textbox', { name: 'Reviewed excerpt' })).toHaveCount(0); + expect(await durableSummary(page)).toEqual({ count: 53, revision: 1, imported: 1 }); +}); + +test('two windows racing the same reviewed key converge on one durable import', async ({ + page, + context, +}) => { + await seedLegacyHistory(page, 50); + const other = await context.newPage(); + await other.goto('/'); + await prepareFixedReview(page); + await prepareFixedReview(other); + await Promise.all( + [page, other].map((current) => + current.getByRole('button', { name: 'Bring back reviewed excerpt', exact: true }).click(), + ), + ); + for (const current of [page, other]) { + const review = current.getByRole('textbox', { name: 'Reviewed excerpt' }); + await expect + .poll( + async () => + (await review.count()) === 0 || + (await current.getByText(/conflicts with its earlier request/).count()) === 1, + ) + .toBe(true); + if (await review.count()) { + await expect(review).toHaveAttribute('readonly', ''); + await current + .getByRole('button', { name: 'Bring back reviewed excerpt', exact: true }) + .click(); + } + await expect(review).toHaveCount(0); + } + expect(await durableSummary(page)).toEqual({ count: 53, revision: 1, imported: 1 }); +}); + +test('an uncertain conversation create reports confirmation guidance without selecting a phantom result', async ({ + page, +}) => { + await seedLegacyHistory(page, 50); + await loseNextWriteAcknowledgement(page); + await page.getByRole('button', { name: 'New', exact: true }).click(); + await expect( + page.getByText(/conversation save could not be confirmed.*exact record before retrying/), + ).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Legacy parent', exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: 'New', exact: true })).toBeDisabled(); + const committed = await durableSummary(page); + await page.getByRole('button', { name: 'Reconcile local save', exact: true }).click(); + await expect(page.getByRole('option', { name: /New conversation/ })).toHaveCount(1); + expect(await durableSummary(page)).toEqual(committed); + await page.reload(); + await expect(page.getByRole('heading', { name: 'New conversation', exact: true })).toBeVisible(); +}); + +for (const { kind, removed } of [ + { kind: 'conversation', removed: false }, + { kind: 'message', removed: false }, + { kind: 'conversation', removed: true }, + { kind: 'message', removed: true }, +] as const) { + test(`a confirmed ${kind} with a failed post-commit IndexedDB read reconciles without another write (deleted=${removed})`, async ({ + page, + }) => { + await page.setViewportSize({ width: 320, height: 568 }); + await seedLegacyHistory(page, 50); + await page.evaluate(() => { + const transaction = IDBDatabase.prototype.transaction; + const get = IDBObjectStore.prototype.get; + let arm = true; + let blocked = false; + IDBDatabase.prototype.transaction = function (...args) { + const tx = Reflect.apply(transaction, this, args) as IDBTransaction; + if (args[1] === 'readwrite' && arm) { + arm = false; + tx.addEventListener( + 'complete', + () => { + blocked = true; + }, + { once: true }, + ); + } + return tx; + }; + IDBObjectStore.prototype.get = function (...args) { + if ( + blocked && + this.name === 'meta' && + this.transaction.objectStoreNames.length === 1 && + args[0] === 'mutationRevision' + ) + throw new Error('Injected post-commit revision read failure'); + return Reflect.apply(get, this, args); + }; + window.__restoreRootReads = () => { + blocked = false; + }; + }); + if (kind === 'conversation') + await page.getByRole('button', { name: 'New', exact: true }).click(); + else { + await page.getByRole('textbox', { name: 'Message', exact: true }).fill('Saved root message'); + await page.getByRole('button', { name: 'Send', exact: true }).click(); + } + await expect( + page.getByText(new RegExp(`The ${kind} was saved, but local history`)), + ).toBeVisible(); + await expect( + page.getByRole('button', { name: kind === 'conversation' ? 'New' : 'Send', exact: true }), + ).toBeDisabled(); + const bounds = await page.evaluate(() => ({ + width: document.documentElement.scrollWidth, + height: document.documentElement.scrollHeight, + })); + expect(bounds.width).toBeLessThanOrEqual(320); + expect(bounds.height).toBeLessThanOrEqual(568); + const committed = await durableSummary(page); + expect(committed.revision).toBe(1); + expect(committed.count).toBe(kind === 'message' ? 53 : 52); + await page.getByRole('button', { name: 'Reconcile local save', exact: true }).click(); + await expect(page.getByText(/Local history could not be reconciled/)).toBeVisible(); + expect(await durableSummary(page)).toEqual(committed); + if (removed) { + await page.evaluate( + (kind) => + new Promise((resolve, reject) => { + const open = indexedDB.open('opencoven-chat'); + open.onerror = () => reject(open.error); + open.onsuccess = () => { + const db = open.result; + const name = kind === 'conversation' ? 'conversations' : 'messages'; + const tx = db.transaction([name, 'meta'], 'readwrite'); + const store = tx.objectStore(name); + const records = store.getAll(); + const revision = tx.objectStore('meta').get('mutationRevision'); + records.onsuccess = () => { + const matching = records.result.filter((record) => + kind === 'conversation' + ? record.title === 'New conversation' + : record.text === 'Saved root message', + ); + if (matching.length !== 1) { + tx.abort(); + return; + } + store.delete(matching[0].id); + }; + revision.onsuccess = () => { + if (!revision.result) { + tx.abort(); + return; + } + tx.objectStore('meta').put({ + key: 'mutationRevision', + value: revision.result.value + 1, + }); + }; + tx.onabort = () => { + db.close(); + reject(tx.error ?? new Error('External deletion failed')); + }; + tx.oncomplete = () => { + db.close(); + resolve(); + }; + }; + }), + kind, + ); + } + const expectedHistory = await durableSummary(page); + await page.evaluate(() => { + if (!window.__restoreRootReads) throw new Error('Missing read recovery control'); + window.__restoreRootReads(); + }); + await page.getByRole('button', { name: 'Reconcile local save', exact: true }).click(); + await expect(page.getByRole('button', { name: 'Reconcile local save' })).toHaveCount(0); + if (removed) { + const content = page.getByRole('textbox', { name: 'Unavailable saved content' }); + await expect(content).toHaveValue( + kind === 'message' ? 'Saved root message' : 'New conversation', + ); + await expect(content).toHaveAttribute('readonly', ''); + await expect(content).toBeEnabled(); + await expect( + page.getByText(/This save committed, but its record is no longer available/), + ).toBeVisible(); + await expect( + page.getByRole('button', { + name: kind === 'message' ? 'Send' : 'New', + exact: true, + }), + ).toBeDisabled(); + if (kind === 'message') { + const layout = await page.evaluate(() => ({ + composerBottom: document.querySelector('.chat-composer')?.getBoundingClientRect().bottom, + historyHeight: document.querySelector('.chat-shell__thread-body')?.clientHeight, + })); + expect(layout.composerBottom).toBeLessThanOrEqual(568); + expect(layout.historyHeight).toBeGreaterThan(0); + } + expect(await durableSummary(page)).toEqual(expectedHistory); + await page.getByRole('button', { name: 'Dismiss unavailable save', exact: true }).click(); + await expect(content).toHaveCount(0); + if (kind === 'message') { + await expect(page.getByRole('textbox', { name: 'Message', exact: true })).toHaveValue(''); + await expect(page.getByRole('button', { name: 'Send', exact: true })).toBeDisabled(); + } else { + await expect(page.getByRole('option', { name: /New conversation/ })).toHaveCount(0); + } + } else if (kind === 'conversation') { + await expect(page.getByRole('option', { name: /New conversation/ })).toHaveCount(1); + await expect(page.getByRole('heading', { name: 'Legacy parent', exact: true })).toBeVisible(); + } else { + await expect(page.getByRole('textbox', { name: 'Message', exact: true })).toHaveValue(''); + await expect( + page.getByRole('list', { name: 'Messages' }).getByText('Saved root message'), + ).toBeVisible(); + } + expect(await durableSummary(page)).toEqual(expectedHistory); + }); +} + +test('an aborted ordinary message is not retryable until exact-record reconciliation confirms absence', async ({ + page, +}) => { + await seedLegacyHistory(page, 50); + await page.evaluate(() => { + const put = IDBObjectStore.prototype.put; + let abort = true; + IDBObjectStore.prototype.put = function (...args) { + if (abort && this.name === 'messages') { + abort = false; + this.transaction.abort(); + throw new Error('Injected root message abort'); + } + return Reflect.apply(put, this, args); + }; + }); + await page + .getByRole('textbox', { name: 'Message', exact: true }) + .fill('Retry only after absence'); + await page.getByRole('button', { name: 'Send', exact: true }).click(); + await expect(page.getByText(/message save could not be confirmed/)).toBeVisible(); + await expect(page.getByRole('button', { name: 'Send', exact: true })).toBeDisabled(); + expect((await durableSummary(page)).revision).toBe(0); + await page.getByRole('button', { name: 'Reconcile local save', exact: true }).click(); + await expect(page.getByText(/save did not commit.*draft is retained/i)).toBeVisible(); + await expect(page.getByRole('textbox', { name: 'Message', exact: true })).toHaveValue( + 'Retry only after absence', + ); + await page.getByRole('button', { name: 'Send', exact: true }).click(); + await expect( + page.getByRole('list', { name: 'Messages' }).getByText('Retry only after absence'), + ).toBeVisible(); + expect((await durableSummary(page)).revision).toBe(1); +}); + +test('a discarded create replay reconciles the old key before allowing a new durable side note', async ({ + page, + context, +}) => { + await seedLegacyHistory(page, 50); + await loseNextWriteAcknowledgement(page); + await page.getByRole('button', { name: 'New retained side note', exact: true }).click(); + await expect( + page.getByText(/side note creation result could not be confirmed.*same operation key/), + ).toBeVisible(); + const other = await context.newPage(); + await other.goto('/'); + await other.getByRole('button', { name: /^Retained side note/ }).click(); + await other.getByRole('button', { name: 'Discard note', exact: false }).click(); + await other.getByRole('button', { name: 'Discard local messages', exact: true }).click(); + await expect(other.getByRole('heading', { name: 'Legacy parent', exact: true })).toBeVisible(); + await page + .getByRole('button', { name: 'Retry retained side note creation', exact: true }) + .click(); + await expect(page.getByText(/creation request refers to a discarded note/)).toBeVisible(); + await page.getByRole('button', { name: 'New retained side note', exact: true }).click(); + await expect(page.getByRole('button', { name: 'Close note', exact: true })).toBeVisible(); + const creations = await page.evaluate( + () => + new Promise<{ state: string; key: string }[]>((resolve, reject) => { + const request = indexedDB.open('opencoven-chat'); + request.onerror = () => reject(request.error); + request.onsuccess = () => { + const db = request.result; + const tx = db.transaction('conversations'); + const records = tx.objectStore('conversations').getAll(); + tx.onabort = () => { + db.close(); + reject(tx.error); + }; + tx.oncomplete = () => { + resolve( + records.result + .filter((record) => record.title === 'Retained side note') + .map((record) => ({ state: record.side.state, key: record.side.operationKey })), + ); + db.close(); + }; + }; + }), + ); + expect(creations.map((entry) => entry.state).sort()).toEqual(['discarded', 'open']); + expect(new Set(creations.map((entry) => entry.key)).size).toBe(2); +}); + +test('uncertain side creation retains its retry identity across real parent navigation', async ({ + page, +}) => { + await seedLegacyHistory(page, 50); + await loseNextWriteAcknowledgement(page); + await page.getByRole('button', { name: 'New retained side note', exact: true }).click(); + await expect(page.getByText(/side note creation result could not be confirmed/)).toBeVisible(); + await page.getByRole('button', { name: 'New', exact: true }).click(); + await expect(page.getByRole('heading', { name: 'New conversation', exact: true })).toBeVisible(); + await expect( + page.getByRole('button', { name: 'New retained side note', exact: true }), + ).toBeEnabled(); + await page.getByRole('option', { name: /Legacy parent/ }).click(); + await page + .getByRole('button', { name: 'Retry retained side note creation', exact: true }) + .click(); + await expect(page.getByRole('button', { name: 'Close note', exact: true })).toBeVisible(); + await page.getByRole('button', { name: 'Return to parent', exact: true }).click(); + await expect(page.getByRole('button', { name: /^Retained side note/ })).toHaveCount(1); +}); + +test('reload exposes a committed uncertain creation for list reconciliation without claiming its retry key survived', async ({ + page, +}) => { + await seedLegacyHistory(page, 50); + await loseNextWriteAcknowledgement(page); + await page.getByRole('button', { name: 'New retained side note', exact: true }).click(); + await expect(page.getByText(/side note creation result could not be confirmed/)).toBeVisible(); + await page.reload(); + await expect( + page.getByText( + /Pending creations survive navigation in this app session only, not reload or restart/, + ), + ).toBeVisible(); + await expect( + page.getByRole('button', { name: 'Retry retained side note creation', exact: true }), + ).toHaveCount(0); + await expect(page.getByRole('button', { name: /^Retained side note/ })).toHaveCount(1); + await page.getByRole('button', { name: /^Retained side note/ }).click(); + await expect(page.getByRole('button', { name: 'Close note', exact: true })).toBeVisible(); +}); + +for (const timing of ['before', 'after'] as const) { + test(`a competing write ${timing} our commit is reconciled before the successful UI refresh`, async ({ + page, + }) => { + await seedLegacyHistory(page, 50); + await page.evaluate((when) => { + const original = IDBDatabase.prototype.transaction; + let injected = false; + IDBDatabase.prototype.transaction = function (...args) { + if (args[1] !== 'readwrite' || injected) + return Reflect.apply(original, this, args) as IDBTransaction; + injected = true; + const own = + when === 'after' ? (Reflect.apply(original, this, args) as IDBTransaction) : null; + const competing = Reflect.apply(original, this, args) as IDBTransaction; + const conversations = competing.objectStore('conversations'); + const messages = competing.objectStore('messages'); + const parent = conversations.get('parent'); + parent.onsuccess = () => { + if (when === 'before') { + conversations.put({ + ...parent.result, + id: 'external-parent', + title: 'External sidebar thread', + }); + messages.put({ + id: 'external-message', + conversationId: 'external-parent', + parentId: null, + role: 'user', + text: 'External transcript', + createdAt: parent.result.updatedAt, + }); + } else { + const turns = messages + .index('by_conversation') + .getAll(IDBKeyRange.bound(['parent'], ['parent', []])); + turns.onsuccess = () => { + const last = turns.result.at(-1); + const timestamp = new Date(Date.parse(parent.result.updatedAt) + 1).toISOString(); + messages.put({ + id: 'after-own-commit', + conversationId: 'parent', + parentId: last.id, + role: 'user', + text: 'External after our commit', + createdAt: timestamp, + }); + conversations.put({ + ...parent.result, + revision: parent.result.revision + 1, + updatedAt: timestamp, + }); + }; + } + }; + const meta = competing.objectStore('meta'); + const revision = meta.get('mutationRevision'); + revision.onsuccess = () => + meta.put({ key: 'mutationRevision', value: revision.result.value + 1 }); + return own ?? (Reflect.apply(original, this, args) as IDBTransaction); + }; + }, timing); + await send(page, 'Our successful append'); + await expect( + page + .getByRole('list', { name: 'Messages' }) + .getByText('Our successful append', { exact: true }), + ).toHaveCount(1); + if (timing === 'before') { + await expect(page.getByRole('option', { name: /External sidebar thread/ })).toBeVisible(); + } else { + await expect( + page + .getByRole('list', { name: 'Messages' }) + .getByText('External after our commit', { exact: true }), + ).toBeVisible(); + } + expect((await durableSummary(page)).revision).toBe(2); + }); +} + +test('a write after IndexedDB snapshot capture is reconciled before the active transcript and sidebar refresh', async ({ + page, +}) => { + await seedLegacyHistory(page, 50); + await page.evaluate(() => { + const original = IDBDatabase.prototype.transaction; + let committed = false; + let captured = false; + IDBDatabase.prototype.transaction = function (...args) { + const transaction = Reflect.apply(original, this, args) as IDBTransaction; + const names = typeof args[0] === 'string' ? [args[0]] : Array.from(args[0]); + const firstWrite = !committed && args[1] === 'readwrite'; + const snapshot = + committed && + !captured && + args[1] === 'readonly' && + names.includes('conversations') && + names.includes('messages'); + if (!firstWrite && !snapshot) return transaction; + if (firstWrite) committed = true; + else captured = true; + const other = Reflect.apply(original, this, [ + ['meta', 'conversations', 'messages'], + 'readwrite', + ]) as IDBTransaction; + const conversations = other.objectStore('conversations'); + const messages = other.objectStore('messages'); + const parent = conversations.get('parent'); + parent.onsuccess = () => { + if (firstWrite) { + conversations.put({ + ...parent.result, + id: 'external-root', + title: 'Concurrent sidebar root', + }); + return; + } + const turns = messages + .index('by_conversation') + .getAll(IDBKeyRange.bound(['parent'], ['parent', []])); + turns.onsuccess = () => { + const timestamp = new Date(Date.parse(parent.result.updatedAt) + 1).toISOString(); + messages.put({ + id: 'after-snapshot', + conversationId: 'parent', + parentId: turns.result.at(-1).id, + role: 'user', + text: 'Committed after snapshot capture', + createdAt: timestamp, + }); + conversations.put({ + ...parent.result, + revision: parent.result.revision + 1, + updatedAt: timestamp, + }); + }; + }; + const meta = other.objectStore('meta'); + const revision = meta.get('mutationRevision'); + revision.onsuccess = () => + meta.put({ key: 'mutationRevision', value: revision.result.value + 1 }); + return transaction; + }; + }); + await send(page, 'Our coherent append'); + const messages = page.getByRole('list', { name: 'Messages' }); + await expect(messages.getByText('Our coherent append', { exact: true })).toHaveCount(1); + await expect(messages.getByText('Committed after snapshot capture', { exact: true })).toHaveCount( + 1, + ); + await expect(page.getByRole('option', { name: /Concurrent sidebar root/ })).toBeVisible(); + expect((await durableSummary(page)).revision).toBe(3); +}); + +test('IndexedDB-cloned boxed and array side states are sanitized without exposing malformed notes', async ({ + page, +}) => { + await seedLegacyHistory(page, 50); + await page.evaluate( + () => + new Promise((resolve, reject) => { + const request = indexedDB.open('opencoven-chat'); + request.onerror = () => reject(request.error); + request.onsuccess = () => { + const db = request.result; + const tx = db.transaction('conversations', 'readwrite'); + const conversations = tx.objectStore('conversations'); + const parent = conversations.get('parent'); + parent.onsuccess = () => { + for (const [index, state] of [ + Object('discarded'), + Object('open'), + ['closed'], + ].entries()) { + conversations.put({ + ...parent.result, + id: `malformed-${index}`, + title: `Malformed note ${index}`, + side: { parentConversationId: 'parent', operationKey: `bad-${index}`, state }, + }); + } + }; + tx.onabort = () => { + db.close(); + reject(tx.error); + }; + tx.oncomplete = () => { + db.close(); + resolve(); + }; + }; + }), + ); + await page.reload(); + await expect(page.getByRole('button', { name: /^Legacy side/ })).toBeVisible(); + await expect(page.getByRole('button', { name: /^Legacy history/ })).toBeVisible(); + await expect(page.getByRole('button', { name: /Malformed note/ })).toHaveCount(0); + await expect(page.getByRole('option', { name: /Malformed note/ })).toHaveCount(0); +}); + +test('a migrated legacy receipt replays through the local writer without new persistence or provenance changes', async ({ + page, +}) => { + await seedLegacyHistory(page, 50); + const before = await durableSummary(page); + await page.getByRole('button', { name: /^Legacy side/ }).click(); + await page.getByRole('checkbox', { name: /Legacy source/ }).check(); + await page.evaluate(() => { + const original = crypto.randomUUID.bind(crypto); + Object.defineProperty(crypto, 'randomUUID', { + configurable: true, + value: () => { + Object.defineProperty(crypto, 'randomUUID', { configurable: true, value: original }); + return 'legacy-import'; + }, + }); + }); + await page.getByRole('button', { name: 'Review Bring back', exact: true }).click(); + await page.getByRole('textbox', { name: 'Reviewed excerpt' }).fill('Legacy imported excerpt'); + await page.getByRole('button', { name: 'Bring back reviewed excerpt', exact: true }).click(); + await expect(page.getByRole('textbox', { name: 'Reviewed excerpt' })).toHaveCount(0); + expect(await durableSummary(page)).toEqual(before); + await page.getByRole('button', { name: 'Return to parent', exact: true }).click(); + await expect( + page.getByRole('list', { name: 'Messages' }).getByText('Legacy imported excerpt'), + ).toHaveCount(1); +}); + +test('a discarded reviewed source keeps its edited excerpt available for copying and explicit cancellation', async ({ + page, + context, +}) => { + await seedLegacyHistory(page, 50); + await prepareFixedReview(page); + const other = await context.newPage(); + await other.goto('/'); + await other.getByRole('button', { name: /^Legacy side/ }).click(); + await other.getByRole('button', { name: 'Discard note', exact: false }).click(); + await other.getByRole('button', { name: 'Discard local messages', exact: true }).click(); + await expect(other.getByRole('heading', { name: 'Legacy parent', exact: true })).toBeVisible(); + await page.getByRole('button', { name: 'Bring back reviewed excerpt', exact: true }).click(); + await page.getByRole('button', { name: 'Review again', exact: true }).click(); + await expect( + page.getByRole('button', { name: 'Choose available messages', exact: true }), + ).toBeVisible(); + const excerpt = page.getByRole('textbox', { name: 'Reviewed excerpt' }); + await expect(excerpt).toHaveValue('Atomic reviewed excerpt'); + await expect(excerpt).toHaveAttribute('readonly', ''); + await expect(excerpt).toBeEnabled(); + await expect( + page.getByRole('button', { name: 'Bring back reviewed excerpt', exact: true }), + ).toBeDisabled(); + expect((await durableSummary(page)).imported).toBe(0); + await page.getByRole('button', { name: 'Cancel review', exact: true }).click(); + await expect(excerpt).toHaveCount(0); +}); + +test('App observes a late IndexedDB import after the panel and parent reload finish before commit', async ({ + page, + context, +}) => { + await seedLegacyHistory(page, 50); + await prepareFixedReview(page); + await page.evaluate(() => { + const get = IDBObjectStore.prototype.get; + let holdNext = true; + IDBObjectStore.prototype.get = function (...args) { + const request = Reflect.apply(get, this, args) as IDBRequest; + if ( + holdNext && + this.name === 'meta' && + this.transaction.mode === 'readonly' && + args[0] === 'mutationRevision' + ) { + holdNext = false; + // Pause admission before the import transaction exists, not just its UI callback. + Object.defineProperty(request, 'onsuccess', { + configurable: true, + set(handler: IDBRequest['onsuccess']) { + request.addEventListener( + 'success', + (event) => { + window.__resumeLocalRevisionRead = () => { + if (!handler) throw new Error('Missing revision read handler'); + handler.call(request, event); + }; + }, + { once: true }, + ); + }, + }); + } + return request; + }; + }); + await page.getByRole('button', { name: 'Bring back reviewed excerpt', exact: true }).click(); + await expect + .poll(() => page.evaluate(() => typeof window.__resumeLocalRevisionRead)) + .toBe('function'); + await page.getByRole('button', { name: 'Return to parent', exact: true }).click(); + await expect( + page.getByRole('button', { name: 'New retained side note', exact: true }), + ).toBeVisible(); + const other = await context.newPage(); + await other.goto('/'); + await other.getByRole('button', { name: 'New', exact: true }).click(); + await expect(other.getByRole('heading', { name: 'New conversation', exact: true })).toBeVisible(); + await expect( + page.getByRole('list', { name: 'Messages' }).getByText('Atomic reviewed excerpt'), + ).toHaveCount(0); + await expect(page.getByRole('textbox', { name: 'Reviewed excerpt' })).toHaveCount(0); + await page.evaluate(() => { + const resume = window.__resumeLocalRevisionRead; + if (!resume) throw new Error('Missing admission barrier'); + delete window.__resumeLocalRevisionRead; + resume(); + }); + await expect( + page + .getByRole('list', { name: 'Messages' }) + .getByText('Atomic reviewed excerpt', { exact: true }), + ).toBeVisible(); + await expect(page.getByRole('option', { name: /New conversation/ })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Legacy parent', exact: true })).toBeVisible(); + expect((await durableSummary(page)).imported).toBe(1); +}); + +test('a partially unloaded selection cannot silently import only the first-page messages', async ({ + page, +}) => { + await seedLegacyHistory(page, 50, 51); + await page.getByRole('button', { name: /^Legacy side/ }).click(); + await page.getByRole('checkbox', { name: /Legacy source/ }).check(); + await page.getByRole('button', { name: 'Load more messages', exact: true }).click(); + await page.getByRole('checkbox', { name: /Side source 51$/ }).check(); + await page.getByRole('button', { name: 'Return to parent', exact: true }).click(); + await page.getByRole('button', { name: /^Legacy side/ }).click(); + await expect(page.getByRole('checkbox', { name: /Side source 51$/ })).toHaveCount(0); + await page.getByRole('button', { name: 'Review Bring back', exact: true }).click(); + await expect(page.getByText(/Selected messages are not all loaded/)).toBeVisible(); + await expect(page.getByRole('textbox', { name: 'Reviewed excerpt' })).toHaveCount(0); + await page.getByRole('button', { name: 'Load more messages', exact: true }).click(); + await expect(page.getByRole('checkbox', { name: /Side source 51$/ })).toBeChecked(); + await expect(page.getByRole('checkbox', { name: /Legacy source/ })).toBeChecked(); + await page.getByRole('button', { name: 'Review Bring back', exact: true }).click(); + await expect(page.getByRole('textbox', { name: 'Reviewed excerpt' })).toHaveValue( + 'Legacy source\n\nSide source 51', + ); + await page.getByRole('button', { name: 'Bring back reviewed excerpt', exact: true }).click(); + await expect(page.getByRole('textbox', { name: 'Reviewed excerpt' })).toHaveCount(0); + const selections = await page.evaluate( + () => + new Promise((resolve, reject) => { + const request = indexedDB.open('opencoven-chat'); + request.onerror = () => reject(request.error); + request.onsuccess = () => { + const database = request.result; + const tx = database.transaction('messages'); + const messages = tx + .objectStore('messages') + .index('by_conversation') + .getAll(IDBKeyRange.bound(['parent'], ['parent', []])); + tx.onabort = () => { + database.close(); + reject(tx.error); + }; + tx.oncomplete = () => { + resolve( + messages.result + .filter( + (message) => + message.broughtBack && message.broughtBack.operationKey !== 'legacy-import', + ) + .map((message) => message.broughtBack.sourceMessageIds), + ); + database.close(); + }; + }; + }), + ); + expect(selections).toEqual([['source', 'side-source-51']]); +}); diff --git a/src/app-startup.test.tsx b/src/app-startup.test.tsx new file mode 100644 index 00000000..e1198d58 --- /dev/null +++ b/src/app-startup.test.tsx @@ -0,0 +1,191 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { App } from './app'; +import type { ChatRecords } from './lib/local/chat-records'; +import { createLocalChatSource, type LocalChatSource } from './lib/local/chat-source'; +import { createMemoryChatBackend } from './lib/local/memory-backend'; + +const history: ChatRecords = { + conversations: [ + { + id: 'existing', + familiarId: 'local', + title: 'Existing local history', + createdAt: '2026-09-11T00:00:00.000Z', + updatedAt: '2026-09-11T00:00:00.000Z', + }, + ], + messages: [ + { + id: 'saved', + conversationId: 'existing', + parentId: null, + role: 'user', + text: 'Previously saved content', + createdAt: '2026-09-11T00:00:00.000Z', + }, + ], +}; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason: Error) => void; + const promise = new Promise((accept, fail) => { + resolve = accept; + reject = fail; + }); + return { promise, resolve, reject }; +} + +function appProps() { + return { + desktopIdentityHost: { canUseTauriCommands: () => true, readInstallationId: vi.fn() }, + controllerFactory: vi.fn(), + queryAdapterFactory: vi.fn(), + }; +} + +test.each(['revision', 'snapshot'] as const)( + 'an actual initial %s failure reaches a local retry gate and retry loads unchanged history', + async (stage) => { + const memory = createMemoryChatBackend(history); + const close = vi.fn(memory.close); + let fail = true; + const backend = { + ...memory, + close, + getMutationRevision: async () => { + if (fail && stage === 'revision') throw new Error('Initial revision unavailable'); + return memory.getMutationRevision?.() ?? 0; + }, + loadAll: async () => { + if (fail && stage === 'snapshot') throw new Error('Initial snapshot unavailable'); + return memory.loadAll(); + }, + }; + const factory = vi.fn(() => createLocalChatSource({ backend, familiarId: 'local' })); + const props = appProps(); + const view = render(); + expect(await screen.findByRole('alert')).toHaveTextContent( + 'Local chat storage could not be opened', + ); + expect(view.container.querySelector('.connection-gate__spinner')).toBeNull(); + expect(screen.queryByRole('button', { name: 'New' })).toBeNull(); + expect(close).not.toHaveBeenCalled(); + expect(await memory.loadAll()).toEqual(history); + fail = false; + fireEvent.click(screen.getByRole('button', { name: 'Retry local storage' })); + await screen.findByRole('heading', { name: 'Existing local history' }); + await screen.findByText('Previously saved content'); + expect(factory).toHaveBeenCalledTimes(2); + expect(close).not.toHaveBeenCalled(); + expect(await memory.loadAll()).toEqual(history); + expect(props.desktopIdentityHost.readInstallationId).not.toHaveBeenCalled(); + expect(props.controllerFactory).not.toHaveBeenCalled(); + expect(props.queryAdapterFactory).not.toHaveBeenCalled(); + view.unmount(); + expect(close).toHaveBeenCalledOnce(); + }, +); + +test('a synchronous factory failure also becomes a local startup error', async () => { + const props = appProps(); + render( + { + throw new Error('Synchronous factory failure'); + }} + />, + ); + await screen.findByRole('button', { name: 'Retry local storage' }); + expect(props.controllerFactory).not.toHaveBeenCalled(); +}); + +test('present IndexedDB opening failures reach App without empty memory success or Cave calls', async () => { + const open = vi.fn(() => { + throw new DOMException('Storage denied', 'SecurityError'); + }); + vi.stubGlobal('indexedDB', { open }); + const props = appProps(); + const view = render( createLocalChatSource()} />); + try { + await screen.findByRole('alert'); + expect(screen.queryByRole('button', { name: 'New' })).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: 'Retry local storage' })); + await waitFor(() => expect(open).toHaveBeenCalledTimes(2)); + await screen.findByRole('alert'); + expect(props.desktopIdentityHost.readInstallationId).not.toHaveBeenCalled(); + expect(props.controllerFactory).not.toHaveBeenCalled(); + expect(props.queryAdapterFactory).not.toHaveBeenCalled(); + } finally { + view.unmount(); + vi.unstubAllGlobals(); + } +}); + +test.each(['resolve', 'reject'] as const)( + 'an obsolete factory %s cannot replace or fail the current local source', + async (outcome) => { + const stale = deferred(); + const next = deferred(); + const oldFactory = vi.fn(() => stale.promise); + const newFactory = vi.fn(() => next.promise); + const oldBackend = createMemoryChatBackend(); + const closeOld = vi.fn(oldBackend.close); + const oldSource = await createLocalChatSource({ + backend: { ...oldBackend, close: closeOld }, + familiarId: 'local', + }); + const currentBackend = createMemoryChatBackend(history); + const closeCurrent = vi.fn(currentBackend.close); + const current = await createLocalChatSource({ + backend: { ...currentBackend, close: closeCurrent }, + familiarId: 'local', + }); + const props = appProps(); + const view = render(); + await waitFor(() => expect(oldFactory).toHaveBeenCalledOnce()); + view.rerender(); + await waitFor(() => expect(newFactory).toHaveBeenCalledOnce()); + await act(async () => { + if (outcome === 'resolve') stale.resolve(oldSource); + else stale.reject(new Error('Obsolete rejection')); + }); + expect(screen.getByLabelText('Startup state')).toHaveTextContent( + 'Preparing local chat storage', + ); + expect(screen.queryByRole('alert')).toBeNull(); + await act(async () => next.resolve(current)); + await screen.findByRole('heading', { name: 'Existing local history' }); + expect(closeCurrent).not.toHaveBeenCalled(); + if (outcome === 'resolve') expect(closeOld).toHaveBeenCalledOnce(); + else oldSource.store.dispose(); + view.unmount(); + expect(closeCurrent).toHaveBeenCalledOnce(); + }, +); + +test.each(['resolve', 'reject'] as const)( + 'an unmounted factory %s is handled without leaking a completed source', + async (outcome) => { + const opening = deferred(); + const factory = vi.fn(() => opening.promise); + const backend = createMemoryChatBackend(); + const close = vi.fn(backend.close); + const source = await createLocalChatSource({ + backend: { ...backend, close }, + familiarId: 'local', + }); + const props = appProps(); + const view = render(); + await waitFor(() => expect(factory).toHaveBeenCalledOnce()); + view.unmount(); + await act(async () => { + if (outcome === 'resolve') opening.resolve(source); + else opening.reject(new Error('Unmounted rejection')); + }); + if (outcome === 'resolve') expect(close).toHaveBeenCalledOnce(); + else source.store.dispose(); + expect(props.controllerFactory).not.toHaveBeenCalled(); + }, +); diff --git a/src/app.test.tsx b/src/app.test.tsx index 7c39cbb1..e4c248aa 100644 --- a/src/app.test.tsx +++ b/src/app.test.tsx @@ -1,4 +1,4 @@ -import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { StrictMode } from 'react'; import { App } from './app'; @@ -9,6 +9,7 @@ import { createChatStore } from './lib/local/chat-store'; import { createLocalChatWriter } from './lib/local/chat-writer'; import { createLocalQueryAdapter, LOCAL_FAMILIAR_ID } from './lib/local/local-query-adapter'; import { createMemoryChatBackend } from './lib/local/memory-backend'; +import type { BringBackInput } from './lib/local/side-conversations'; import type { CaveConnectionController } from './lib/sdk/connection-controller'; import type { QueryAdapter } from './lib/sdk/query-adapter'; @@ -137,6 +138,179 @@ function makeQueryAdapter(): QueryAdapter { } describe('App', () => { + it.each(['parent', 'foreign'])( + 'a late real-store import refreshes the active %s after another write and panel unmount', + async (destination) => { + const local = createLocalSourceFactory(); + const parent = await local.store.createConversation('Import parent'); + const other = await local.store.createConversation('Other parent'); + const side = await local.store.createSideConversation({ + parentConversationId: parent.id, + operationKey: 'seed-side', + }); + await local.store.appendMessage(side.id, 'user', 'Source text'); + const source = await local.factory(); + const capability = source.writer.sideConversations; + if (!capability) throw new Error('Missing local side capability'); + let release!: () => void; + const delayed = new Promise((resolve) => { + release = resolve; + }); + const bringBack = vi.fn(async (input: BringBackInput) => { + await delayed; + return capability.bringBack(input); + }); + const writer = { ...source.writer, sideConversations: { ...capability, bringBack } }; + const ownedSource = { ...source, writer }; + const controllerFactory = vi.fn(); + render( + Promise.resolve(ownedSource)} + controllerFactory={controllerFactory} + desktopIdentityHost={{ canUseTauriCommands: () => false, readInstallationId: vi.fn() }} + />, + ); + fireEvent.click(await screen.findByRole('option', { name: /Import parent/ })); + fireEvent.click(await screen.findByRole('button', { name: /Retained side note/ })); + fireEvent.click(await screen.findByRole('checkbox', { name: /Source text/ })); + fireEvent.click(screen.getByRole('button', { name: 'Review Bring back' })); + fireEvent.change(await screen.findByRole('textbox', { name: 'Reviewed excerpt' }), { + target: { value: 'Late imported excerpt' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Bring back reviewed excerpt' })); + await waitFor(() => expect(bringBack).toHaveBeenCalledOnce()); + fireEvent.click(screen.getByRole('option', { name: /Other parent/ })); + await screen.findByRole('heading', { name: 'Other parent' }); + const composer = await screen.findByRole('textbox', { name: 'Message' }); + fireEvent.change(composer, { target: { value: 'Other write completed' } }); + fireEvent.click(screen.getByRole('button', { name: 'Send' })); + await within(await screen.findByRole('list', { name: 'Messages' })).findByText( + 'Other write completed', + ); + expect(local.store.listMessages(other.id, 50).data).toHaveLength(1); + await waitFor(() => + expect( + within(screen.getByRole('listbox', { name: 'Conversations' })).getAllByRole('option')[0], + ).toHaveTextContent('Other parent'), + ); + if (destination === 'parent') { + fireEvent.click(screen.getByRole('option', { name: /Import parent/ })); + await screen.findByRole('heading', { name: 'Import parent' }); + await screen.findByRole('button', { name: 'New retained side note' }); + } + expect(screen.queryByRole('textbox', { name: 'Reviewed excerpt' })).not.toBeInTheDocument(); + expect(screen.queryByText('Late imported excerpt')).not.toBeInTheDocument(); + await act(async () => release()); + await waitFor(() => expect(local.store.listMessages(parent.id, 50).data).toHaveLength(1)); + if (destination === 'parent') { + expect(await screen.findByText('Late imported excerpt')).toBeVisible(); + } else { + expect(screen.getByRole('heading', { name: 'Other parent' })).toBeVisible(); + expect(screen.queryByText('Late imported excerpt')).not.toBeInTheDocument(); + await waitFor(() => + expect( + within(screen.getByRole('listbox', { name: 'Conversations' })).getAllByRole( + 'option', + )[0], + ).toHaveTextContent('Import parent'), + ); + } + expect(controllerFactory).not.toHaveBeenCalled(); + }, + ); + + it('owns one local-store subscription for creation, state and append notifications and removes it on unmount', async () => { + const local = createLocalSourceFactory(); + const parent = await local.store.createConversation('Visible parent'); + const source = await local.factory(); + const unsubscribe = vi.fn(); + const subscribe = vi.fn((listener) => { + const stop = local.store.subscribe(listener); + return () => { + unsubscribe(); + stop(); + }; + }); + const ownedSource = { ...source, store: { ...local.store, subscribe } }; + const view = render( + Promise.resolve(ownedSource)} + desktopIdentityHost={{ canUseTauriCommands: () => false, readInstallationId: vi.fn() }} + />, + ); + await screen.findByRole('heading', { name: 'Visible parent' }); + let side!: Awaited>; + await act(async () => { + side = await local.store.createSideConversation({ + parentConversationId: parent.id, + operationKey: 'background-create', + }); + }); + await screen.findByRole('button', { name: /Retained side note · open/ }); + await act(async () => { + await local.store.setSideState( + { parentConversationId: parent.id, sideConversationId: side.id }, + 'closed', + ); + }); + await screen.findByRole('button', { name: /Retained side note · closed/ }); + await act(async () => { + await local.store.appendMessage(parent.id, 'user', 'Owner-observed append'); + }); + await screen.findByText('Owner-observed append'); + await act(async () => { + await local.store.createConversation('Background thread'); + }); + await screen.findByRole('option', { name: /Background thread/ }); + expect(screen.getByRole('heading', { name: 'Visible parent' })).toBeVisible(); + expect(subscribe).toHaveBeenCalledOnce(); + view.unmount(); + expect(unsubscribe).toHaveBeenCalledOnce(); + }); + + it('local mutation notifications do not refetch the active Cave source', async () => { + const local = createLocalSourceFactory(); + const parent = await local.store.createConversation('Local parent'); + const harness = createControllerHarness({ + state: 'ready', + caveInstanceId: 'cave-1', + covenAvailable: false, + }); + const adapter = makeQueryAdapter(); + const readInstallationId = vi + .fn() + .mockResolvedValue(INSTALLATION_ID); + render( + harness.controller} + queryAdapterFactory={() => adapter} + desktopIdentityHost={{ canUseTauriCommands: () => true, readInstallationId }} + />, + ); + fireEvent.click(await screen.findByRole('button', { name: 'Connect to Cave' })); + await waitFor(() => expect(screen.getByRole('button', { name: 'Coven Cave' })).toBeEnabled()); + fireEvent.click(screen.getByRole('button', { name: 'Coven Cave' })); + await screen.findByText('Hello from Cave.'); + const reads = [ + adapter.listFamiliars, + adapter.listProjects, + adapter.listConversations, + adapter.getConversation, + adapter.listMessages, + ]; + const counts = reads.map((read) => vi.mocked(read).mock.calls.length); + await act(async () => { + await local.store.appendMessage(parent.id, 'user', 'Local background write'); + }); + expect(reads.map((read) => vi.mocked(read).mock.calls.length)).toEqual(counts); + expect(adapter.invalidate).not.toHaveBeenCalled(); + expect(readInstallationId).toHaveBeenCalledOnce(); + expect(screen.getByText('Hello from Cave.')).toBeVisible(); + fireEvent.click(screen.getByRole('button', { name: 'This device' })); + expect(await screen.findByText('Local background write')).toBeVisible(); + }); + it('mounts local chat without touching Cave', async () => { const controllerFactory = vi.fn(); const readInstallationId = vi.fn(); diff --git a/src/app.tsx b/src/app.tsx index 592180d0..c351d5aa 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -26,6 +26,7 @@ import { createQueryAdapter, type QueryAdapter } from './lib/sdk/query-adapter'; type ControllerFactory = (installationId: string) => CaveConnectionController; type QueryAdapterFactory = (getClient: () => CaveReadClient | null) => QueryAdapter; +/** A successful call transfers a source to App; failed factories clean up resources they opened. */ type LocalSourceFactory = () => Promise; type InstallationIdReader = DesktopHost['readInstallationId']; type InstallationBootstrapState = @@ -281,22 +282,35 @@ function CaveHost({ ); } -function LocalStartup() { +function LocalStartup({ failed, onRetry }: { failed: boolean; onRetry: () => void }) { return ( -
+

OpenCoven desktop chat

OpenCoven Chat

-
+ {failed ? ( +
+ +
+ ) : null}
); @@ -309,11 +323,12 @@ export function App({ localSourceFactory = defaultLocalSourceFactory, }: AppProps) { const [localSource, setLocalSource] = useState(null); + const [localFailed, setLocalFailed] = useState(false); + const [localAttempt, setLocalAttempt] = useState(0); const [caveEnabled, setCaveEnabled] = useState(false); const [caveSurfaceOpen, setCaveSurfaceOpen] = useState(false); const [caveStatus, setCaveStatus] = useState(null); const [activeKind, setActiveKind] = useState<'local' | 'cave'>('local'); - const [revision, setRevision] = useState(0); const readInstallationId = useCallback( () => desktopIdentityHost.readInstallationId(), @@ -324,24 +339,45 @@ export function App({ setCaveStatus(status); }, []); + // biome-ignore lint/correctness/useExhaustiveDependencies: localAttempt explicitly retries local-source opening. useEffect(() => { let mounted = true; let created: LocalChatSource | null = null; - - void localSourceFactory().then((source) => { - if (!mounted) { - source.store.dispose(); - return; - } - created = source; - setLocalSource(source); - }); + setLocalSource(null); + setLocalFailed(false); + + void Promise.resolve() + .then(() => (mounted ? localSourceFactory() : undefined)) + .then( + (source) => { + if (!mounted) { + source?.store.dispose(); + return; + } + if (!source) { + setLocalFailed(true); + return; + } + created = source; + setLocalSource(source); + }, + () => { + if (mounted) setLocalFailed(true); + }, + ); return () => { mounted = false; created?.store.dispose(); }; - }, [localSourceFactory]); + }, [localSourceFactory, localAttempt]); + + const subscribeLocal = useCallback( + (listener: () => void) => localSource?.store.subscribe(listener) ?? (() => undefined), + [localSource], + ); + const getLocalRevision = useCallback(() => localSource?.store.getRevision() ?? 0, [localSource]); + const localRevision = useSyncExternalStore(subscribeLocal, getLocalRevision); const caveSource = caveStatus?.source ?? null; // Falling back to local rather than showing an empty Cave view: a dropped @@ -356,7 +392,15 @@ export function App({ }, [activeKind, caveSource]); if (activeSource === null) { - return ; + return ( + { + setLocalFailed(false); + setLocalAttempt((attempt) => attempt + 1); + }} + /> + ); } return ( @@ -434,17 +478,11 @@ export function App({ { - void activeSource.writer.createConversation().then((result) => { - if (result.status === 'ok') { - setRevision((current) => current + 1); - } - }); - }} + onCreateConversation={() => activeSource.writer.createConversation()} onForgetCredential={() => { void caveStatus?.controller?.forgetCredential(); }} diff --git a/src/chat-chapters.test.tsx b/src/chat-chapters.test.tsx new file mode 100644 index 00000000..0b1c8274 --- /dev/null +++ b/src/chat-chapters.test.tsx @@ -0,0 +1,514 @@ +import { readFileSync } from 'node:fs'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { ChatChapters } from './chat-chapters'; +import { ChatShell } from './chat-shell'; +import { + type ConversationChapterPage, + hasValidChapterHeaders, + loadedConversationChapters, + utcChapterDay, +} from './lib/chat-chapters'; +import type { CaveReadClient } from './lib/sdk/connection-controller'; +import type { QueryAdapter, QueryResult } from './lib/sdk/query-adapter'; +import { createQueryAdapter } from './lib/sdk/query-adapter'; + +const message = (id: string, createdAt: string, conversationId = 'one') => ({ + id, + createdAt, + conversationId, + parentId: null, + role: 'user', + text: `text ${id}`, + attachmentCount: 0, + toolCount: 0, +}); +const messages = [message('a', '2026-09-08T23:00:00Z'), message('b', '2026-09-09T01:00:00Z')]; +const page = (sourceRevision = 'a'.repeat(64), hasMore = false): ConversationChapterPage => ({ + conversationId: 'one', + rule: 'utc-day-v1', + contextStatus: 'context-unverified', + sourceRevision, + status: 'complete', + data: [...(loadedConversationChapters('one', messages) ?? [])], + cursor: hasMore ? { hasMore: true, next: 'cursor-next' } : { hasMore: false }, +}); + +const goldenPath = process.env.COVEN_CONTINUITY_GOLDEN_VECTORS; +test('a disconnected chapter source explains that its index is unavailable', async () => { + render( + ({ status: 'not_ready' })) } as unknown as QueryAdapter + } + />, + ); + fireEvent.click(screen.getByRole('button', { name: /Ongoing/ })); + expect(await screen.findByRole('alert')).toHaveTextContent('Chapter index unavailable'); + expect(screen.queryByRole('navigation', { name: 'UTC chapters' })).not.toBeInTheDocument(); +}); + +test('Escape invalidates a stale chapter index before keyboard reopening', async () => { + let refreshed = false; + const invalidate = vi.fn(() => { + refreshed = true; + }); + const listChapters = vi.fn(async () => + refreshed ? { status: 'ok', data: page() } : { status: 'stale' }, + ); + render( + , + ); + const toggle = screen.getByRole('button', { name: /Ongoing/ }); + fireEvent.click(toggle); + await screen.findByRole('alert'); + fireEvent.keyDown(toggle, { key: 'Escape' }); + expect(invalidate).toHaveBeenCalledOnce(); + expect(toggle).toHaveFocus(); + fireEvent.click(toggle); + expect(await screen.findByRole('navigation', { name: 'UTC chapters' })).toBeVisible(); +}); + +test.each(['root', 'continuation'])( + 'an unavailable %s chapter page is terminal even when it advertises another cursor', + async (position) => { + const unavailable = { + ...page(), + status: 'unavailable' as const, + data: [], + cursor: { + ...(position === 'continuation' ? { current: 'cursor-next' } : {}), + hasMore: true, + next: 'unavailable-next', + }, + }; + const listChapters = vi.fn( + async (): Promise> => ({ status: 'ok', data: page() }), + ); + if (position === 'continuation') + listChapters.mockResolvedValueOnce({ status: 'ok', data: page(undefined, true) }); + listChapters.mockResolvedValueOnce({ status: 'ok', data: unavailable }); + render( + , + ); + fireEvent.click(screen.getByRole('button', { name: /Ongoing/ })); + if (position === 'continuation') + fireEvent.click(await screen.findByRole('button', { name: 'Load more chapters' })); + await screen.findByText('Chapter index unavailable. Your transcript is unchanged.'); + const load = screen.queryByRole('button', { name: 'Load more chapters' }); + if (load) await act(async () => fireEvent.click(load)); + expect(listChapters).toHaveBeenCalledTimes(position === 'root' ? 1 : 2); + expect(screen.queryByRole('button', { name: 'Load more chapters' })).not.toBeInTheDocument(); + expect(screen.queryByRole('navigation', { name: 'UTC chapters' })).not.toBeInTheDocument(); + }, +); + +test.skipIf(goldenPath === undefined)( + 'DEVELOPMENT builder matches Cave-owned canonical golden vectors', + () => { + if (!goldenPath) + throw new Error('COVEN_CONTINUITY_GOLDEN_VECTORS must name the Cave-owned fixture'); + const golden = JSON.parse(readFileSync(goldenPath, 'utf8')) as { + contract: string; + algorithm: string; + cases: { + id: string; + conversationId: string; + turns: { id: string; createdAt: string }[]; + expected?: { + id: string; + date: string; + firstTurnId: string; + lastTurnId: string; + turnCount: number; + }[]; + expectedError?: string; + }[]; + }; + expect(golden.contract).toBe('cave.familiar-continuity-v1'); + expect(golden.algorithm).toBe('utc-day-v1'); + expect(golden.cases.length).toBeGreaterThan(0); + for (const vector of golden.cases) { + const chapters = loadedConversationChapters( + vector.conversationId, + vector.turns.map((turn) => ({ ...turn, conversationId: vector.conversationId })), + ); + if (vector.expectedError) { + expect(chapters, vector.id).toBeNull(); + continue; + } + if (!vector.expected) throw new Error(`${vector.id} has no expected result`); + expect( + (chapters ?? []).map(({ id, day, firstTurnId, lastTurnId, turnCount }) => ({ + id, + date: day, + firstTurnId, + lastTurnId, + turnCount, + })), + vector.id, + ).toEqual(vector.expected); + } + }, +); + +test('UTC-day-v1 matches stable producer anchors and preserves clock corrections', () => { + const turns = [...messages, message('c', '2026-09-08T23:00:00Z')]; + const chapters = loadedConversationChapters('one', turns); + expect(chapters?.map((chapter) => chapter.id)).toEqual( + ['a', 'b', 'c'].map((id) => JSON.stringify(['utc-day-v1', 'one', id])), + ); + expect(utcChapterDay('2026-09-09T00:30:00Z')).toBe('2026-09-09'); + expect(utcChapterDay('2026-09-09T00:30:00.123Z')).toBe('2026-09-09'); + for (const invalid of [ + null, + 4, + '2026-02-30T00:00:00Z', + '2026-09-09T01:00:00', + 'invalid', + '2026-09-09T00:30:00+02:00', + '2026-09-09T00:30:00+00:00', + '2026-09-09T00:30:00.1Z', + '2026-09-09T00:30:00.123456789Z', + ]) { + expect(utcChapterDay(invalid)).toBeNull(); + } + expect(loadedConversationChapters('other', turns)).toBeNull(); + expect( + loadedConversationChapters('one', [...turns, message('a', '2026-09-08T23:00:00Z')]), + ).toBeNull(); +}); + +test('old producers show loaded-only partial chapters without inventing a full index', async () => { + render( + , + ); + fireEvent.click(screen.getByRole('button', { name: /Ongoing/ })); + expect(await screen.findByText(/Full chapter index unsupported/)).toHaveTextContent('(partial)'); + expect(screen.getByRole('button', { name: '2026-09-08 1 loaded turn' })).toBeVisible(); + expect(screen.getByText(/This conversation only/)).toHaveTextContent('Context unverified'); +}); + +test('DEVELOPMENT header reads never prefetch transcript bodies and reject stale revision paging', async () => { + const listChapters = vi + .fn() + .mockResolvedValueOnce({ status: 'ok', data: page('a'.repeat(64), true) }) + .mockResolvedValueOnce({ status: 'ok', data: page('b'.repeat(64)) }) + .mockResolvedValue({ status: 'ok', data: page('b'.repeat(64)) }); + const listMessages = vi.fn(); + const invalidate = vi.fn(); + render( + , + ); + expect(listChapters).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole('button', { name: /Ongoing/ })); + fireEvent.click(await screen.findByRole('button', { name: '2026-09-08 1 turn' })); + expect(screen.getByText(/This chapter is not loaded/)).toBeVisible(); + expect(listMessages).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole('button', { name: 'Load more chapters' })); + expect(await screen.findByRole('alert')).toHaveTextContent('chapter index changed'); + expect(screen.queryByRole('navigation', { name: 'UTC chapters' })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /Ongoing/ })); + expect(invalidate).toHaveBeenCalledOnce(); + fireEvent.click(screen.getByRole('button', { name: /Ongoing/ })); + expect(await screen.findByRole('navigation', { name: 'UTC chapters' })).toBeVisible(); +}); + +test('a deleted anchor is explicit, and late source results stay quarantined', async () => { + let resolve!: (value: QueryResult) => void; + const old = { + listChapters: vi.fn( + () => + new Promise>((done) => { + resolve = done; + }), + ), + } as unknown as QueryAdapter; + const current = { + listChapters: vi.fn().mockResolvedValue({ status: 'ok', data: page() }), + } as unknown as QueryAdapter; + const view = render( + , + ); + fireEvent.click(screen.getByRole('button', { name: /Ongoing/ })); + view.rerender( + , + ); + fireEvent.click(await screen.findByRole('button', { name: '2026-09-08 1 turn' })); + expect(screen.getByText(/anchor is no longer available/)).toBeVisible(); + await act(async () => resolve({ status: 'error', code: 'internal_error' })); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); +}); + +test.each([ + ['another conversation', { conversationId: 'other' }], + ['an impossible day', { day: '2026-02-30' }], + ['an empty anchor', { firstTurnId: '' }], + ['a nonpositive turn count', { turnCount: 0 }], +])('chapter headers reject %s instead of offering an unverified anchor', async (_, change) => { + const valid = page(); + const listChapters = vi.fn(async () => ({ + status: 'ok' as const, + data: { + ...valid, + data: valid.data.map((chapter, index) => (index === 0 ? { ...chapter, ...change } : chapter)), + }, + })); + const adapter = { listChapters } as unknown as QueryAdapter; + render( + , + ); + fireEvent.click(screen.getByRole('button', { name: /Ongoing/ })); + expect(await screen.findByRole('alert')).toHaveTextContent('Chapter index unavailable'); + expect(screen.queryByRole('navigation', { name: 'UTC chapters' })).not.toBeInTheDocument(); +}); + +test('chapter paging rejects repeated headers despite a forward-moving cursor', async () => { + const root = page('revision', true); + const listChapters = vi + .fn() + .mockResolvedValueOnce({ status: 'ok', data: root }) + .mockResolvedValueOnce({ + status: 'ok', + data: { + ...root, + cursor: { current: 'cursor-next', hasMore: false }, + }, + }); + render( + , + ); + fireEvent.click(screen.getByRole('button', { name: /Ongoing/ })); + fireEvent.click(await screen.findByRole('button', { name: 'Load more chapters' })); + expect(await screen.findByRole('alert')).toHaveTextContent('chapter index changed'); + expect(screen.queryByRole('navigation', { name: 'UTC chapters' })).not.toBeInTheDocument(); +}); + +test('chapter header validation bounds pages and rejects duplicate anchors within a page', () => { + const valid = page(); + const first = valid.data[0]; + if (!first) throw new Error('Missing first chapter fixture'); + expect(hasValidChapterHeaders(valid, 'one')).toBe(true); + expect( + hasValidChapterHeaders({ ...valid, data: [first, { ...first, id: 'another-id' }] }, 'one'), + ).toBe(false); + expect(hasValidChapterHeaders({ ...valid, sourceRevision: '' }, 'one')).toBe(false); + const headers = Array.from({ length: 51 }, (_, index) => ({ + ...first, + id: `chapter-${index}`, + firstTurnId: `turn-${index}`, + lastTurnId: `turn-${index}`, + turnCount: 1, + })); + expect(hasValidChapterHeaders({ ...valid, data: headers.slice(0, 50) }, 'one')).toBe(true); + expect(hasValidChapterHeaders({ ...valid, data: headers }, 'one')).toBe(false); +}); + +test('chapter header navigation retains the eight-page ceiling', async () => { + const first = page().data[0]; + if (!first) throw new Error('Missing first chapter fixture'); + const listChapters = vi.fn(async (_id: string, options?: { cursor?: string }) => { + const index = Number(options?.cursor ?? 0); + return { + status: 'ok' as const, + data: { + ...page('revision'), + data: [ + { + ...first, + id: `chapter-${index}`, + firstTurnId: `turn-${index}`, + lastTurnId: `turn-${index}`, + turnCount: 1, + }, + ], + cursor: { + ...(options?.cursor ? { current: options.cursor } : {}), + hasMore: true, + next: String(index + 1), + }, + }, + }; + }); + render( + , + ); + fireEvent.click(screen.getByRole('button', { name: /Ongoing/ })); + for (let index = 1; index <= 8; index += 1) { + fireEvent.click(await screen.findByRole('button', { name: 'Load more chapters' })); + } + expect(await screen.findByText(/Chapter page limit reached/)).toBeVisible(); + expect(listChapters).toHaveBeenCalledTimes(8); +}); + +test('chapter query caches remain source-bound, bounded and invalidatable', async () => { + let finish!: (value: ConversationChapterPage) => void; + const first = { + listConversationChapters: vi.fn( + () => + new Promise((done) => { + finish = done; + }), + ), + } as unknown as CaveReadClient; + const second = { + listConversationChapters: vi.fn().mockResolvedValue(page()), + } as unknown as CaveReadClient; + let client = first; + const adapter = createQueryAdapter(() => client, { maxCacheEntries: 1 }); + if (!adapter.listChapters) throw new Error('Chapter read port missing'); + const read = adapter.listChapters; + const pending = read('one'); + client = second; + finish(page()); + expect(await pending).toEqual({ status: 'stale' }); + expect((await read('one')).status).toBe('ok'); + adapter.invalidate(); + expect((await read('one')).status).toBe('ok'); + expect(second.listConversationChapters).toHaveBeenCalledTimes(2); + adapter.dispose(); + expect(await read('one')).toEqual({ status: 'not_ready' }); +}); + +test.each(['invalid', 'transient', 'unavailable'])( + 'explicit reopen bypasses a cached %s chapter result', + async (kind) => { + const read = vi.fn().mockResolvedValue(page()); + if (kind === 'transient') read.mockRejectedValueOnce(new Error('Temporary failure')); + else + read.mockResolvedValueOnce( + kind === 'invalid' + ? { ...page(), sourceRevision: '' } + : { ...page(), status: 'unavailable', data: [] }, + ); + const client = { listConversationChapters: read } as unknown as CaveReadClient; + const adapter = createQueryAdapter(() => client, { now: () => 0 }); + render( + , + ); + const toggle = screen.getByRole('button', { name: /Ongoing/ }); + fireEvent.click(toggle); + await screen.findByText('Chapter index unavailable. Your transcript is unchanged.'); + fireEvent.keyDown(toggle, { key: 'Escape' }); + fireEvent.click(toggle); + expect(await screen.findByRole('navigation', { name: 'UTC chapters' })).toBeVisible(); + expect(read).toHaveBeenCalledTimes(2); + }, +); + +test('reopening legitimate unsupported chapter fallback does not invalidate other query caches', async () => { + const client = {} as CaveReadClient; + const adapter = createQueryAdapter(() => client, { now: () => 0 }); + const invalidate = vi.fn(adapter.invalidate); + render( + , + ); + const toggle = screen.getByRole('button', { name: /Ongoing/ }); + fireEvent.click(toggle); + await screen.findByText(/Full chapter index unsupported/); + fireEvent.click(toggle); + fireEvent.click(toggle); + await screen.findByText(/Full chapter index unsupported/); + expect(invalidate).not.toHaveBeenCalled(); +}); +test('same-name familiars keep separate exact conversations in the production shell', async () => { + const conversations = [ + { + id: 'one', + familiarId: 'first', + title: 'First exact thread', + updatedAt: '2026-09-09T00:00:00Z', + }, + { + id: 'two', + familiarId: 'second', + title: 'Second exact thread', + updatedAt: '2026-09-09T00:00:00Z', + }, + ]; + const ok = (data: T) => ({ status: 'ok' as const, data }); + const adapter: QueryAdapter = { + listFamiliars: vi.fn().mockResolvedValue( + ok({ + data: [ + { id: 'first', displayName: 'Mara', role: 'Guide' }, + { id: 'second', displayName: 'Mara', role: 'Guide' }, + ], + }), + ), + listProjects: vi.fn().mockResolvedValue(ok({ data: [] })), + listConversations: vi.fn().mockResolvedValue(ok({ data: conversations })), + getConversation: vi.fn(async (id) => { + const record = conversations.find((conversation) => conversation.id === id); + if (!record) throw new Error('Missing exact conversation'); + return ok(record); + }), + listMessages: vi.fn(async (id) => ok({ data: [message(id, '2026-09-09T00:00:00Z', id)] })), + familiarContract: vi.fn(), + familiarAnalytics: vi.fn(), + invalidate: vi.fn(), + dispose: vi.fn(), + }; + render(); + expect(await screen.findByText('text one')).toBeVisible(); + fireEvent.change(screen.getByRole('combobox', { name: 'Familiar' }), { + target: { value: 'second' }, + }); + expect(await screen.findByText('text two')).toBeVisible(); + expect(screen.queryByText('text one')).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /Ongoing/ })); + expect(await screen.findByText(/This conversation only/)).toBeVisible(); + expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); +}); diff --git a/src/chat-chapters.tsx b/src/chat-chapters.tsx new file mode 100644 index 00000000..5bb0bfe2 --- /dev/null +++ b/src/chat-chapters.tsx @@ -0,0 +1,212 @@ +import type { CaveConversationMessage } from '@opencoven/cave-client/managed'; +import { useEffect, useId, useMemo, useRef, useState } from 'react'; +import { + type ConversationChapterPage, + chapterTurnElementId, + hasValidChapterHeaders, + loadedConversationChapters, +} from './lib/chat-chapters'; +import { createManualPageWalk } from './lib/sdk/manual-page-walk'; +import type { QueryAdapter, QueryResult } from './lib/sdk/query-adapter'; + +type Props = Readonly<{ + conversationId: string; + messages: readonly CaveConversationMessage[]; + hasMoreMessages: boolean; + queryAdapter: QueryAdapter; + localOnly?: boolean; +}>; + +export function ChatChapters({ + conversationId, + messages, + hasMoreMessages, + queryAdapter, + localOnly = false, +}: Props) { + const [expanded, setExpanded] = useState(false); + const [result, setResult] = useState>({ status: 'loading' }); + const [paging, setPaging] = useState(false); + const [notice, setNotice] = useState(''); + const generation = useRef(0); + const toggle = useRef(null); + const bodyId = useId(); + const walk = useMemo(() => createManualPageWalk(), []); + const loaded = useMemo( + () => loadedConversationChapters(conversationId, messages), + [conversationId, messages], + ); + + useEffect(() => { + const request = ++generation.current; + walk.reset(); + setNotice(''); + setPaging(false); + setResult({ status: 'loading' }); + if (!expanded) return; + const read = queryAdapter.listChapters?.bind(queryAdapter); + if (!read || localOnly) { + setResult({ status: 'error', code: 'unsupported_operation' }); + return; + } + void read(conversationId).then((next) => { + if (request !== generation.current) return; + setResult( + next.status === 'ok' && + (!hasValidChapterHeaders(next.data, conversationId) || !walk.acceptRootPage(next.data)) + ? { status: 'error', code: 'invalid_response' } + : next, + ); + }); + return () => { + generation.current += 1; + }; + }, [conversationId, expanded, queryAdapter, walk, localOnly]); + + function loadMore() { + if (result.status !== 'ok' || result.data.status === 'unavailable' || paging) return; + const previous = result.data; + const cursor = previous.cursor?.next; + const read = queryAdapter.listChapters?.bind(queryAdapter); + if (!cursor || !read) return; + if (!walk.canFetchNextPage()) { + setNotice('Chapter page limit reached. Reopen Ongoing to refresh.'); + return; + } + const request = generation.current; + setPaging(true); + void read(conversationId, { cursor }).then((next) => { + if (request !== generation.current) return; + setPaging(false); + if (next.status !== 'ok') { + setResult(next); + return; + } + if ( + next.data.sourceRevision !== previous.sourceRevision || + !hasValidChapterHeaders(next.data, conversationId, previous.data) || + !walk.acceptNextPage(cursor, next.data) + ) { + setResult({ status: 'reconcile_required' }); + return; + } + setResult({ + status: 'ok', + data: { ...next.data, data: [...previous.data, ...next.data.data] }, + }); + }); + } + + const unsupported = result.status === 'error' && result.code === 'unsupported_operation'; + const chapters = result.status === 'ok' ? result.data.data : unsupported ? loaded : null; + const unavailable = result.status === 'ok' && result.data.status === 'unavailable'; + const stale = result.status === 'reconcile_required' || result.status === 'stale'; + function close() { + if (stale || unavailable || (result.status === 'error' && !unsupported)) + queryAdapter.invalidate(); + setExpanded(false); + } + return ( +
{ + if (event.key === 'Escape' && expanded) { + event.preventDefault(); + close(); + toggle.current?.focus(); + } + }} + > + + {expanded ? ( +
+

+ This conversation only · UTC chapters ·{' '} + {localOnly ? 'Local notes, no model context' : 'Context unverified'} +

+ {unsupported ? ( +

+ {localOnly + ? 'Local note chapters. Showing only loaded messages' + : 'Full chapter index unsupported by this SDK or Cave. Showing only loaded messages'} + {hasMoreMessages ? ' (partial)' : ''}. +

+ ) : null} + {result.status === 'loading' ? Loading chapter headers… : null} + {result.status === 'ok' && result.data.status === 'partial' ? ( + Partial chapter index. Only these anchors are verified. + ) : null} + {stale ? ( +

The chapter index changed. Close and reopen Ongoing to refresh.

+ ) : null} + {unavailable || (unsupported && !loaded) ? ( + Chapter index unavailable. Your transcript is unchanged. + ) : null} + {result.status === 'not_ready' || (result.status === 'error' && !unsupported) ? ( +

Chapter index unavailable. Your transcript is unchanged.

+ ) : null} + {chapters && !unavailable ? ( + + ) : null} + {result.status === 'ok' && !unavailable && result.data.cursor?.hasMore ? ( + + ) : null} + {notice ? {notice} : null} +
+ ) : null} +
+ ); +} diff --git a/src/chat-composer.css b/src/chat-composer.css index 86f0685d..d519f15c 100644 --- a/src/chat-composer.css +++ b/src/chat-composer.css @@ -84,6 +84,11 @@ border-top: 1px solid var(--chat-border, rgb(255 255 255 / 12%)); } +.chat-composer:has(.chat-write-recovery) { + min-height: 0; + overflow-y: auto; +} + .chat-composer__label { position: absolute; width: 1px; diff --git a/src/chat-composer.tsx b/src/chat-composer.tsx index 531b6b67..1a201b64 100644 --- a/src/chat-composer.tsx +++ b/src/chat-composer.tsx @@ -1,5 +1,6 @@ -import { useId, useState } from 'react'; - +import { useEffect, useId, useMemo, useRef, useSyncExternalStore } from 'react'; +import { ChatWriteRecovery } from './chat-write-recovery'; +import { createDraft } from './lib/chat-continuity'; import type { ChatWriter } from './lib/local/chat-writer'; export type ChatComposerProps = Readonly<{ @@ -7,16 +8,9 @@ export type ChatComposerProps = Readonly<{ conversationId: string | null; isDurable: boolean; onWritten: () => void; + savedDraft?: ReturnType | undefined; }>; -type ComposerStatus = - | Readonly<{ status: 'idle' }> - | Readonly<{ status: 'sending' }> - | Readonly<{ status: 'error'; message: string }>; - -const IDLE: ComposerStatus = Object.freeze({ status: 'idle' } as const); -const SENDING: ComposerStatus = Object.freeze({ status: 'sending' } as const); - function messageForCode(code: string): string { switch (code) { case 'invalid_request': @@ -28,40 +22,80 @@ function messageForCode(code: string): string { } } -export function ChatComposer({ writer, conversationId, isDurable, onWritten }: ChatComposerProps) { - const [draft, setDraft] = useState(''); - const [composerStatus, setComposerStatus] = useState(IDLE); +export function ChatComposer({ + writer, + conversationId, + isDurable, + onWritten, + savedDraft, +}: ChatComposerProps) { + // biome-ignore lint/correctness/useExhaustiveDependencies: changing the exact destination resets a standalone composer. + const localDraft = useMemo(createDraft, [writer, conversationId]); + const entry = savedDraft ?? localDraft; + const { + text: draft, + pending, + error, + writes, + recovery, + } = useSyncExternalStore(entry.subscribe, entry.getSnapshot); + const observed = useRef({ entry, writes }); + useEffect(() => { + const changed = observed.current.entry === entry && observed.current.writes !== writes; + observed.current = { entry, writes }; + if (changed) onWritten(); + }, [entry, writes, onWritten]); const inputId = useId(); const canSend = - conversationId !== null && draft.trim().length > 0 && composerStatus.status !== 'sending'; + writer.canWrite() && + conversationId !== null && + draft.trim().length > 0 && + !pending && + !recovery; async function send() { - if (conversationId === null || draft.trim().length === 0) { + if ( + conversationId === null || + draft.trim().length === 0 || + entry.getSnapshot().pending || + entry.getSnapshot().recovery || + !writer.canWrite() + ) { return; } - setComposerStatus(SENDING); + entry.update({ pending: true, error: '' }); try { const result = await writer.sendMessage(conversationId, draft); + if (result.status === 'reconcile_required') { + entry.update({ pending: false, recovery: result.recovery, error: '' }); + return; + } if (result.status === 'ok') { - setDraft(''); - setComposerStatus(IDLE); - onWritten(); + if (result.data.conversationId !== conversationId) { + entry.update({ + pending: false, + error: 'The save result did not match this exact conversation. Your draft was kept.', + }); + return; + } + entry.update({ + text: '', + pending: false, + error: '', + writes: entry.getSnapshot().writes + 1, + }); return; } - setComposerStatus( - Object.freeze({ - status: 'error', - message: result.status === 'unsupported' ? result.reason : messageForCode(result.code), - }), - ); + entry.update({ + pending: false, + error: result.status === 'unsupported' ? result.reason : messageForCode(result.code), + }); } catch { - setComposerStatus( - Object.freeze({ status: 'error', message: messageForCode('service_unavailable') }), - ); + entry.update({ pending: false, error: messageForCode('service_unavailable') }); } } @@ -83,22 +117,20 @@ export function ChatComposer({ writer, conversationId, isDurable, onWritten }: C rows={2} value={draft} placeholder={conversationId === null ? 'Start a conversation first' : 'Write a message…'} - disabled={conversationId === null || composerStatus.status === 'sending'} + disabled={!writer.canWrite() || conversationId === null || pending || recovery !== null} + maxLength={32_000} onChange={(event) => { - setDraft(event.target.value); - if (composerStatus.status === 'error') { - setComposerStatus(IDLE); - } + entry.update({ text: event.target.value, error: '' }); }} onKeyDown={(event) => { - if (event.key === 'Enter' && !event.shiftKey) { + if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) { event.preventDefault(); void send(); } }} />

@@ -106,11 +138,35 @@ export function ChatComposer({ writer, conversationId, isDurable, onWritten }: C ? 'Saved on this device. No familiar is connected, so no reply will arrive.' : 'This device has no available storage, so these messages are kept in memory only and will be lost when the app closes.'}

- {composerStatus.status === 'error' ? ( + {error ? ( - {composerStatus.message} + {error} ) : null} + {recovery ? ( + { + const current = entry.getSnapshot(); + if (current.recovery !== recovery) return; + const matches = + result.receipt.kind === 'message' && + result.receipt.conversationId === conversationId && + result.receipt.text === current.text.trim(); + entry.update({ + recovery: null, + text: result.outcome === 'committed' && matches ? '' : current.text, + error: + result.outcome === 'not_committed' + ? 'The save did not commit. Your draft is retained and may be sent again.' + : '', + writes: current.writes + 1, + }); + }} + /> + ) : null} ); } diff --git a/src/chat-continuity.test.tsx b/src/chat-continuity.test.tsx new file mode 100644 index 00000000..4ab97841 --- /dev/null +++ b/src/chat-continuity.test.tsx @@ -0,0 +1,437 @@ +import type { CaveConversation, CaveConversationMessage } from '@opencoven/cave-client/managed'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { ChatShell } from './chat-shell'; +import { chapterTurnElementId } from './lib/chat-chapters'; +import { continuityMemory, exactThreadKey } from './lib/chat-continuity'; +import { openChatStore } from './lib/local/chat-store'; +import { + type ChatWriter, + createLocalChatWriter, + createReadOnlyChatWriter, + type WriteResult, +} from './lib/local/chat-writer'; +import { createLocalQueryAdapter, LOCAL_FAMILIAR_ID } from './lib/local/local-query-adapter'; +import { createMemoryChatBackend } from './lib/local/memory-backend'; +import type { QueryAdapter } from './lib/sdk/query-adapter'; + +const timestamp = '2026-09-09T00:00:00.000Z'; +const conversations = [ + { id: 'a-new', familiarId: 'a', title: 'A newest', updatedAt: timestamp }, + { id: 'a-old', familiarId: 'a', title: 'A older', updatedAt: timestamp }, + { id: 'b', familiarId: 'b', title: 'B exact', updatedAt: timestamp }, +]; +const ok = (data: T) => ({ status: 'ok' as const, data }); +const message = (id: string): CaveConversationMessage => ({ + id: `${id}-message`, + conversationId: id, + text: `${id} text`, + role: 'user', + parentId: null, + createdAt: timestamp, + attachmentCount: 0, + toolCount: 0, +}); +function source(): QueryAdapter { + return { + listFamiliars: vi.fn(async () => + ok({ + data: [ + { id: 'a', displayName: 'Same name', role: 'Guide' }, + { id: 'b', displayName: 'Same name', role: 'Guide' }, + ], + }), + ), + listConversations: vi.fn(async () => ok({ data: conversations })), + listProjects: vi.fn(async () => ok({ data: [] })), + getConversation: vi.fn(async (id) => { + const record = conversations.find((item) => item.id === id); + return record ? ok(record) : { status: 'error' as const, code: 'not_found' }; + }), + listMessages: vi.fn(async (id) => ok({ data: [message(id)] })), + familiarContract: vi.fn(), + familiarAnalytics: vi.fn(), + invalidate: vi.fn(), + dispose: vi.fn(), + }; +} + +test('remembered scroll anchors must intersect the history pane, not the composer below it', async () => { + const adapter = source(); + const memory = continuityMemory(adapter, null); + render(); + const message = (await screen.findByText('a-new text')).closest('li'); + const body = message?.closest('.chat-shell__thread-body'); + if (!message || !body) throw new Error('Missing history fixture'); + vi.spyOn(body, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 100, 400, 200)); + const bounds = vi.spyOn(message, 'getBoundingClientRect'); + bounds.mockReturnValue(new DOMRect(0, 350, 400, 100)); + fireEvent.scroll(body); + expect(memory.anchors.get(exactThreadKey('a', 'a-new'))).toBeUndefined(); + bounds.mockReturnValue(new DOMRect(0, 250, 400, 100)); + fireEvent.scroll(body); + expect(memory.anchors.get(exactThreadKey('a', 'a-new'))).toBe('a-new-message'); +}); + +test('a late successful create refreshes the sidebar without replacing the navigated thread', async () => { + const adapter = source(); + let finish!: (result: WriteResult) => void; + const create = vi.fn( + () => + new Promise>((resolve) => { + finish = resolve; + }), + ); + const writer: ChatWriter = { + canWrite: () => true, + createConversation: create, + sendMessage: vi.fn(), + }; + render(); + await screen.findByText('a-new text'); + fireEvent.click(screen.getByRole('button', { name: 'New' })); + await waitFor(() => expect(create).toHaveBeenCalledOnce()); + fireEvent.change(screen.getByRole('combobox', { name: 'Familiar' }), { target: { value: 'b' } }); + await screen.findByText('b text'); + const created = { + id: 'created', + familiarId: 'a', + title: 'Created while away', + updatedAt: timestamp, + }; + vi.mocked(adapter.listConversations).mockResolvedValue(ok({ data: [created, ...conversations] })); + await act(async () => finish(ok(created))); + await waitFor(() => expect(adapter.listConversations).toHaveBeenCalledTimes(2)); + expect(screen.getByText('b text')).toBeVisible(); + fireEvent.change(screen.getByRole('combobox', { name: 'Familiar' }), { target: { value: 'a' } }); + expect(await screen.findByRole('option', { name: /Created while away/ })).toBeVisible(); + expect(await screen.findByText('a-new text')).toBeVisible(); +}); + +test.each(['error', 'unsupported', 'throw'] as const)( + 'create %s reports creation guidance rather than selection failure', + async (status) => { + const adapter = source(); + const create = vi.fn(async (): Promise> => { + if (status === 'throw') throw new Error('acknowledgement lost'); + return status === 'unsupported' + ? { status, reason: 'Creation is unavailable for this source.' } + : { status, code: 'service_unavailable' }; + }); + render( + true, createConversation: create, sendMessage: vi.fn() }} + onCreateConversation={create} + />, + ); + await screen.findByText('a-new text'); + fireEvent.click(screen.getByRole('button', { name: 'New' })); + await screen.findByText( + status === 'unsupported' + ? 'Creation is unavailable for this source.' + : /creation result could not be confirmed.*storage.*before retrying/i, + ); + expect(screen.queryByText(/could not be selected/)).not.toBeInTheDocument(); + expect(screen.getByText('a-new text')).toBeVisible(); + }, +); + +test('familiar return restores the exact older conversation and anchor, not newest', async () => { + const adapter = source(); + const view = render(); + fireEvent.click(await screen.findByRole('option', { name: /A older/ })); + const anchor = await screen.findByText('a-old text'); + fireEvent.focus(anchor.closest('li') as HTMLElement); + fireEvent.change(screen.getByRole('combobox', { name: 'Familiar' }), { target: { value: 'b' } }); + await screen.findByText('b text'); + fireEvent.change(screen.getByRole('combobox', { name: 'Familiar' }), { target: { value: 'a' } }); + await screen.findByText('a-old text'); + expect(document.getElementById(chapterTurnElementId('a-old', 'a-old-message'))).toHaveFocus(); + view.unmount(); + render(); + expect(await screen.findByText('a-old text')).toBeVisible(); +}); + +test.each(['not loaded', 'unavailable'])( + 'a remembered later-page familiar stays represented when its roster entry is %s', + async (availability) => { + const adapter = source(); + const other = source(); + const first = { id: 'a', displayName: 'First familiar', role: 'Guide' }; + const later = { id: 'b', displayName: 'Later familiar', role: 'Guide' }; + vi.mocked(adapter.listFamiliars).mockImplementation(async (options) => + ok({ + data: options?.cursor ? [later] : [first], + cursor: options?.cursor + ? { current: options.cursor, hasMore: false } + : { hasMore: true, next: 'later' }, + }), + ); + const view = render(); + fireEvent.click(await screen.findByRole('button', { name: 'Load more familiars' })); + await screen.findByRole('option', { name: 'Later familiar — Guide' }); + fireEvent.change(screen.getByRole('combobox', { name: 'Familiar' }), { + target: { value: 'b' }, + }); + await screen.findByText('b text'); + view.rerender(); + await screen.findByText('a-new text'); + if (availability === 'unavailable') { + vi.mocked(adapter.listFamiliars).mockResolvedValue( + ok({ data: [first], cursor: { hasMore: false } }), + ); + vi.mocked(adapter.getConversation).mockResolvedValue({ + status: 'error', + code: 'not_found', + }); + } + view.rerender(); + if (availability === 'unavailable') { + expect(await screen.findByRole('alert')).toHaveTextContent( + 'selected conversation is unavailable', + ); + expect(screen.queryByText('a-new text')).not.toBeInTheDocument(); + } else { + await screen.findByText('b text'); + } + const select = screen.getByRole('combobox', { name: 'Familiar' }); + expect(select).toHaveValue('b'); + expect(select.querySelector('option:checked')).toHaveTextContent( + `Saved familiar b — ${availability}`, + ); + if (availability === 'not loaded') { + fireEvent.click(screen.getByRole('button', { name: 'Load more familiars' })); + await screen.findByRole('option', { name: 'Later familiar — Guide' }); + expect(select).toHaveValue('b'); + expect(select.querySelector('option:checked')).toHaveTextContent('Later familiar — Guide'); + expect(screen.queryByRole('option', { name: /Saved familiar/ })).not.toBeInTheDocument(); + expect(screen.getByText('b text')).toBeVisible(); + } + }, +); + +test('source and writer changes quarantine drafts and late send outcomes', async () => { + const adapter = source(); + const other = source(); + let resolve!: (value: WriteResult) => void; + const writer: ChatWriter = { + canWrite: () => true, + createConversation: vi.fn(), + sendMessage: vi.fn( + () => + new Promise>((done) => { + resolve = done; + }), + ), + }; + const view = render(); + await screen.findByText('a-new text'); + fireEvent.change(screen.getByRole('textbox', { name: 'Message' }), { + target: { value: 'first source draft' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Send' })); + view.rerender(); + await screen.findByText('a-new text'); + expect(screen.getByRole('textbox', { name: 'Message' })).toHaveValue(''); + fireEvent.change(screen.getByRole('textbox', { name: 'Message' }), { + target: { value: 'second source draft' }, + }); + await act(async () => resolve({ status: 'error', code: 'service_unavailable' })); + expect(screen.getByRole('textbox', { name: 'Message' })).toHaveValue('second source draft'); + view.rerender(); + await screen.findByText('a-new text'); + expect(screen.getByRole('textbox', { name: 'Message' })).toHaveValue('first source draft'); + expect(screen.getByRole('alert')).toHaveTextContent('could not be saved'); + view.rerender(); + await screen.findByText('a-new text'); + expect(screen.getByRole('textbox', { name: 'Message' })).toHaveValue(''); +}); + +test('missing remembered conversations and anchors never silently choose latest', async () => { + const adapter = source(); + const view = render(); + fireEvent.click(await screen.findByRole('option', { name: /A older/ })); + fireEvent.focus((await screen.findByText('a-old text')).closest('li') as HTMLElement); + view.unmount(); + vi.mocked(adapter.getConversation).mockResolvedValue({ status: 'error', code: 'not_found' }); + const next = render(); + expect(await screen.findByRole('alert')).toHaveTextContent( + 'selected conversation is unavailable', + ); + expect(screen.queryByText('a-new text')).not.toBeInTheDocument(); + next.unmount(); + const older = conversations[1]; + if (!older) throw new Error('Missing older fixture'); + vi.mocked(adapter.getConversation).mockResolvedValue(ok(older)); + vi.mocked(adapter.listMessages).mockResolvedValue(ok({ data: [] })); + render(); + expect(await screen.findByText(/saved message is unavailable/)).toBeVisible(); +}); + +test('production local side notes preserve parent draft and import only the edited review', async () => { + const store = await openChatStore({ + familiarId: LOCAL_FAMILIAR_ID, + backend: createMemoryChatBackend(), + }); + const parent = await store.createConversation('Exact parent'); + const writer = createLocalChatWriter(store); + const adapter = createLocalQueryAdapter(store); + render(); + await screen.findByRole('button', { name: 'New retained side note' }); + fireEvent.change(screen.getByRole('textbox', { name: 'Message' }), { + target: { value: 'unsent parent draft' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'New retained side note' })); + await screen.findByRole('button', { name: 'Close note' }); + expect(screen.getByRole('textbox', { name: 'Message' })).toHaveValue(''); + fireEvent.change(screen.getByRole('textbox', { name: 'Message' }), { + target: { value: 'raw side text' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Send' })); + fireEvent.click(await screen.findByRole('checkbox', { name: /raw side text/ })); + fireEvent.click(screen.getByRole('button', { name: 'Review Bring back' })); + fireEvent.change(await screen.findByRole('textbox', { name: 'Reviewed excerpt' }), { + target: { value: 'only reviewed text' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Bring back reviewed excerpt' })); + await waitFor(() => + expect(store.listMessages(parent.id, 50).data.map((entry) => entry.text)).toEqual([ + 'only reviewed text', + ]), + ); + fireEvent.click(await screen.findByRole('button', { name: 'Close note' })); + expect(await screen.findByText('only reviewed text')).toBeVisible(); + expect(screen.getByRole('textbox', { name: 'Message' })).toHaveValue('unsent parent draft'); + expect(screen.queryByText('raw side text')).not.toBeInTheDocument(); +}); + +test('Cave refuses side mutations rather than offering a local fallback', async () => { + render(); + expect(await screen.findByText(/Side chats and Bring back are unavailable/)).toBeVisible(); + expect(screen.queryByRole('button', { name: 'New retained side note' })).not.toBeInTheDocument(); + expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); + expect( + screen.getByText( + 'Import provenance is unavailable through this installed SDK. Messages are displayed as read-only text.', + ), + ).toBeVisible(); +}); + +test('a mismatched write receipt cannot clear the exact-thread draft', async () => { + const writer: ChatWriter = { + canWrite: () => true, + createConversation: vi.fn(), + sendMessage: vi.fn(async () => ok(message('b'))), + }; + render(); + await screen.findByText('a-new text'); + fireEvent.change(screen.getByRole('textbox', { name: 'Message' }), { + target: { value: 'keep this draft' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Send' })); + expect(await screen.findByRole('alert')).toHaveTextContent( + 'did not match this exact conversation', + ); + expect(screen.getByRole('textbox', { name: 'Message' })).toHaveValue('keep this draft'); +}); + +test('each familiar keeps its own draft while a saved older chat remains off the root page', async () => { + const adapter = source(); + const writer: ChatWriter = { + canWrite: () => true, + createConversation: vi.fn(), + sendMessage: vi.fn(), + }; + const view = render(); + fireEvent.click(await screen.findByRole('option', { name: /A older/ })); + await screen.findByText('a-old text'); + fireEvent.change(screen.getByRole('textbox', { name: 'Message' }), { + target: { value: 'draft a' }, + }); + fireEvent.change(screen.getByRole('combobox', { name: 'Familiar' }), { target: { value: 'b' } }); + await screen.findByText('b text'); + expect(screen.getByRole('textbox', { name: 'Message' })).toHaveValue(''); + fireEvent.change(screen.getByRole('textbox', { name: 'Message' }), { + target: { value: 'draft b' }, + }); + fireEvent.change(screen.getByRole('combobox', { name: 'Familiar' }), { target: { value: 'a' } }); + await screen.findByText('a-old text'); + expect(screen.getByRole('textbox', { name: 'Message' })).toHaveValue('draft a'); + view.unmount(); + vi.mocked(adapter.listConversations).mockResolvedValue( + ok({ data: conversations.filter((record) => record.id !== 'a-old') }), + ); + render(); + await screen.findByText('a-old text'); + expect(screen.getByRole('textbox', { name: 'Message' })).toHaveValue('draft a'); +}); + +test('producer replacement behind the same adapter cannot reuse another source selection', async () => { + const adapter = source(); + let identity = {}; + adapter.getSourceIdentity = () => identity; + const view = render(); + fireEvent.click(await screen.findByRole('option', { name: /A older/ })); + await screen.findByText('a-old text'); + identity = {}; + view.rerender(); + expect(await screen.findByText('a-new text')).toBeVisible(); + expect(screen.queryByText('a-old text')).not.toBeInTheDocument(); +}); + +test('a wrong-familiar exact record is refused before the composer can write', async () => { + const adapter = source(); + vi.mocked(adapter.getConversation).mockResolvedValue( + ok({ + id: 'a-new', + familiarId: 'another', + title: 'Foreign record', + updatedAt: timestamp, + }), + ); + const writer: ChatWriter = { + canWrite: () => true, + createConversation: vi.fn(), + sendMessage: vi.fn(), + }; + render(); + await screen.findByRole('alert'); + expect(screen.getByRole('textbox', { name: 'Message' })).toBeDisabled(); + expect(screen.queryByText('a-new text')).not.toBeInTheDocument(); + expect(writer.sendMessage).not.toHaveBeenCalled(); +}); + +test('returning before a pending save completes refreshes only that exact thread and submits once', async () => { + const adapter = source(); + let resolve!: (value: WriteResult) => void; + const writer: ChatWriter = { + canWrite: () => true, + createConversation: vi.fn(), + sendMessage: vi.fn( + () => + new Promise>((done) => { + resolve = done; + }), + ), + }; + render(); + await screen.findByText('a-new text'); + const input = screen.getByRole('textbox', { name: 'Message' }); + fireEvent.change(input, { target: { value: 'pending note' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + fireEvent.keyDown(input, { key: 'Enter' }); + expect(writer.sendMessage).toHaveBeenCalledTimes(1); + fireEvent.change(screen.getByRole('combobox', { name: 'Familiar' }), { target: { value: 'b' } }); + await screen.findByText('b text'); + fireEvent.change(screen.getByRole('combobox', { name: 'Familiar' }), { target: { value: 'a' } }); + await screen.findByText('a-new text'); + expect(screen.getByRole('textbox', { name: 'Message' })).toBeDisabled(); + vi.mocked(adapter.listMessages).mockImplementation(async (id) => + ok({ + data: [message(id), { ...message(id), id: 'saved', text: 'pending note' }], + }), + ); + await act(async () => resolve(ok(message('a-new')))); + expect(await screen.findByText('pending note')).toBeVisible(); + expect(screen.getByRole('textbox', { name: 'Message' })).toHaveValue(''); +}); diff --git a/src/chat-shell.css b/src/chat-shell.css index d0c9b748..4401e419 100644 --- a/src/chat-shell.css +++ b/src/chat-shell.css @@ -1,3 +1,149 @@ +.chat-chapters { + margin-bottom: 1.5rem; + border-block: 1px solid var(--chat-line-strong); +} + +.chat-chapters__toggle { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + width: 100%; + padding: 0.75rem 0; + background: transparent; + border: 0; + color: var(--text-primary); + font: inherit; + cursor: pointer; +} + +.chat-chapters__toggle span:first-child { + color: var(--chat-violet); + font-weight: 600; +} + +.chat-chapters__toggle span:last-child, +.chat-chapters__context, +.chat-chapters__notice, +.chat-chapters__chapter span { + color: var(--chat-muted); + font-size: 0.8rem; +} + +.chat-chapters__body { + display: grid; + gap: 0.75rem; + padding-bottom: 1rem; +} + +.chat-side { + display: grid; + gap: 0.75rem; + padding: 1rem 0; + margin-bottom: 1rem; + border-bottom: 1px solid var(--chat-line); +} + +.chat-side p { + margin: 0; + line-height: 1.5; +} +.chat-side__actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} +.chat-side__selection { + display: flex; + gap: 0.5rem; + align-items: baseline; + margin-block: 0.5rem; + overflow-wrap: anywhere; +} +.chat-side__review { + display: grid; + gap: 0.75rem; +} +.chat-side__review textarea { + width: 100%; + box-sizing: border-box; + resize: vertical; +} +.chat-side__list { + padding-left: 1.25rem; + display: grid; + gap: 0.5rem; +} +.chat-side button, +.chat-side textarea { + padding: 0.65rem 0.85rem; + border: 1px solid var(--chat-line-strong); + border-radius: 0.5rem; + background: var(--chat-panel); + color: var(--text-primary); + font: inherit; +} +.chat-side button { + cursor: pointer; + overflow-wrap: anywhere; +} +.chat-side button:disabled { + opacity: 0.5; + cursor: default; +} +.chat-side button:focus-visible, +.chat-side textarea:focus-visible, +.chat-side input:focus-visible { + outline: 2px solid var(--chat-violet); + outline-offset: 3px; +} + +.chat-chapters__body p { + margin: 0; + line-height: 1.5; +} + +.chat-chapters__list { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +@media (max-width: 640px) { + .chat-chapters__body { + max-height: 45vh; + overflow-y: auto; + overscroll-behavior: contain; + } + .chat-chapters__list { + display: grid; + grid-template-columns: minmax(0, 1fr); + } +} + +.chat-chapters__chapter { + display: grid; + gap: 0.25rem; + padding: 0.75rem 1rem; + border: 1px solid var(--chat-line); + border-radius: 0.5rem; + background: var(--chat-panel); + color: var(--text-primary); + font: inherit; + text-align: left; + cursor: pointer; +} + +.chat-chapters__chapter time { + font-variant-numeric: tabular-nums; +} + +.chat-chapters button:focus-visible, +.chat-shell__message:focus { + outline: 2px solid var(--chat-violet); + outline-offset: 3px; +} + .chat-shell { --chat-violet: var(--accent-violet); --chat-panel: rgba(16, 12, 24, 0.88); @@ -309,6 +455,10 @@ grid-template-rows: auto minmax(0, 1fr) auto; } +.chat-shell__thread--writable:has(.chat-composer .chat-write-recovery) { + grid-template-rows: auto minmax(0, 1fr) minmax(0, 1fr); +} + @media (max-width: 640px) { .chat-shell { grid-template-columns: minmax(0, 1fr); diff --git a/src/chat-shell.test.tsx b/src/chat-shell.test.tsx index 9a17f2b4..73ed9ddd 100644 --- a/src/chat-shell.test.tsx +++ b/src/chat-shell.test.tsx @@ -103,20 +103,20 @@ function makeQueryAdapter(overrides: Partial = {}): QueryAdapter { }, ]), ), - getConversation: vi.fn().mockResolvedValue({ + getConversation: vi.fn().mockImplementation(async (id: string) => ({ status: 'ok', data: { - id: 'conversation-1', + id, familiarId: 'familiar-1', title: 'First thread', updatedAt: '2026-08-25T00:00:00.000Z', }, - }), - listMessages: vi.fn().mockResolvedValue( + })), + listMessages: vi.fn().mockImplementation(async (id: string) => okPage([ { id: 'message-1', - conversationId: 'conversation-1', + conversationId: id, parentId: null, role: 'assistant', text: 'Hello from Cave.', diff --git a/src/chat-shell.tsx b/src/chat-shell.tsx index cd18143f..e659b6d5 100644 --- a/src/chat-shell.tsx +++ b/src/chat-shell.tsx @@ -5,10 +5,21 @@ import type { CaveProject, } from '@opencoven/cave-client/managed'; import type { Page } from '@opencoven/sdk-core/browser'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; - +import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'; +import { ChatChapters } from './chat-chapters'; import { ChatComposer } from './chat-composer'; -import type { ChatWriter } from './lib/local/chat-writer'; +import { ChatSideConversations } from './chat-side-conversations'; +import { ChatSideRecovery } from './chat-side-recovery'; +import { ChatWriteRecovery } from './chat-write-recovery'; +import { chapterTurnElementId } from './lib/chat-chapters'; +import { + type ContinuityMemory, + continuityMemory, + createDraft, + exactThreadKey, +} from './lib/chat-continuity'; +import type { ChatWriter, RootWriteResult } from './lib/local/chat-writer'; +import type { LocalMessage } from './lib/local/local-query-adapter'; import { createManualPageWalk, type ManualPageWalk } from './lib/sdk/manual-page-walk'; import type { QueryAdapter, QueryResult } from './lib/sdk/query-adapter'; @@ -53,7 +64,7 @@ type ChatShellProps = Readonly<{ * thread renders a composer. */ writer?: ChatWriter | null; - onCreateConversation?: () => void; + onCreateConversation?: () => Promise>; isDurable?: boolean; }>; @@ -68,6 +79,8 @@ const AUTH_REPAIR_CODES = new Set([ 'credential_update_in_progress', ]); const INVALID_PAGINATION_CODE = 'invalid_response'; +const CREATE_FAILURE_NOTICE = + 'The conversation creation result could not be confirmed. Check local storage and refresh the conversation list before retrying.'; function toListState(result: QueryResult>): ListResourceState { switch (result.status) { @@ -242,7 +255,13 @@ function permitsLoadMore(state: LoadMoreState): boolean { return state.status !== 'error' || state.code !== INVALID_PAGINATION_CODE; } -export function ChatShell({ +export function ChatShell(props: ChatShellProps) { + const identity = props.queryAdapter.getSourceIdentity?.() ?? props.queryAdapter; + const memory = continuityMemory(identity, props.writer ?? null); + return ; +} + +function ChatShellView({ queryAdapter, onReconcile, onForgetCredential, @@ -250,7 +269,8 @@ export function ChatShell({ writer = null, onCreateConversation, isDurable = true, -}: ChatShellProps) { + memory, +}: ChatShellProps & { memory: ContinuityMemory }) { const [familiarsState, setFamiliarsState] = useState>({ status: 'idle', }); @@ -272,8 +292,28 @@ export function ChatShell({ status: 'idle', }); const [messagesLoadMore, setMessagesLoadMore] = useState({ status: 'idle' }); - const [selectedFamiliarId, setSelectedFamiliarId] = useState(null); - const [selectedConversationId, setSelectedConversationId] = useState(null); + const [selectedFamiliarId, setSelectedFamiliarId] = useState(memory.familiarId); + const [selectedConversationId, setSelectedConversationId] = useState( + memory.familiarId ? (memory.conversations.get(memory.familiarId) ?? null) : null, + ); + const [positionNotice, setPositionNotice] = useState(''); + const creationEntry = memory.rootCreation; + const { + pending: creating, + recovery: creationRecovery, + error: creationError, + } = useSyncExternalStore(creationEntry.subscribe, creationEntry.getSnapshot); + const navigationEpoch = useRef(0); + const mounted = useRef(false); + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + threadRequestRef.current += 1; + shellRequestRef.current += 1; + }; + }, []); + const threadBodyRef = useRef(null); const [writeRevision, setWriteRevision] = useState(0); const onWritten = useCallback(() => { setWriteRevision((current) => current + 1); @@ -297,6 +337,50 @@ export function ChatShell({ }; } const pageWalks = pageWalksRef.current; + const exactKey = + selectedFamiliarId && selectedConversationId + ? exactThreadKey(selectedFamiliarId, selectedConversationId) + : null; + if (exactKey && !memory.drafts.has(exactKey)) memory.drafts.set(exactKey, createDraft()); + useEffect(() => { + function rememberPosition(event: Event) { + const body = threadBodyRef.current; + if (!exactKey || !body || (event.target !== document && event.target !== body)) return; + const viewport = body.getBoundingClientRect(); + const top = Math.max(0, viewport.top); + const bottom = Math.min(window.innerHeight, viewport.bottom); + if (bottom <= top) return; + const first = [...body.querySelectorAll('[data-turn-id]')].find((element) => { + const bounds = element.getBoundingClientRect(); + return bounds.bottom > top && bounds.top < bottom; + }); + if (first?.dataset.turnId) memory.anchors.set(exactKey, first.dataset.turnId); + } + window.addEventListener('scroll', rememberPosition, { capture: true, passive: true }); + return () => window.removeEventListener('scroll', rememberPosition, true); + }, [exactKey, memory]); + + function selectConversation(id: string | null, remember = true) { + if (id === selectedConversationId) return; + navigationEpoch.current += 1; + threadRequestRef.current += 1; + if (remember && selectedFamiliarId && id) memory.conversations.set(selectedFamiliarId, id); + setPositionNotice(''); + setConversationState({ status: 'idle' }); + setMessagesState({ status: 'idle' }); + setSelectedConversationId(id); + } + + function selectFamiliar(id: string | null) { + navigationEpoch.current += 1; + threadRequestRef.current += 1; + memory.familiarId = id; + setSelectedFamiliarId(id); + setSelectedConversationId(id ? (memory.conversations.get(id) ?? null) : null); + setPositionNotice(''); + setConversationState({ status: 'idle' }); + setMessagesState({ status: 'idle' }); + } function resetAllPageWalks(): void { shellRequestRef.current += 1; @@ -527,7 +611,10 @@ export function ChatShell({ return; } if (result.status === 'ok') { - if (!pageWalks.messages.acceptNextPage(cursor, result.data)) { + if ( + result.data.data.some((message) => message.conversationId !== conversationId) || + !pageWalks.messages.acceptNextPage(cursor, result.data) + ) { setMessagesLoadMore({ status: 'error', code: INVALID_PAGINATION_CODE }); return; } @@ -552,17 +639,11 @@ export function ChatShell({ const allConversations = conversationsState.status === 'ready' ? conversationsState.items : []; useEffect(() => { - if (familiars.length === 0) { - setSelectedFamiliarId(null); - return; - } - - setSelectedFamiliarId((current) => - current !== null && familiars.some((familiar) => familiar.id === current) - ? current - : (familiars[0]?.id ?? null), - ); - }, [familiars]); + if (familiarsState.status !== 'ready' || selectedFamiliarId !== null) return; + const first = familiars[0]?.id ?? null; + memory.familiarId = first; + setSelectedFamiliarId(first); + }, [familiars, familiarsState.status, selectedFamiliarId, memory]); const filteredConversations = useMemo(() => { if (selectedFamiliarId === null) { @@ -576,8 +657,13 @@ export function ChatShell({ const selectedConversation = useMemo( () => filteredConversations.find((conversation) => conversation.id === selectedConversationId) ?? + (conversationState.status === 'ready' && + conversationState.data.id === selectedConversationId && + conversationState.data.familiarId === selectedFamiliarId + ? conversationState.data + : null) ?? null, - [filteredConversations, selectedConversationId], + [filteredConversations, selectedConversationId, selectedFamiliarId, conversationState], ); const currentFamiliar = useMemo( @@ -586,17 +672,11 @@ export function ChatShell({ ); useEffect(() => { - if (filteredConversations.length === 0) { - setSelectedConversationId(null); - return; - } - - setSelectedConversationId((current) => - current !== null && filteredConversations.some((conversation) => conversation.id === current) - ? current - : (filteredConversations[0]?.id ?? null), - ); - }, [filteredConversations]); + if (selectedConversationId !== null || !selectedFamiliarId) return; + const id = memory.conversations.get(selectedFamiliarId) ?? filteredConversations[0]?.id ?? null; + if (id) memory.conversations.set(selectedFamiliarId, id); + setSelectedConversationId(id); + }, [filteredConversations, selectedConversationId, selectedFamiliarId, memory]); // biome-ignore lint/correctness/useExhaustiveDependencies: revision and writeRevision are refetch triggers, not values this effect reads. useEffect(() => { @@ -633,6 +713,17 @@ export function ChatShell({ ) { return; } + if ( + (conversationResult.status === 'ok' && + (conversationResult.data.id !== conversationId || + conversationResult.data.familiarId !== selectedFamiliarId)) || + (messagesResult.status === 'ok' && + messagesResult.data.data.some((message) => message.conversationId !== conversationId)) + ) { + setConversationState({ status: 'error', code: 'invalid_response' }); + setMessagesState({ status: 'error', code: 'invalid_response' }); + return; + } setConversationState(itemState(conversationResult)); setMessagesState(toRootListState(messagesResult, pageWalks.messages)); } @@ -642,14 +733,40 @@ export function ChatShell({ return () => { active = false; }; - }, [pageWalks, queryAdapter, revision, selectedConversationId, writeRevision]); + }, [ + pageWalks, + queryAdapter, + revision, + selectedConversationId, + selectedFamiliarId, + writeRevision, + ]); useEffect(() => { if (selectedConversationId === null || conversationState.status !== 'ready') { return; } - threadHeadingRef.current?.focus(); - }, [conversationState, selectedConversationId]); + if (messagesState.status !== 'ready') return; + const anchorId = exactKey ? memory.anchors.get(exactKey) : undefined; + if (anchorId) { + const anchor = document.getElementById( + chapterTurnElementId(selectedConversationId, anchorId), + ); + if (anchor) { + anchor.focus({ preventScroll: true }); + anchor.scrollIntoView?.({ block: 'nearest' }); + setPositionNotice(''); + } else { + setPositionNotice( + messagesState.hasMore + ? 'Your saved position is not loaded. Load more messages to reach it.' + : 'Your saved message is unavailable. No other position was substituted.', + ); + } + } else { + threadHeadingRef.current?.focus({ preventScroll: true }); + } + }, [conversationState, messagesState, selectedConversationId, exactKey, memory]); function renderConversationList() { const action = repairAction([familiarsState, conversationsState, conversationsLoadMore], { @@ -719,7 +836,7 @@ export function ChatShell({ role="option" aria-selected={selected} onClick={() => { - setSelectedConversationId(conversation.id); + selectConversation(conversation.id); }} > @@ -828,6 +945,12 @@ export function ChatShell({ return statusPanel(formatError(conversationsState.code), 'alert', action); } if (conversationState.status === 'error') { + if (conversationState.code === 'not_found') { + return statusPanel( + 'The selected conversation is unavailable. Choose another exact conversation.', + 'alert', + ); + } return statusPanel(formatError(conversationState.code), 'alert', action); } if (messagesState.status === 'error') { @@ -873,6 +996,12 @@ export function ChatShell({ {messagesState.items.map((message) => (
  • { + if (exactKey) memory.anchors.set(exactKey, message.id); + }} className={`chat-shell__message chat-shell__message--${message.role}`} >
    @@ -883,6 +1012,14 @@ export function ChatShell({

    {message.text}

    + {writer?.sideConversations?.custody === 'local-only' && + (message as LocalMessage).localImport ? ( +

    + Reviewed local excerpt · Source note{' '} + {(message as LocalMessage).localImport?.sideConversationId}. Content only, not + execution or approval. +

    + ) : null}
  • ))} @@ -920,9 +1057,17 @@ export function ChatShell({ disabled={familiars.length === 0} value={selectedFamiliarId ?? ''} onChange={(event) => { - setSelectedFamiliarId(event.target.value || null); + selectFamiliar(event.target.value || null); }} > + {selectedFamiliarId !== null && currentFamiliar === null ? ( + + ) : null} {familiars.map((familiar) => (
    @@ -1007,13 +1218,70 @@ export function ChatShell({ {canWrite ? 'Local chat' : 'Read-only chat'} -
    {renderThreadBody()}
    +
    + {!canWrite ? ( +

    + Import provenance is unavailable through this installed SDK. Messages are displayed as + read-only text. +

    + ) : null} + {positionNotice ? {positionNotice} : null} + {selectedConversationId && + conversationState.status === 'ready' && + messagesState.status === 'ready' ? ( + selectConversation(id, false)} + onWritten={onWritten} + memory={memory} + familiarId={selectedFamiliarId ?? ''} + /> + ) : null} + {canWrite && + writer?.sideConversations?.custody === 'local-only' && + selectedConversationId && + selectedFamiliarId && + conversationState.status === 'error' && + conversationState.code === 'not_found' ? ( + + ) : null} + {selectedConversationId && messagesState.status === 'ready' ? ( + + ) : null} + {renderThreadBody()} +
    {canWrite ? ( ) : null} diff --git a/src/chat-side-conversations.tsx b/src/chat-side-conversations.tsx new file mode 100644 index 00000000..00fc245a --- /dev/null +++ b/src/chat-side-conversations.tsx @@ -0,0 +1,632 @@ +import type { CaveConversationMessage } from '@opencoven/cave-client/managed'; +import { useEffect, useId, useMemo, useRef, useState, useSyncExternalStore } from 'react'; +import { + type ContinuityMemory, + createSideReview, + sideCreationMemory, + sideReviewMemory, +} from './lib/chat-continuity'; +import type { ChatWriter, WriteResult } from './lib/local/chat-writer'; +import type { BringBackSelectionInput, SideConversation } from './lib/local/side-conversations'; +import { createManualPageWalk } from './lib/sdk/manual-page-walk'; + +type Props = Readonly<{ + conversationId: string; + messages: readonly CaveConversationMessage[]; + hasMoreMessages: boolean; + writer: ChatWriter | null; + isDurable: boolean; + onNavigate: (id: string) => void; + onWritten: () => void; + memory: ContinuityMemory; + familiarId: string; + metadataRevision?: number; +}>; + +const failureGuidance = { + read: 'Local side notes could not be read. Retry the read.', + create: + 'The side note creation result could not be confirmed. Retry with the same operation key.', + state: 'The note state change could not be confirmed. Refresh the note before retrying.', + prepare: 'The local review could not be prepared. Retry when the source is available.', + review: + 'The import result could not be confirmed. Retry the unchanged review with the same operation key.', +}; +const MISSING_REVIEW_NOTICE = + 'The exact parent, side note, or selected message is unavailable. This request did not commit an import. Choose available messages, or copy the excerpt before canceling if the note is unavailable.'; + +function failure( + result: Exclude, { status: 'ok' }>, + context: keyof typeof failureGuidance, +): string { + if (result.status === 'unsupported') return result.reason; + if (result.code === 'conflict' && context === 'review') + return 'This operation conflicts with its earlier request. Keep this review and retry unchanged to reconcile; it may already have committed.'; + if (result.code === 'not_found') + return 'The exact parent, side note, or selected message is unavailable.'; + return failureGuidance[context]; +} + +export function ChatSideConversations({ + conversationId, + messages, + hasMoreMessages, + writer, + isDurable, + onNavigate, + onWritten, + memory, + familiarId, + metadataRevision = 0, +}: Props) { + const capability = writer?.canWrite() ? writer.sideConversations : undefined; + const [side, setSide] = useState(null); + const [ready, setReady] = useState(false); + const [loadError, setLoadError] = useState(''); + const [loadAttempt, setLoadAttempt] = useState(0); + const [notes, setNotes] = useState([]); + const [cursor, setCursor] = useState(); + const [notice, setNotice] = useState(''); + const [operationBusy, setBusy] = useState(false); + const [discarding, setDiscarding] = useState(false); + const emptyReview = useMemo(createSideReview, []); + const reviewEntry = side + ? sideReviewMemory(memory, familiarId, side.side.parentConversationId, side.id) + : emptyReview; + const { + selected, + review, + phase, + notice: reviewNotice, + writes, + } = useSyncExternalStore(reviewEntry.subscribe, reviewEntry.getSnapshot); + const creationEntry = sideCreationMemory(memory, familiarId, conversationId); + const creation = useSyncExternalStore(creationEntry.subscribe, creationEntry.getSnapshot); + const creationOwner = useRef(creationEntry); + creationOwner.current = creationEntry; + const observedCreation = useRef({ creationEntry, writes: creation.writes }); + useEffect(() => { + const changed = + observedCreation.current.creationEntry === creationEntry && + observedCreation.current.writes !== creation.writes; + observedCreation.current = { creationEntry, writes: creation.writes }; + if (changed) onWritten(); + }, [creationEntry, creation.writes, onWritten]); + const busy = operationBusy || creation.pending || phase === 'preparing' || phase === 'sending'; + const observed = useRef({ reviewEntry, writes }); + useEffect(() => { + const changed = + observed.current.reviewEntry === reviewEntry && observed.current.writes !== writes; + observed.current = { reviewEntry, writes }; + if (changed) onWritten(); + }, [reviewEntry, writes, onWritten]); + const active = useRef(false); + const readGeneration = useRef(0); + const pending = useRef<{ context: 'read' | 'state'; generation: number } | null>(null); + const walk = useRef(createManualPageWalk()); + const reviewId = useId(); + + // biome-ignore lint/correctness/useExhaustiveDependencies: loadAttempt and metadataRevision explicitly refresh this scoped read. + useEffect(() => { + active.current = true; + readGeneration.current += 1; + // A write can trigger this refresh before its acknowledgement arrives. + if (pending.current?.context === 'read') { + pending.current = null; + setBusy(false); + } + setReady(false); + setLoadError(''); + setNotice(''); + const alive = { value: true }; + if (!capability) + return () => { + active.current = false; + }; + void (async () => { + try { + const info = await capability.get(conversationId); + if (!alive.value) return; + if (info.status !== 'ok') { + setLoadError(failure(info, 'read')); + return; + } + setSide(info.data); + if (!info.data) { + const page = await capability.list(conversationId); + if (!alive.value) return; + if (page.status !== 'ok') { + setLoadError(failure(page, 'read')); + return; + } + if (!walk.current.acceptRootPage(page.data)) { + setLoadError('The side note page is invalid. Retry to refresh.'); + return; + } + setNotes(page.data.data); + setCursor(page.data.cursor?.hasMore ? page.data.cursor.next : undefined); + } + setReady(true); + } catch { + if (alive.value) setLoadError(failureGuidance.read); + } + })(); + return () => { + alive.value = false; + active.current = false; + }; + }, [capability, conversationId, loadAttempt, metadataRevision]); + + async function run( + context: 'read' | 'state', + operation: () => Promise>, + success: (data: T) => void, + ) { + if (pending.current) return; + const request = { context, generation: readGeneration.current }; + pending.current = request; + const ownsRequest = () => + active.current && + pending.current === request && + (context === 'state' || readGeneration.current === request.generation); + setBusy(true); + setNotice(''); + try { + const result = await operation(); + if (!ownsRequest()) return; + if (result.status === 'ok') success(result.data); + else setNotice(failure(result, context)); + } catch { + if (ownsRequest()) setNotice(failureGuidance[context]); + } finally { + if (pending.current === request) { + pending.current = null; + if (active.current) setBusy(false); + } + } + } + + async function startReview() { + const snapshot = reviewEntry.getSnapshot(); + if ( + !capability || + !side || + busy || + snapshot.phase === 'preparing' || + snapshot.phase === 'sending' || + (snapshot.review && snapshot.phase !== 'rejected' && snapshot.phase !== 'reselecting') + ) + return; + const previous = + snapshot.phase === 'rejected' || snapshot.phase === 'reselecting' ? snapshot.review : null; + const source = messages.filter((message) => snapshot.selected.includes(message.id)); + const loadedIds = new Set(source.map((message) => message.id)); + if ( + (!previous || snapshot.phase === 'reselecting') && + (snapshot.selected.length === 0 || snapshot.selected.some((id) => !loadedIds.has(id))) + ) { + reviewEntry.update({ + notice: snapshot.selected.length + ? 'Selected messages are not all loaded. Use Load more messages, or clear the selection and select available messages. Your selection and any edited excerpt are retained.' + : 'Select messages before preparing a review.', + }); + return; + } + const input: BringBackSelectionInput = { + parentConversationId: side.side.parentConversationId, + sideConversationId: side.id, + sourceMessageIds: + snapshot.phase === 'reselecting' + ? source.map((message) => message.id) + : (previous?.sourceMessageIds ?? source.map((message) => message.id)), + excerpt: + previous?.excerpt ?? + source + .map((message) => message.text) + .join('\n\n') + .slice(0, 32_000), + operationKey: crypto.randomUUID(), + }; + reviewEntry.update({ phase: 'preparing', notice: '', selected: input.sourceMessageIds }); + try { + const result = await capability.prepareBringBack(input); + if (result.status === 'ok') { + reviewEntry.update({ review: { ...input, preconditions: result.data }, phase: 'editing' }); + } else if (previous && result.status === 'error' && result.code === 'not_found') { + reviewEntry.update({ phase: 'unavailable', notice: MISSING_REVIEW_NOTICE }); + } else { + reviewEntry.update({ + phase: previous ? snapshot.phase : 'idle', + notice: failure(result, 'prepare'), + }); + } + } catch { + reviewEntry.update({ + phase: previous ? snapshot.phase : 'idle', + notice: 'The local branch could not be read. No import was attempted.', + }); + } + } + + async function submitReview() { + const snapshot = reviewEntry.getSnapshot(); + const input = snapshot.review; + if ( + !capability || + !input?.excerpt.trim() || + (snapshot.phase !== 'editing' && snapshot.phase !== 'uncertain') + ) + return; + reviewEntry.update({ phase: 'sending', notice: '' }); + try { + const result = await capability.bringBack(input); + if (result.status === 'ok' && result.data.conversationId === input.parentConversationId) { + reviewEntry.update({ + review: null, + selected: [], + phase: 'idle', + writes: reviewEntry.getSnapshot().writes + 1, + notice: + 'Reviewed excerpt saved once to the exact local parent. The side note is still retained.', + }); + } else if (result.status === 'error' && result.code === 'stale_review') { + reviewEntry.update({ + phase: 'rejected', + notice: + 'The reviewed local branch changed. This request did not commit. Review again with a fresh operation key before importing.', + }); + } else if (result.status === 'error' && result.code === 'not_found') { + reviewEntry.update({ phase: 'unavailable', notice: MISSING_REVIEW_NOTICE }); + } else { + reviewEntry.update({ + phase: 'uncertain', + notice: + result.status === 'ok' + ? 'The receipt did not match the exact parent. Retry to reconcile.' + : failure(result, 'review'), + }); + } + } catch { + reviewEntry.update({ + phase: 'uncertain', + notice: failureGuidance.review, + }); + } + } + + if (!capability) { + return ( +

    + Side chats and Bring back are unavailable for this source. No local fallback or Cave writes. +

    + ); + } + + async function create() { + const snapshot = creationEntry.getSnapshot(); + if (!capability || snapshot.pending) return; + const operationKey = snapshot.operationKey ?? crypto.randomUUID(); + creationEntry.update({ operationKey, pending: true, notice: '' }); + let result: WriteResult; + try { + result = await capability.create({ parentConversationId: conversationId, operationKey }); + } catch { + if (creationEntry.getSnapshot().operationKey === operationKey) { + creationEntry.update({ pending: false, notice: failureGuidance.create }); + } + return; + } + if (creationEntry.getSnapshot().operationKey !== operationKey) return; + if (result.status !== 'ok') { + creationEntry.update({ pending: false, notice: failure(result, 'create') }); + return; + } + const writes = creationEntry.getSnapshot().writes + 1; + const current = active.current && creationOwner.current === creationEntry; + if (current) observedCreation.current = { creationEntry, writes }; + creationEntry.update({ + operationKey: null, + pending: false, + writes, + notice: + result.data.side.state === 'discarded' + ? 'This creation request refers to a discarded note.' + : 'Retained side note creation confirmed. The note is listed under its exact parent.', + }); + if (current) { + onWritten(); + if (result.data.side.state !== 'discarded') onNavigate(result.data.id); + } + } + + function changeState(state: 'open' | 'closed' | 'discarded') { + if (!capability || !side) return; + if (state === 'discarded' && reviewEntry.getSnapshot().review) { + setNotice('Resolve the current review before discarding its source note.'); + return; + } + void run( + 'state', + () => + capability.setState( + { + parentConversationId: side.side.parentConversationId, + sideConversationId: side.id, + }, + state, + ), + (updated) => { + setSide(updated); + onWritten(); + if (state !== 'open') onNavigate(updated.side.parentConversationId); + }, + ); + } + + return ( +
    +

    + Local-only notes · No connected familiar · No model replies +

    +

    + {isDurable + ? 'Retained on this device. Closing keeps the note; discard removes its local messages.' + : 'Storage is unavailable. Notes and imports remain in memory only and are lost when the app closes.'}{' '} + No network, execution, or memory writeback. Not Cave-backed. +

    + {!ready ? ( + <> + + {loadError || 'Loading local side notes…'} + + {loadError ? ( + + ) : null} + + ) : side ? ( + <> +
    + + {side.side.state === 'closed' ? ( + + ) : ( + + )} + +
    + {discarding ? ( +
    +

    + Discard this note’s local messages? Reviewed excerpts already brought back stay in + the parent. +

    + + +
    + ) : null} + {messages.length ? ( +
    + Select messages for a reviewed excerpt + {messages.map((message, index) => ( + + ))} + {hasMoreMessages ? ( +

    Only loaded messages are selectable. Load more in the transcript below.

    + ) : null} + + {selected.length > 0 && (!review || phase === 'reselecting') ? ( + + ) : null} +
    + ) : null} + {review ? ( +
    { + event.preventDefault(); + void submitReview(); + }} + > + +