diff --git a/apps/desktop/e2e/partial-history-notice.spec.ts b/apps/desktop/e2e/partial-history-notice.spec.ts index 062170417d5..fb70d0a9404 100644 --- a/apps/desktop/e2e/partial-history-notice.spec.ts +++ b/apps/desktop/e2e/partial-history-notice.spec.ts @@ -58,25 +58,29 @@ async function wheelToTop(page: Page): Promise { await frames(page, 4); } -test('a transcript over the history budget loads earlier Turns only on request', async ({ +test('a transcript over the history budget loads earlier Turns on upward reading', async ({ partialHistoryWindow: page, }) => { test.setTimeout(90_000); await page.setViewportSize({ width: 1_400, height: 800 }); - const loadEarlier = page.getByRole('button', { name: '载入更早的记录' }); - const pendingLoad = page.locator('button:disabled', { hasText: '载入更早的记录' }); + const transcript = page.locator('.maka-chat-message-list'); const ticks = page.locator(TICK); + const loadedTurnCount = async (): Promise => + Number(await transcript.getAttribute('data-turn-source-count')); await expect(page.locator(`[data-turn-id="turn-partial-history-${PARTIAL_HISTORY_TURN_COUNT}"]`)).toBeVisible(); // The Host Turn index lists the whole Session before its history is loaded. await expect(ticks).toHaveCount(PARTIAL_HISTORY_TURN_COUNT); - await expect(loadEarlier).toHaveCount(1); + let loadedTurns = await loadedTurnCount(); + expect(loadedTurns).toBeLessThan(PARTIAL_HISTORY_TURN_COUNT); + await frames(page, 4); + expect(await loadedTurnCount()).toBe(loadedTurns); let loads = 0; - while ((await loadEarlier.count()) > 0) { + while (loadedTurns < PARTIAL_HISTORY_TURN_COUNT) { await wheelToTop(page); - await loadEarlier.click(); - await expect(pendingLoad).toHaveCount(0, { timeout: 30_000 }); + await expect.poll(loadedTurnCount, { timeout: 30_000 }).toBeGreaterThan(loadedTurns); + loadedTurns = await loadedTurnCount(); loads += 1; expect(loads).toBeLessThan(PARTIAL_HISTORY_TURN_COUNT); } diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 6f8bdb00d8b..8a9005600f8 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -547,7 +547,7 @@ "react": 1 }, "importSpecifiers": 7, - "nonTriviaTokens": 2158 + "nonTriviaTokens": 2143 }, "src/renderer/app-shell-copy.ts": { "importDeclarations": 1, @@ -731,7 +731,7 @@ "react": 1 }, "importSpecifiers": 43, - "nonTriviaTokens": 7290 + "nonTriviaTokens": 7285 }, "src/renderer/use-app-shell-session-list.ts": { "importDeclarations": 3, diff --git a/apps/desktop/src/main/__tests__/app-shell-command-actions.test.ts b/apps/desktop/src/main/__tests__/app-shell-command-actions.test.ts index 0ccca513a2b..a892319d6cd 100644 --- a/apps/desktop/src/main/__tests__/app-shell-command-actions.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-command-actions.test.ts @@ -20,7 +20,9 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { + buildAppShellCommandList, resolveManualDiagnosticTarget, + type AppShellCommandListOptions, } from '../../renderer/app-shell-command-actions.js'; import { contextCompactionNotice, @@ -69,6 +71,102 @@ test('targets manual diagnostics to the current task or new-task Host profile', ); }); +test('conversation copy and save wait for the complete transcript', async (t) => { + const originalNavigator = Object.getOwnPropertyDescriptor(globalThis, 'navigator'); + const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); + let clipboard = ''; + let clipboardFailure = false; + let saved = ''; + const errors: Array<[string, string]> = []; + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: { clipboard: { writeText: async (value: string) => { + if (clipboardFailure) throw new Error('clipboard denied'); + clipboard = value; + } } }, + }); + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { + maka: { + sessions: { + saveConversationToFile: async ({ markdown }: { markdown: string }) => { + saved = markdown; + return { ok: true }; + }, + }, + }, + }, + }); + t.after(() => { + if (originalNavigator) Object.defineProperty(globalThis, 'navigator', originalNavigator); + else Reflect.deleteProperty(globalThis, 'navigator'); + if (originalWindow) Object.defineProperty(globalThis, 'window', originalWindow); + else Reflect.deleteProperty(globalThis, 'window'); + }); + + const requested: string[] = []; + const options = { + uiLocale: 'en', + activeId: 'session-1', + activePermissionMode: undefined, + canSetPermissionMode: true, + clientPathsAccessible: false, + connections: [], + defaultConnection: null, + renderCompleteConversation: async (sessionId: string) => { + requested.push(sessionId); + return 'oldest question\nnewest answer'; + }, + paletteActions: { + saveConversationToFile: async ({ markdown }: { markdown: string }) => { + saved = markdown; + return { ok: true }; + }, + }, + newTaskProfileId: undefined, + settingsOpen: false, + settingsProfileId: undefined, + sessionCatalog: { + getState: () => ({ sessions: [{ id: 'session-1', name: 'Long task' }] }), + }, + themePref: 'auto', + hiddenSessionIds: new Set(), + captureComposerImportOwner: () => ({ sessionId: 'session-1', navSection: 'sessions' }), + createSession() {}, + openSideConversation() {}, + openHelp() {}, + openScheduledTaskCreate() {}, + openProjectFolder: async () => {}, + openSessionInChat() {}, + openSettings() {}, + openSettingsSection() {}, + openWorkspaceFolder: async () => {}, + refreshConnections: async () => {}, + copyTodayDailyReview: async () => {}, + pasteTodayDailyReview: async () => {}, + saveTodayDailyReview: async () => {}, + setNavSelection() {}, + setPermissionMode: async () => true, + setThemePref() {}, + toastApi: { success() {}, info() {}, error(title: string, description: string) { errors.push([title, description]); } }, + } as unknown as AppShellCommandListOptions; + const commands = buildAppShellCommandList({ current: options }); + + await commands.find(({ id }) => id === 'diag:export-conversation')?.run(); + await commands.find(({ id }) => id === 'diag:save-conversation-file')?.run(); + + assert.deepEqual(requested, ['session-1', 'session-1']); + assert.match(clipboard, /oldest question/); + assert.match(clipboard, /newest answer/); + assert.match(saved, /oldest question/); + assert.match(saved, /newest answer/); + + clipboardFailure = true; + await commands.find(({ id }) => id === 'diag:export-conversation')?.run(); + assert.deepEqual(errors, [['Copy failed', 'Clipboard unavailable']]); +}); + test('presents every successful-frame context compaction outcome', () => { assert.deepEqual(contextCompactionNotice({ kind: 'compacted', checkpointId: 'checkpoint-1' }, 'en'), { level: 'success', diff --git a/apps/desktop/src/main/__tests__/app-shell-command-options.ts b/apps/desktop/src/main/__tests__/app-shell-command-options.ts index bae422a7f86..300677c17c8 100644 --- a/apps/desktop/src/main/__tests__/app-shell-command-options.ts +++ b/apps/desktop/src/main/__tests__/app-shell-command-options.ts @@ -42,7 +42,7 @@ export function appShellCommandOptions( clientPathsAccessible: false, connections: [], defaultConnection: null, - renderPublishedConversation: () => '', + renderCompleteConversation: async () => '', newTaskProfileId: 'new-task-profile', settingsOpen: false, settingsProfileId: undefined, @@ -71,10 +71,11 @@ export function appShellCommandOptions( setPermissionMode: async () => true, setThemePref() {}, toastApi: { - success: (title) => toasts.push(`success:${title}`), - info() {}, + toast: () => '', warning: () => '', confirm: async () => false, dismiss() {}, + success: (title) => String(toasts.push(`success:${title}`)), + info: () => '', error: (title, description, _details, target) => - toasts.push(`error:${title}:${description}:${JSON.stringify(target)}`), + String(toasts.push(`error:${title}:${description}:${JSON.stringify(target)}`)), }, ...overrides, }; diff --git a/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts b/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts index 706c0c583e6..c9a3168c2a9 100644 --- a/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts @@ -699,6 +699,26 @@ describe('composer first-send cleanup', () => { assert.equal(await actions.refreshMessages('session', { requiredAssistantMessageId: 'answer' }), true); }); + it('a parked reader settles only after durable proof without publishing the unseen tail', async () => { + const catalog = createSessionCatalogController(); + const workspace = createConversationWorkspace(catalog, stubConversationServices().observation); + workspace.commands.setActiveId('session'); + let durable = false; + const controller = { + ready: async () => {}, + waitForDurableMessage: async () => durable, + store: { + snapshot: () => ({ sessionId: 'session', messages: [], hasNewer: true }), + hasDurableMessage: () => false, + }, + } as unknown as DesktopTranscriptRangeController; + workspace.transcriptRangeRef.current = controller; + const actions = createTranscriptCommands(workspace, { current: { locale: 'en', toast: { error: () => '' } } }); + assert.equal(await actions.refreshMessages('session', { requiredAssistantMessageId: 'answer' }), false); + durable = true; + assert.equal(await actions.refreshMessages('session', { requiredAssistantMessageId: 'answer' }), true); + }); + it('an in-flight refresh reads publication that commits after the call began', async () => { const catalog = createSessionCatalogController(); const workspace = createConversationWorkspace(catalog, stubConversationServices().observation); diff --git a/apps/desktop/src/main/__tests__/command-palette-desktop-actions.test.ts b/apps/desktop/src/main/__tests__/command-palette-desktop-actions.test.ts index b1e9439d8d6..357cc7403da 100644 --- a/apps/desktop/src/main/__tests__/command-palette-desktop-actions.test.ts +++ b/apps/desktop/src/main/__tests__/command-palette-desktop-actions.test.ts @@ -135,8 +135,8 @@ describe('command palette Desktop actions', () => { const exports: unknown[][] = []; const options = appShellCommandOptions(toasts, { paletteActions: palette, - renderPublishedConversation: (sessionName, locale) => { - exports.push([sessionName, locale]); + renderCompleteConversation: async (sessionId, sessionName, locale) => { + exports.push([sessionId, sessionName, locale]); return '# Long task export'; }, }); @@ -150,7 +150,7 @@ describe('command palette Desktop actions', () => { assert.equal(name, 'saveConversationToFile'); assert.deepEqual(Object.keys(input as object), ['markdown', 'defaultName']); assert.equal((input as { markdown: string }).markdown, '# Long task export', 'Save writes the Conversation-owned export'); - assert.deepEqual(exports, Array(3).fill(['Long task', 'en'])); + assert.deepEqual(exports, Array(3).fill(['session-1', 'Long task', 'en'])); assert.match((input as { defaultName: string }).defaultName, /^maka-Long-task-\d{4}-\d{2}-\d{2}\.md$/); assert.equal(toasts.length, 2); assert.equal(toasts[0], `success:${copy.conversationSavedTitle}`); diff --git a/apps/desktop/src/main/__tests__/composer-submission-owner.test.ts b/apps/desktop/src/main/__tests__/composer-submission-owner.test.ts index 25ef3c0e50c..f76b2fd6887 100644 --- a/apps/desktop/src/main/__tests__/composer-submission-owner.test.ts +++ b/apps/desktop/src/main/__tests__/composer-submission-owner.test.ts @@ -109,6 +109,7 @@ interface RegionProps { } interface TranscriptProps { + hasLaterHistory?: boolean; deriveTurnPresentation(turns: readonly TurnViewModel[]): TurnPresentation; safeResumeAction?: { pending: boolean; detail: string | undefined; onResume(): void }; activeTurn: { turnId: string; awaitingInput: boolean; compacting: boolean } | undefined; @@ -149,6 +150,8 @@ function harness(options: { sharedSessionActive?: boolean; ownerSessionId?: string; listMessages?: ReturnType['listMessages']; + hasNewer?: boolean; + showLatest?: (sessionId: string) => Promise; resume?: ReturnType['resume']; cancelMessage?: ReturnType['cancelMessage']; releaseRecoveryAttachments?: ReturnType['releaseRecoveryAttachments']; @@ -174,20 +177,28 @@ function harness(options: { conversationServices.observation.openTranscript = (sessionId) => { let messages: StoredMessage[] = []; let ready = false; + let hasNewer = options.hasNewer ?? false; const listeners = new Set<() => void>(); published.push((next) => { messages = next; ready = true; for (const listener of listeners) listener(); }); return { store: { - range: () => ({ sessionId, hasOlder: false, ready, generation: 'range' }), + range: () => ({ sessionId, hasOlder: false, hasNewer, ready, generation: 'range' }), snapshot: () => { if (!ready) throw new Error('Desktop transcript range is not initialized'); - return { sessionId, messages, ready }; + return { sessionId, messages, hasNewer, ready }; }, subscribe(listener) { listeners.add(listener); return () => { listeners.delete(listener); }; }, hasDurableMessage: (id) => messages.some((message) => message.id === id), }, ready: async () => {}, waitForDurableMessage: async () => true, reload: async () => {}, holdsCachedTranscript: () => false, loadEarlier: async () => {}, observationChanged: () => {}, + loadNewer: async () => {}, seek: async () => {}, + showLatest: async () => { + await options.showLatest?.(sessionId); + hasNewer = false; + for (const listener of listeners) listener(); + }, + readComplete: async () => messages, close: async () => {}, }; }; @@ -598,6 +609,34 @@ describe('ComposerSubmissionProvider', () => { assert.equal(h.transcript.safeResumeAction?.pending, false); }); + test('Composer Resume follows the latest window before admission without waiting for its read', async () => { + const calls: unknown[] = []; + const latest = deferred(); + const h = harness({ + ownerSessionId: 'A', + hasNewer: true, + showLatest: (sessionId) => { calls.push(['latest', sessionId]); return latest.promise; }, + resume: { + queryPlan: async () => ({ disposition: 'ready' }) as Awaited['resume']['queryPlan']>>, + start: async (sessionId) => { + calls.push(['start', sessionId]); + return { disposition: 'started', runId: 'run-1', turnId: 'turn-2' }; + }, + subscribeChanges: () => () => undefined, + }, + }); + await act(async () => h.target.setActiveId('A')); + await act(async () => h.published[0]!([userTurn('turn-1', 'older window')])); + assert.equal(h.transcript.hasLaterHistory, true); + + await act(async () => h.region.resumeAction!.onResume()); + assert.deepEqual(calls, [['latest', 'A'], ['start', 'A']]); + assert.equal(h.transcript.hasLaterHistory, true, 'resume admission does not wait for history navigation'); + + await act(async () => latest.resolve()); + assert.equal(h.transcript.hasLaterHistory, false, 'the resumed Turn is visible at the session tail'); + }); + test('runs local delivery recovery for the published Session below the owner', async () => { const listed: string[] = []; const reconciled: unknown[] = []; diff --git a/apps/desktop/src/main/__tests__/conversation-compaction.test.ts b/apps/desktop/src/main/__tests__/conversation-compaction.test.ts index be313c9b306..28091e9fe37 100644 --- a/apps/desktop/src/main/__tests__/conversation-compaction.test.ts +++ b/apps/desktop/src/main/__tests__/conversation-compaction.test.ts @@ -152,6 +152,7 @@ describe('Conversation owner context compaction', () => { }, ready: async () => {}, waitForDurableMessage: async () => true, reload: async () => {}, holdsCachedTranscript: () => false, loadEarlier: async () => {}, observationChanged: () => {}, + loadNewer: async () => {}, seek: async () => {}, showLatest: async () => {}, readComplete: async () => [], close: async () => {}, }); services.observation.subscribeEvents = (_sessionId, onEvent, phase) => { diff --git a/apps/desktop/src/main/__tests__/conversation-owner.test.ts b/apps/desktop/src/main/__tests__/conversation-owner.test.ts index 2ec01e1c52a..3ac6d11cf48 100644 --- a/apps/desktop/src/main/__tests__/conversation-owner.test.ts +++ b/apps/desktop/src/main/__tests__/conversation-owner.test.ts @@ -19,8 +19,8 @@ import assert from 'node:assert/strict'; import { afterEach, describe, it } from 'node:test'; -import { act, createElement, Fragment, Profiler, useEffect, useState } from 'react'; -import { LocaleProvider, ToastProvider, type TransientUserMessageProjection } from '@maka/ui'; +import { act, createElement, Fragment, Profiler, useEffect, useState, type ComponentProps } from 'react'; +import { LocaleProvider, ToastProvider, type ChatView, type TransientUserMessageProjection } from '@maka/ui'; import type { StoredMessage } from '@maka/core/session'; import type { UiLocale } from '@maka/core/ui-locale'; import type { DesktopSessionSummary } from '../../shared/desktop-session-projection.js'; @@ -42,6 +42,9 @@ const message = (id: string): StoredMessage => ({ type: 'user', id, text: id, tu function harness(options: { locale?: UiLocale; hasOlder?: boolean; + hasNewer?: boolean; + completeMessages?: readonly StoredMessage[]; + searchTarget?: ComponentProps['searchTarget']; listTurnLandmarks?: ConversationServices['sessions']['listTurnLandmarks']; } = {}) { const { root } = installReactRenderer(); @@ -49,33 +52,39 @@ function harness(options: { catalog.commitSessions(['A', 'B', 'C'].map(row)); const opened: Array<{ sessionId: string; closed: boolean; listeners: Set<() => void>; + initialTurnId?: string; calls: string[]; publish(messages: StoredMessage[]): void; error(error: unknown): void; }> = []; const observations: Array<{ sessionId: string; closed: boolean; phase: Parameters[2]; fail: () => void }> = []; const services = stubConversationServices( options.listTurnLandmarks ? { sessions: { listTurnLandmarks: options.listTurnLandmarks } } : {}, ); - services.observation.openTranscript = (sessionId, error) => { + services.observation.openTranscript = (sessionId, error, initialTurnId) => { let messages: StoredMessage[] = []; let ready = false; const listeners = new Set<() => void>(); const resource = { - sessionId, closed: false, listeners, error, + sessionId, closed: false, listeners, error, initialTurnId, calls: [] as string[], publish(next: StoredMessage[]) { messages = next; ready = true; for (const listener of listeners) listener(); }, }; opened.push(resource); return { store: { - range: () => ({ sessionId, hasOlder: options.hasOlder ?? false, ready, generation: 'range' }), + range: () => ({ sessionId, hasOlder: options.hasOlder ?? false, hasNewer: options.hasNewer ?? false, ready, generation: 'range' }), snapshot: () => { if (!ready) throw new Error('Desktop transcript range is not initialized'); - return { sessionId, messages, ready }; + return { sessionId, messages, ready, hasNewer: options.hasNewer ?? false }; }, subscribe(listener) { listeners.add(listener); return () => { listeners.delete(listener); }; }, hasDurableMessage: (id) => messages.some((message) => message.id === id), }, ready: async () => {}, waitForDurableMessage: async () => true, - reload: async () => {}, holdsCachedTranscript: () => false, loadEarlier: async () => {}, observationChanged: () => {}, + reload: async () => {}, holdsCachedTranscript: () => false, observationChanged: () => {}, + loadEarlier: async () => { resource.calls.push('earlier'); }, + loadNewer: async () => { resource.calls.push('newer'); }, + seek: async (sequence) => { resource.calls.push(`seek:${sequence}`); }, + showLatest: async () => { resource.calls.push('latest'); }, + readComplete: async () => { resource.calls.push('complete'); return options.completeMessages ?? messages; }, close: async () => { resource.closed = true; }, }; }; @@ -88,7 +97,8 @@ function harness(options: { }; let owner!: ReturnType; let target!: ReturnType; - let transcript: { activeSessionId: string | undefined; messages: StoredMessage[]; liveContentSeedGeneration: number; onReadingAnchorChange?: (turnId?: string) => void } | undefined; + let transcript: ({ activeSessionId: string | undefined; messages: StoredMessage[]; liveContentSeedGeneration: number } + & Pick, 'hasEarlierHistory' | 'hasLaterHistory' | 'onLoadEarlierHistory' | 'onLoadLaterHistory' | 'onLoadLatestHistory' | 'onLoadTranscriptTurn' | 'onReadingAnchorChange'>) | undefined; let shellRenders = 0; let transcriptRenders = 0; let composerRenders = 0; @@ -115,7 +125,7 @@ function harness(options: { createElement(Profiler, { id: 'conversation-lifecycle', onRender: () => { lifecycleCommits += 1; } }, createElement(ConversationLifecycle, { refreshSessions: async () => [], onExecutionBoundaryChanged() {}, showModelSetupToast() {}, onTurnCompleted() {}, - searchTarget: null, clearSearchTarget() {}, + searchTarget: options.searchTarget ?? null, clearSearchTarget() {}, })), visible ? createElement(ConversationTranscriptRegion[0]>, { surface: Transcript, localInteractionAvailable: true, visible: !covered }) : null, createElement(ConversationComposerRegion[0]>, { surface: Composer, ...stubComposerGateInputs() }), @@ -141,6 +151,48 @@ function harness(options: { describe('Conversation ownership', () => { afterEach(cleanupFakeDom); + for (const [searchTarget, expected] of [ + [null, 'bookmark'], + [{ sessionId: 'A', turnId: 'search' }, 'search'], + [{ sessionId: 'B', turnId: 'other-session' }, 'bookmark'], + ] as const) { + it(`opens the initial window at ${expected} with search target ${searchTarget?.sessionId ?? 'none'}`, async () => { + const h = harness({ hasOlder: true, hasNewer: true, searchTarget }); + await act(async () => { + h.owner.workspace.ui.setTranscriptReadingAnchor('A', { turnId: 'bookmark' }); + h.target.setActiveId('A'); + }); + assert.equal(h.opened[0]!.initialTurnId, expected); + await act(async () => h.opened[0]!.publish([message(expected)])); + await act(async () => h.owner.readingCommands.current?.captureAnchor('next-reading-position')); + assert.equal(h.opened.length, 1, 'recording a reading position retains the observation owner'); + await act(async () => h.root.unmount()); + }); + } + + it('owns paging and complete export without replacing a historical publication', async () => { + const complete = [message('oldest'), message('middle'), message('latest')]; + const h = harness({ hasOlder: true, hasNewer: true, completeMessages: complete }); + await act(async () => h.target.setActiveId('A')); + await act(async () => h.opened[0]!.publish([complete[1]!])); + assert.equal(h.transcript?.hasEarlierHistory, true); + assert.equal(h.transcript?.hasLaterHistory, true); + const before = h.counts; + assert.equal(await h.target.renderCompleteConversation('A', 'Task A', 'en'), renderConversationMarkdown('Task A', complete, 'en')); + assert.deepEqual(h.transcript?.messages, [complete[1]!]); + assert.deepEqual(h.counts, before, 'export leaves the displayed window and its readers unchanged'); + await act(async () => { + await h.transcript?.onLoadEarlierHistory?.(); + await h.transcript?.onLoadLaterHistory?.(); + await h.transcript?.onLoadTranscriptTurn?.({ turnId: 'oldest', sequence: 2 }); + await h.transcript?.onLoadLatestHistory?.(); + }); + assert.deepEqual(h.opened[0]!.calls, ['complete', 'earlier', 'newer', 'seek:2', 'latest']); + assert.equal(h.opened.length, 1); + assert.equal(h.observations.length, 1); + await act(async () => h.root.unmount()); + }); + it('ignores catalog bookkeeping for both requested and displayed rows while retaining lifecycle updates', async () => { const h = harness(); const patchRow = (id: string, patch: Partial) => h.catalog.commitSessions( @@ -188,13 +240,13 @@ describe('Conversation ownership', () => { }); } - it('exports the published range for Copy and Save without handing the shell its messages', async () => { + it('exports complete history for Copy and Save without handing the shell its messages', async () => { const h = harness(); await act(async () => h.target.setActiveId('A')); const published = [message('first'), message('second')]; await act(async () => h.opened[0]!.publish(published)); assert.equal( - h.target.renderPublishedConversation('Task A', 'en'), + await h.target.renderCompleteConversation('A', 'Task A', 'en'), renderConversationMarkdown('Task A', published, 'en'), ); assert.equal('readMessages' in h.target, false, 'the shell has no invocation-time message read'); diff --git a/apps/desktop/src/main/__tests__/desktop-transcript-range-store.test.ts b/apps/desktop/src/main/__tests__/desktop-transcript-range-store.test.ts index 28f7285d0f6..43b557cc758 100644 --- a/apps/desktop/src/main/__tests__/desktop-transcript-range-store.test.ts +++ b/apps/desktop/src/main/__tests__/desktop-transcript-range-store.test.ts @@ -19,6 +19,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; +import { getEventListeners } from 'node:events'; import type { StoredMessage } from '@maka/core/session'; import { deferred, waitFor } from '@maka/core/test-only/async-primitives'; import { SESSION_CONTINUITY_SCHEMA_VERSION } from '@maka/runtime-host/protocol'; @@ -38,6 +39,7 @@ import { createDesktopTranscriptReconnectRecovery, createDesktopTranscriptRangeController, DesktopTranscriptRangeStore, + openDesktopTranscriptHistory, } from '../../renderer/platform/desktop/desktop-transcript-range-store.js'; import { mergeSettledMessages } from '../../renderer/settled-message-merge.js'; import { @@ -1302,6 +1304,228 @@ test('reports each tail watermark the reader reaches once', async () => { await controller.close(); }); +test('leaving an initial bookmark does not report its cancellation or reopen the newer intent', async () => { + const store = transcriptStore(); + const errors: unknown[] = []; + let opens = 0; + const identity = { sessionId: 'session-1', generation: 'live', hostEpoch: 'host-1' }; + const controller = createDesktopTranscriptRangeController(store, async (signal) => { + if (++opens === 1) { + return new Promise((_, reject) => signal.addEventListener('abort', () => reject(new Error('superseded')), { once: true })); + } + for (const batch of encodeDesktopTranscriptSnapshot({ + ...identity, durableThrough: 1, hasOlder: false, beginsAtTurnBoundary: true, + durable: [{ sequence: 1, message: assistantMessage('latest') }], + })) store.accept(batch); + return transcriptHandle(identity); + }, { initialTurnId: 'old', onError: (error) => errors.push(error) }); + controller.observationChanged('ready'); + try { + await controller.showLatest(); + assert.equal(opens, 2); + assert.deepEqual(errors, []); + assert.deepEqual(store.snapshot().messages, [assistantMessage('latest')]); + } finally { + await controller.close(); + } +}); + +test('returning to latest cancels a pending seek even while the old view covers the tail', async () => { + const store = transcriptStore(); + const errors: unknown[] = []; + const identity = { sessionId: 'session-1', generation: 'live', hostEpoch: 'host-1' }; + let seeking: AbortSignal | undefined; + let opens = 0; + const controller = createDesktopTranscriptRangeController(store, async (signal, _floor, position) => { + opens++; + if (position) { + seeking = signal; + return new Promise((_, reject) => signal.addEventListener('abort', () => reject(new Error('superseded')), { once: true })); + } + for (const batch of encodeDesktopTranscriptSnapshot({ + ...identity, durableThrough: 30, hasOlder: true, beginsAtTurnBoundary: true, + durable: [{ sequence: 30, message: assistantMessage('latest') }], + })) store.accept(batch); + return transcriptHandle(identity); + }, { onError: (error) => errors.push(error) }); + try { + await controller.ready(); + const seek = controller.seek(10); + await waitFor(() => seeking !== undefined, { timeoutMs: 1000 }); + assert.equal(store.range().hasNewer, false); + await controller.showLatest(); + await seek; + assert.equal(seeking?.aborted, true); + assert.equal(opens, 3); + assert.deepEqual(errors, []); + } finally { await controller.close(); } +}); + +for (const phase of ['opening', 'waiting'] as const) { + test(`navigation cancels a background durability check without an error while ${phase}`, async () => { + const store = transcriptStore(); + const errors: unknown[] = []; + const identity = { sessionId: 'session-1', generation: 'live', hostEpoch: 'host-1' }; + let detachedSignal: AbortSignal | undefined; + let detachedClosed = false; + const controller = createDesktopTranscriptRangeController(store, async (signal, _floor, position, receive) => { + if (receive) { + detachedSignal = signal; + if (phase === 'opening') { + return new Promise((_, reject) => signal.addEventListener('abort', () => reject(new Error('cancelled')), { once: true })); + } + } + for (const batch of encodeDesktopTranscriptBatches(identity, { + durableThrough: 10, hasOlder: true, hasNewer: Boolean(position), beginsAtTurnBoundary: true, + durable: [{ sequence: 10, message: assistantMessage('held') }], reset: true, ready: true, + })) (receive ?? ((value) => store.accept(value)))({ ...batch, deliverySequence: 1 }); + return transcriptHandle(identity, { async close() { if (receive) detachedClosed = true; } }); + }, { initialTurnId: 'old', onError: (error) => errors.push(error) }); + try { + await controller.ready(); + const result = controller.waitForDurableMessage('future', 50).then( + (value) => ({ value }), (error: unknown) => ({ error }), + ); + await waitFor(() => detachedSignal !== undefined, { timeoutMs: 1000 }); + await controller.showLatest(); + assert.deepEqual(await result, { value: false }); + assert.equal(detachedSignal?.aborted, true); + if (phase === 'waiting') assert.equal(detachedClosed, true); + assert.deepEqual(errors, []); + } finally { await controller.close(); } + }); +} + +test('a real detached durability failure still rejects', async () => { + const store = transcriptStore(); + const failure = new Error('Host read failed'); + const identity = { sessionId: 'session-1', generation: 'live', hostEpoch: 'host-1' }; + const controller = createDesktopTranscriptRangeController(store, async (_signal, _floor, _position, receive) => { + if (receive) throw failure; + for (const batch of encodeDesktopTranscriptBatches(identity, { + durableThrough: 10, hasOlder: true, hasNewer: true, beginsAtTurnBoundary: true, + durable: [{ sequence: 10, message: assistantMessage('held') }], reset: true, ready: true, + })) store.accept(batch); + return transcriptHandle(identity); + }, { initialTurnId: 'old', onError(error) { throw error; } }); + try { + await controller.ready(); + await assert.rejects(controller.waitForDurableMessage('future', 50), (error) => error === failure); + } finally { await controller.close(); } +}); + +test('navigation still rejects a cancelled complete export instead of returning partial history', async () => { + const store = transcriptStore(); + const identity = { sessionId: 'session-1', generation: 'live', hostEpoch: 'host-1' }; + let exporting = false; + const controller = createDesktopTranscriptRangeController(store, async (signal, _floor, position, receive) => { + if (receive) { + exporting = true; + return new Promise((_, reject) => signal.addEventListener('abort', () => reject(new Error('cancelled export')), { once: true })); + } + for (const batch of encodeDesktopTranscriptBatches(identity, { + durableThrough: 10, hasOlder: true, hasNewer: Boolean(position), beginsAtTurnBoundary: true, + durable: [{ sequence: 10, message: assistantMessage('held') }], reset: true, ready: true, + })) store.accept(batch); + return transcriptHandle(identity); + }, { initialTurnId: 'old', onError(error) { throw error; } }); + try { + await controller.ready(); + const rejected = assert.rejects(controller.readComplete(), /cancelled export/); + await waitFor(() => exporting, { timeoutMs: 1000 }); + await controller.showLatest(); + await rejected; + } finally { await controller.close(); } +}); + +for (const navigation of ['seek', 'latest'] as const) { + test(`a failed ${navigation} reports its error and recovers the held window after reconnection`, async () => { + const store = transcriptStore(); + const failure = new Error('Host disconnected during navigation'); + const errors: unknown[] = []; + let opens = 0; + let online = true; + const controller = createDesktopTranscriptRangeController(store, async () => { + if (!online) throw failure; + const identity = { sessionId: 'session-1', generation: `live-${++opens}`, hostEpoch: 'host-1' }; + for (const batch of encodeDesktopTranscriptBatches(identity, { + durableThrough: 10, hasOlder: true, hasNewer: true, beginsAtTurnBoundary: true, + durable: [{ sequence: 10, message: assistantMessage('held window') }], reset: true, ready: true, + })) store.accept(batch); + return transcriptHandle(identity); + }, { initialTurnId: 'old', onError: (error) => errors.push(error) }); + try { + await controller.ready(); + const held = store.snapshot(); + online = false; + await assert.rejects(navigation === 'seek' ? controller.seek(20) : controller.showLatest(), /Host disconnected/); + assert.deepEqual(errors, [failure]); + assert.equal(store.snapshot(), held); + online = true; + controller.observationChanged('pending'); + controller.observationChanged('ready'); + await waitFor(() => store.range().generation === 'live-2', { timeoutMs: 1000 }); + assert.deepEqual(store.snapshot().messages, held.messages); + assert.equal(store.range().hasNewer, true); + assert.deepEqual(errors, [failure]); + } finally { + await controller.close(); + } + }); +} + +test('a complete visible transcript exports without opening another consumer', async () => { + const store = transcriptStore(); + const identity = { sessionId: 'session-1', generation: 'live', hostEpoch: 'host-1' }; + let opens = 0; + const controller = createDesktopTranscriptRangeController(store, async () => { + opens++; + for (const batch of encodeDesktopTranscriptSnapshot({ + ...identity, durableThrough: 1, hasOlder: false, beginsAtTurnBoundary: true, + durable: [{ sequence: 1, message: assistantMessage('complete') }], + })) store.accept(batch); + return transcriptHandle(identity); + }, { onError(error) { throw error; } }); + try { + await controller.ready(); + assert.equal(await controller.readComplete(), store.snapshot().messages); + assert.equal(opens, 1); + } finally { + await controller.close(); + } +}); + +test('complete reads release their cancellation listeners without retaining the detached transcript', async () => { + const store = transcriptStore(); + const signals: AbortSignal[] = []; + const identity = { sessionId: 'session-1', generation: 'live', hostEpoch: 'host-1' }; + const open = openDesktopTranscriptHistory(async (_sessionId, receive, registerCancellation, _mode, resumeFrom) => { + registerCancellation?.(() => {}); + for (const batch of encodeDesktopTranscriptSnapshot({ + ...identity, durableThrough: 1, hasOlder: resumeFrom !== 0, beginsAtTurnBoundary: true, + durable: [{ sequence: 1, message: assistantMessage('complete') }], + })) receive({ ...batch, deliverySequence: 1 }); + return transcriptHandle(identity); + }, store.sessionId, (batch) => store.accept(batch)); + const controller = createDesktopTranscriptRangeController(store, (signal, ...args) => { + signals.push(signal); + return open(signal, ...args); + }, { onError(error) { throw error; } }); + try { + await controller.ready(); + const parent = signals[0]!; + const listeners = getEventListeners(parent, 'abort').length; + for (let i = 0; i < 3; i++) { + assert.deepEqual(await controller.readComplete(), [assistantMessage('complete')]); + assert.equal(getEventListeners(parent, 'abort').length, listeners); + assert.equal(signals.at(-1)!.aborted, true); + assert.equal(parent.aborted, false); + } + } finally { + await controller.close(); + } +}); + test('cancels a transcript open that is still waiting for a Host', async () => { const store = transcriptStore(); let openSignal: AbortSignal | undefined; diff --git a/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts b/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts index 50f08d46019..bd420f862fa 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts @@ -69,6 +69,7 @@ import { RuntimeHostReconnectingIpcMain } from '../runtime-host-reconnecting-ipc import { desktopSessionResourceKey } from '../../shared/runtime-host-identity.js'; import { deferred, waitFor as pollFor } from '@maka/core/test-only/async-primitives'; import { canRepairManagedRuntimeHostStartup } from '../runtime-host-startup-recovery.js'; +import type { DesktopTranscriptBatch } from '../../preload/transcript-contract.js'; import { ManagedArtifactPreview } from '../managed-artifact-preview.js'; import { DesktopSessionLocalStore } from '../session-local-store.js'; import { DesktopSessionLocalService } from '../session-local-service.js'; @@ -357,6 +358,39 @@ test('owns one complete Desktop candidate generation and can restart cleanly', a assert.equal(ipc.size, 0); }); +test('forwards the candidate initial history budget to the Host page request', async (t) => { + const observations = new RuntimeHostSessionObservationRegistry((error) => { throw error; }); + const consumerId = 'budget-reader'; + const ipc = ipcHarness((channel, payload) => { + if (channel !== `sessions:transcript:${consumerId}`) return; + const batch = payload as DesktopTranscriptBatch; + queueMicrotask(() => observations.acknowledgeTranscript(consumerId, batch.generation, batch.deliverySequence, 1)); + }); + const host = connectionHarness('budget'); + const subscribe = host.connection.openSessionSubscription.bind(host.connection); + const budgets: number[] = []; + host.connection.openSessionSubscription = async (...args) => { + const subscription = await subscribe(...args); + const page = { ...subscription.transcriptBootstrap!.durable, throughSequence: 0, endsAtTurnBoundary: true }; + return { + ...subscription, + transcriptWatermark: 0, + transcriptBootstrap: { throughSequence: 0, durable: page }, + async loadTranscriptPage(request) { + budgets.push(request.maxBytes); + return { ...page, direction: request.direction }; + }, + }; + }; + const initialBudget = 1024 * 1024; + const candidate = await createDesktopRuntimeHostCandidate(host.connection, { + ...deps(ipc), transcriptInitialHistoryBytes: initialBudget, transcriptHistoryBytes: 512 * 1024, + }, observations); + t.after(async () => { await candidate.close(); await observations.close(); }); + await ipc.invoke('sessions:transcript:open', 'session-budget', consumerId, 'history'); + assert.deepEqual(budgets, [512 * 1024], 'the 1 MiB opening answer uses the Host page limit, not the 128 KiB default'); +}); + test('Stop after Host restart durably frees a full local outbox through the registered candidate IPC', async (t) => { const local = await stopLocalMessages(t); const cancelledIds = Array.from({ length: 64 }, (_, index) => `cancelled-${index}`); diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts index b520f5ce05b..c4115a07c32 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts @@ -97,17 +97,20 @@ for (const phase of ['connecting', 'seeding'] as const) { } } -test('transcript history IPC forwards open mode, load-earlier, and read-turn to the registry', async () => { +test('transcript history IPC forwards window intent and both page directions to the registry', async () => { const ipc = ipcHarness(); const observations = new RuntimeHostSessionObservationRegistry(); const calls: unknown[] = []; - observations.openTranscript = async (sessionId, consumerId, target, mode) => { - calls.push({ command: 'open', sessionId, consumerId, targetId: target.id, mode }); + observations.openTranscript = async (sessionId, consumerId, target, mode, _resumeFrom, position) => { + calls.push({ command: 'open', sessionId, consumerId, targetId: target.id, mode, position }); return { sessionId, generation: 'generation-1', hostEpoch: 'host-1', readThroughMessageId: null }; }; observations.loadEarlierTranscript = async (consumerId, targetId) => { calls.push({ command: 'earlier', consumerId, targetId }); }; + observations.loadNewerTranscript = async (consumerId, targetId) => { + calls.push({ command: 'newer', consumerId, targetId }); + }; const turn = [{ type: 'user', id: 'message-1', turnId: 'turn-1', ts: 1, text: 'hello' }]; observations.readTranscriptTurn = async (sessionId, turnId) => { calls.push({ command: 'read-turn', sessionId, turnId }); @@ -115,13 +118,15 @@ test('transcript history IPC forwards open mode, load-earlier, and read-turn to }; registerRuntimeHostSessionObservationIpc({ observations, resolveSideConversation: async () => false }, ipc); - await ipc.invoke('sessions:transcript:open', 'shared-session', 'guest-consumer', 'history'); + await ipc.invoke('sessions:transcript:open', 'shared-session', 'guest-consumer', 'history', undefined, { turnId: 'turn-1' }); await ipc.invoke('sessions:transcript:load-earlier', 'guest-consumer'); + await ipc.invoke('sessions:transcript:load-newer', 'guest-consumer'); assert.deepEqual(await ipc.invoke('sessions:transcript:read-turn', 'shared-session', 'turn-1'), turn); assert.equal(ipc.reconnectableChannels.has('sessions:transcript:read-turn'), true); assert.deepEqual(calls, [ - { command: 'open', sessionId: 'shared-session', consumerId: 'guest-consumer', targetId: 9, mode: 'history' }, + { command: 'open', sessionId: 'shared-session', consumerId: 'guest-consumer', targetId: 9, mode: 'history', position: { turnId: 'turn-1' } }, { command: 'earlier', consumerId: 'guest-consumer', targetId: 9 }, + { command: 'newer', consumerId: 'guest-consumer', targetId: 9 }, { command: 'read-turn', sessionId: 'shared-session', turnId: 'turn-1' }, ]); await assert.rejects( @@ -129,7 +134,11 @@ test('transcript history IPC forwards open mode, load-earlier, and read-turn to /Invalid Desktop transcript open mode/, ); await assert.rejects(ipc.invoke('sessions:transcript:load-earlier', ''), /Transcript consumer/); - assert.equal(calls.length, 3); + await assert.rejects(ipc.invoke('sessions:transcript:load-newer', ''), /Transcript consumer/); + for (const position of [{ turnId: '' }, { sequence: -1 }, { sequence: 20, throughSequence: 10 }]) { + await assert.rejects(ipc.invoke('sessions:transcript:open', 'shared-session', 'bad', 'history', undefined, position), /Invalid/); + } + assert.equal(calls.length, 4); }); test('treats pending Session observation teardown as IPC cancellation', async () => { diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts index a87ba03c75c..5a138975683 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts @@ -721,6 +721,7 @@ test('fences earlier transcript failures across same-source replica recovery', a }, emitSessionsChanged() {}, transcriptHistoryBytes: 1, + transcriptInitialHistoryBytes: 1, }); const observations = new RuntimeHostSessionObservationRegistry(); const batches: DesktopTranscriptBatch[] = []; @@ -1345,6 +1346,107 @@ test('coalesces transcript changes into one bounded delta while renderer deliver await observer.close(); }); +test('opens a small recent range and reads larger earlier ranges without losing history', async (t) => { + const rows = Array.from({ length: 24 }, (_, index) => + turnRow((index + 1) * 10, `turn-${index}`, 'x'.repeat(64 * 1024)), + ); + const host = historyHost(rows, { pageRows: 2 }); + const requests: number[] = []; + const events = new AsyncFrameQueue(); + const observer = new RuntimeHostSessionObserver({ + client: { + openSession: async () => runtimeHostSessionFixture({ + snapshot: continuitySnapshot(), events, + transcriptBootstrap: host.bootstrap, + loadTranscriptPage: async (request) => { + requests.push(request.maxBytes); + return host.loadTranscriptPage(request); + }, + decodeTranscriptPage: host.decodeTranscriptPage, + async close() { events.end(); }, + }), + }, + emitSessionsChanged() {}, + transcriptInitialHistoryBytes: 128 * 1024, + transcriptHistoryBytes: 512 * 1024, + }); + t.after(() => observer.close()); + const batches: DesktopTranscriptBatch[] = []; + const consumerId = 'consumer-bounded-open'; + await observer.openTranscript('session-1', consumerId, ackingTranscriptTarget(observer, consumerId, 26, batches), 'history'); + const takeAnswer = () => { + const answer = batches.splice(0); + assert.equal(answer.filter((batch) => batch.ready).length, 1); + assert.equal(answer.at(-1)!.ready, true); + return { + rows: answer.flatMap(durableSequences).sort((left, right) => left - right), + hasOlder: answer.at(-1)!.hasOlder, + earlierThan: answer[0]!.earlierThan, + }; + }; + const initial = takeAnswer(); + assert.deepEqual(initial.rows, [230, 240], 'opening must not replay the entire history'); + assert.equal(initial.hasOlder, true); + assert.ok(requests.every((bytes) => bytes <= 128 * 1024), 'bound Host reads as well as the final answer'); + requests.splice(0); + + await observer.loadEarlierTranscript(consumerId, 26); + const earlier = takeAnswer(); + assert.equal(earlier.earlierThan, initial.rows[0]); + assert.ok(earlier.rows.length > initial.rows.length, 'reading earlier uses its own larger budget'); + assert.equal(earlier.hasOlder, true, 'one earlier read does not automatically fetch everything'); + assert.ok(requests.every((bytes) => bytes <= 512 * 1024)); + const delivered = [...earlier.rows, ...initial.rows]; + let hasOlder: boolean | undefined = earlier.hasOlder; + while (hasOlder) { + await observer.loadEarlierTranscript(consumerId, 26); + const answer = takeAnswer(); + assert.equal(answer.earlierThan, delivered[0]); + delivered.unshift(...answer.rows); + hasOlder = answer.hasOlder; + } + assert.deepEqual(delivered, rows.map((row) => row.identity), 'paging must retain every row exactly once'); +}); + +test('finishes an oversized opening Turn with the regular page budget', async (t) => { + const rows = Array.from({ length: 6 }, (_, index) => turnRow(index + 1, 'oversized')); + const host = historyHost(rows, { pageRows: 2 }); + const requests: number[] = []; + const events = new AsyncFrameQueue(); + const observer = new RuntimeHostSessionObserver({ + client: { + openSession: async () => runtimeHostSessionFixture({ + snapshot: continuitySnapshot(), events, + transcriptBootstrap: host.bootstrap, + loadTranscriptPage: async (request) => { + requests.push(request.maxBytes); + return host.loadTranscriptPage(request); + }, + decodeTranscriptPage: host.decodeTranscriptPage, + async close() { events.end(); }, + }), + }, + emitSessionsChanged() {}, + transcriptInitialHistoryBytes: 1, + transcriptHistoryBytes: 4, + }); + t.after(() => observer.close()); + const batches: DesktopTranscriptBatch[] = []; + const consumerId = 'consumer-oversized-open'; + await observer.openTranscript( + 'session-1', + consumerId, + ackingTranscriptTarget(observer, consumerId, 26, batches), + 'history', + ); + + assert.deepEqual(requests, [1, 4, 4]); + assert.deepEqual( + batches.flatMap(durableSequences).sort((left, right) => left - right), + rows.map((row) => row.identity), + ); +}); + test('delivers history in whole Turns within the budget and continues exactly on load earlier', async () => { const events = new AsyncFrameQueue(); // Oldest first: a (3 rows), b (1 huge row), c (2 rows), d (1 huge row). The budget is one and a half @@ -1376,6 +1478,7 @@ test('delivers history in whole Turns within the budget and continues exactly on }, emitSessionsChanged() {}, transcriptHistoryBytes: Math.floor(smallRowBytes * 1.5), + transcriptInitialHistoryBytes: Math.floor(smallRowBytes * 1.5), }); const batches: DesktopTranscriptBatch[] = []; const consumerId = 'consumer-history'; @@ -1441,6 +1544,7 @@ test('reads earlier history past its budget down to the requested Turn in one an }, emitSessionsChanged() {}, transcriptHistoryBytes: Math.floor(smallRowBytes * 1.5), + transcriptInitialHistoryBytes: Math.floor(smallRowBytes * 1.5), }); const batches: DesktopTranscriptBatch[] = []; const consumerId = 'consumer-located'; @@ -1494,6 +1598,7 @@ test('keeps the history already delivered across a same-session recovery', async }, emitSessionsChanged() {}, transcriptHistoryBytes: Math.floor(smallRowBytes * 1.5), + transcriptInitialHistoryBytes: Math.floor(smallRowBytes * 1.5), }); const batches: DesktopTranscriptBatch[] = []; const consumerId = 'consumer-recovered-history'; @@ -1553,6 +1658,7 @@ test('keeps the history already delivered across a replacement of the Host conne }, emitSessionsChanged() {}, transcriptHistoryBytes: Math.floor(smallRowBytes * 1.5), + transcriptInitialHistoryBytes: Math.floor(smallRowBytes * 1.5), }); const first = sources(); const second = sources(); @@ -1618,6 +1724,7 @@ test('a tail transcript consumer still receives the replica snapshot', async () }, emitSessionsChanged() {}, transcriptHistoryBytes: 1, + transcriptInitialHistoryBytes: 1, }); const batches: DesktopTranscriptBatch[] = []; await observer.openTranscript('session-1', 'consumer-tail', ackingTranscriptTarget(observer, 'consumer-tail', 27, batches)); diff --git a/apps/desktop/src/main/__tests__/streaming-handoff.test.ts b/apps/desktop/src/main/__tests__/streaming-handoff.test.ts index e5106315d3a..1dae023bdb7 100644 --- a/apps/desktop/src/main/__tests__/streaming-handoff.test.ts +++ b/apps/desktop/src/main/__tests__/streaming-handoff.test.ts @@ -81,6 +81,7 @@ function renderLiveTurn(liveTurn: LiveTurnProjection): Promise { permissionMode: 'ask', }, messages: [{ type: 'user', id: 'user-1', turnId: liveTurn.turnId, ts: 1, text: 'go' }], + activeTurn: liveTurn.terminal ? undefined : { turnId: liveTurn.turnId }, scrollBehavior: 'smooth', liveTurns: liveTurn ? [liveTurn] : undefined, onNew() {}, @@ -307,6 +308,7 @@ describe('single live-turn handoff', () => { { type: 'assistant', id: 'assistant-1', turnId: 'turn-1', ts: 2, text, modelId: 'model' }, ], scrollBehavior: 'smooth', + activeTurn: { turnId: 'turn-1' }, liveTurns: [{ turnId: 'turn-1', steps: [{ diff --git a/apps/desktop/src/main/__tests__/transcript-history-read.test.ts b/apps/desktop/src/main/__tests__/transcript-history-read.test.ts index e83b3189668..7e42c5f3ae8 100644 --- a/apps/desktop/src/main/__tests__/transcript-history-read.test.ts +++ b/apps/desktop/src/main/__tests__/transcript-history-read.test.ts @@ -77,7 +77,9 @@ test('keeps both Turns reachable when an oversized ledger Turn is followed by a const history: { sequence: number; message: StoredMessage }[] = []; let cursor: string | null = null; do { - const page = await replica.readOlderPage(completeThrough, cursor); + // Opening uses a smaller Host page than later reading, including when a + // single tool result needs several pages to finish decoding. + const page = await replica.readOlderPage(completeThrough, cursor, PAGE_BYTES); history.unshift(...page.durable); cursor = page.nextCursor; } while (cursor !== null); diff --git a/apps/desktop/src/main/__tests__/transcript-reading-position-controller.test.ts b/apps/desktop/src/main/__tests__/transcript-reading-position-controller.test.ts index aaa4b52747c..57bb341d343 100644 --- a/apps/desktop/src/main/__tests__/transcript-reading-position-controller.test.ts +++ b/apps/desktop/src/main/__tests__/transcript-reading-position-controller.test.ts @@ -33,6 +33,7 @@ import { createAppShellSessionUiStateController } from '../../renderer/features/ import { createTranscriptRestoreLifecycle, prepareTranscriptForSend, + readCompleteTranscript, restoreSessionTranscriptRange, } from '../../renderer/features/conversation/testing.js'; import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; @@ -57,6 +58,37 @@ function handle(overrides: Partial = {}): DesktopTransc }; } +test('complete transcript reads target the beginning and reject provisional ranges', async () => { + const messages = [answer('a'), answer('b')]; + const reads: (number | undefined)[] = []; + let range = { sessionId: SESSION_ID, hasOlder: true, ready: true, generation: 'live-1' }; + const controller = { + store: { + range: () => range, + snapshot: () => ({ messages }), + }, + async loadEarlier(throughSequence?: number) { + reads.push(throughSequence); + range = { ...range, hasOlder: false }; + }, + }; + const controllerRef = { current: controller }; + const sessionRef = { current: SESSION_ID }; + + assert.deepEqual( + await readCompleteTranscript(controllerRef, sessionRef, SESSION_ID), + messages, + ); + assert.deepEqual(reads, [0]); + + range = { ...range, generation: 'cached:host-1' }; + await assert.rejects( + readCompleteTranscript(controllerRef, sessionRef, SESSION_ID), + /complete task transcript is not available/, + ); + assert.deepEqual(reads, [0], 'cached previews cannot be exported as complete history'); +}); + async function restoreFromEarlierHistory(lookupTurn?: (sessionId: string, turnId: string) => Promise) { const store = new DesktopTranscriptRangeStore(SESSION_ID); for (const batch of encodeDesktopTranscriptSnapshot({ @@ -64,16 +96,17 @@ async function restoreFromEarlierHistory(lookupTurn?: (sessionId: string, turnId ...IDENTITY, durableThrough: 30, durable: [{ sequence: 30, message: answer('c') }], hasOlder: true, })) store.accept(batch); const reads: (number | undefined)[] = []; - const controller = createDesktopTranscriptRangeController(store, async () => handle({ - async loadEarlier(throughSequence) { - reads.push(throughSequence); + const controller = createDesktopTranscriptRangeController(store, async (_signal, _resume, position) => { + if (position && 'sequence' in position) { + reads.push(position.sequence); for (const batch of encodeDesktopTranscriptBatches(IDENTITY, { - durableThrough: 30, + durableThrough: 20, durable: [{ sequence: 10, message: answer('a') }, { sequence: 20, message: answer('b') }], - hasOlder: false, earlierThan: 30, reset: false, ready: true, + hasOlder: false, hasNewer: true, reset: true, ready: true, })) store.accept(batch); - }, - }), { onError: (error) => assert.fail(String(error)) }); + } + return handle(); + }, { onError: (error) => assert.fail(String(error)) }); let anchor: { turnId: string } | undefined = { turnId: 'a' }; const unavailable: string[] = []; const lifecycle = createTranscriptRestoreLifecycle(); @@ -97,7 +130,7 @@ async function restoreFromEarlierHistory(lookupTurn?: (sessionId: string, turnId } } -test('a bookmark older than the loaded history is read down to in one request located by the Turn index', async () => { +test('a bookmark opens the indexed window without filling the gap to the tail', async () => { const lookups: string[] = []; const result = await restoreFromEarlierHistory(async (_sessionId, turnId) => { lookups.push(turnId); @@ -105,7 +138,7 @@ test('a bookmark older than the loaded history is read down to in one request lo }); assert.deepEqual(lookups, ['a']); assert.deepEqual(result.reads, [10], 'a restored bookmark reads nothing more'); - assert.deepEqual(result.turns, ['a', 'b', 'c']); + assert.deepEqual(result.turns, ['a', 'b']); assert.deepEqual(result.anchor, { turnId: 'a' }); assert.deepEqual(result.unavailable, []); }); @@ -128,16 +161,17 @@ test('a cached transcript keeps a stored bookmark pending until the live answer })) store.accept(batch); const reads: (number | undefined)[] = []; const lookups: string[] = []; - const controller = createDesktopTranscriptRangeController(store, async () => handle({ - async loadEarlier(throughSequence) { - reads.push(throughSequence); + const controller = createDesktopTranscriptRangeController(store, async (_signal, _resume, position) => { + if (position && 'sequence' in position) { + reads.push(position.sequence); for (const batch of encodeDesktopTranscriptBatches(IDENTITY, { - durableThrough: 30, + durableThrough: 20, durable: [{ sequence: 10, message: answer('a') }, { sequence: 20, message: answer('b') }], - hasOlder: false, earlierThan: 30, reset: false, ready: true, + hasOlder: false, hasNewer: true, reset: true, ready: true, })) store.accept(batch); - }, - }), { onError: (error) => assert.fail(String(error)) }); + } + return handle(); + }, { onError: (error) => assert.fail(String(error)) }); let anchor: { turnId: string } | undefined = { turnId: 'a' }; const unavailable: string[] = []; const lifecycle = createTranscriptRestoreLifecycle(); @@ -170,7 +204,7 @@ test('a cached transcript keeps a stored bookmark pending until the live answer await settle(); assert.deepEqual(lookups, ['a']); assert.deepEqual(reads, [10]); - assert.deepEqual(store.snapshot().messages.map(({ turnId }) => turnId), ['a', 'b', 'c']); + assert.deepEqual(store.snapshot().messages.map(({ turnId }) => turnId), ['a', 'b']); assert.deepEqual(anchor, { turnId: 'a' }); assert.deepEqual(unavailable, []); } finally { diff --git a/apps/desktop/src/main/__tests__/transcript-window.test.ts b/apps/desktop/src/main/__tests__/transcript-window.test.ts new file mode 100644 index 00000000000..d6f941ed3fc --- /dev/null +++ b/apps/desktop/src/main/__tests__/transcript-window.test.ts @@ -0,0 +1,344 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import test, { type TestContext } from 'node:test'; +import { markPersisted } from '@maka/core/persisted-value'; +import { decodeStoredMessage, type StoredMessage } from '@maka/core/session'; +import { waitFor } from '@maka/core/test-only/async-primitives'; +import { SESSION_CONTINUITY_SCHEMA_VERSION } from '@maka/runtime-host/protocol'; +import { ClientSessionSubscription } from '../../../../../packages/runtime-host/dist/client/session-subscription.js'; +import { createSessionTranscriptBootstrap, readSessionTranscriptPage, updateSubscriberTranscriptHighWater } from '../../../../../packages/runtime-host/dist/server/session-transcript-pager.js'; +import { RuntimeHostSessionObserver } from '../runtime-host-session-observer.js'; +import { RuntimeHostSessionObservationRegistry } from '../runtime-host-session-observation-registry.js'; +import { createDesktopTranscriptRangeController, DesktopTranscriptRangeStore } from '../../renderer/platform/desktop/desktop-transcript-range-store.js'; +import type { DesktopTranscriptBatch, DesktopTranscriptHandle, DesktopTranscriptPosition } from '../../preload/transcript-contract.js'; +import { runtimeHostSessionFixture } from './runtime-host-session-test-fixture.js'; +import { openTranscriptLedger } from './transcript-ledger-test-fixture.js'; + +function turn(id: string): StoredMessage[] { + return [ + { type: 'user', id: `user-${id}`, turnId: id, ts: 1, text: `Question ${id}` }, + { type: 'assistant', id: `answer-${id}`, turnId: id, ts: 2, text: `Answer ${id} ${'x'.repeat(400)}`, modelId: 'fixture-model' }, + { type: 'turn_state', id: `done-${id}`, turnId: id, ts: 3, status: 'completed' }, + ]; +} + +async function harness(t: TestContext, source: StoredMessage[], initialTurnId: string, checkpoint = source.at(-1)!.id, initialBytes = 600) { + const ledger = await openTranscriptLedger(source); + await ledger.appendThrough(checkpoint); + const { reader, sessionId } = ledger; + const store = new DesktopTranscriptRangeStore(JSON.stringify(['host-1', sessionId])); + const requests: Parameters[0][] = []; + const markers: string[] = []; + const errors: unknown[] = []; + const observers: RuntimeHostSessionObserver[] = []; + let replicaThrough: number | null = null; + let beforeAcknowledge: ((batch: DesktopTranscriptBatch) => Promise) | undefined; + let currentSubscription: ClientSessionSubscription; + let currentState: Awaited>['state']; + let frameSequence = 1; + const registry = new RuntimeHostSessionObservationRegistry((error) => errors.push(error)); + const newObserver = () => { + const observer = new RuntimeHostSessionObserver({ + client: { + async openSession() { + const subscriptionId = `subscription-${sessionId}`; + const opened = await createSessionTranscriptBootstrap({ + reader, sessionId, subscriptionId, throughSequence: await reader.readDurableHighWater(sessionId), + maxBytes: 1024, projection: 'owner', + }); + currentState = opened.state; + frameSequence = 1; + const subscription = new ClientSessionSubscription({ + hostEpoch: 'host-1', subscriptionId, nextSequence: 1, activeAssistantStreams: [], transcript: opened.bootstrap, + snapshot: { + schemaVersion: SESSION_CONTINUITY_SCHEMA_VERSION, + session: { sessionId, metadataRevision: 1, status: 'active', createdAt: 1, isArchived: false }, + projectionRevision: 1, rootTurn: null, goal: null, + queue: { hostEpoch: 'host-1', queueRevision: 0, steering: [], followup: [] }, interactions: { pending: [] }, + }, + }, async () => {}, async (request) => { + requests.push(request); + return readSessionTranscriptPage({ reader, state: opened.state, request }); + }, async () => {}); + currentSubscription = subscription; + return runtimeHostSessionFixture({ + snapshot: subscription.snapshot, events: subscription, transcriptBootstrap: opened.bootstrap, + transcriptWatermark: () => subscription.transcriptWatermark, + decodeTranscriptPage: (page, maxBytes, accountBytes) => subscription.decodeTranscriptPage( + page, (value) => decodeStoredMessage(markPersisted(value)), maxBytes, accountBytes, + ), + loadTranscriptPage: (request) => subscription.loadTranscriptPage(request), + close: () => subscription.close(), + }); + }, + listSessionTurnLandmarks: async (id, turnId) => ({ + sessionId: id, ...await reader.readDurableTurnLandmarks(id, { turnId: turnId ?? null, maxLandmarks: 100 }), + }), + async setSessionReadMarker(_id, messageId) { + markers.push(messageId); + return undefined as never; + }, + }, + cacheTranscript(snapshot) { replicaThrough = snapshot.durableThrough; }, + emitSessionsChanged() {}, transcriptHistoryBytes: 1200, transcriptInitialHistoryBytes: initialBytes, + }); + observers.push(observer); + return observer; + }; + let observer = newObserver(); + await registry.attach(observer); + let nextConsumer = 0; + const controller = createDesktopTranscriptRangeController(store, async ( + signal: AbortSignal, resumeFrom?: number, position?: DesktopTranscriptPosition, + receive: (batch: DesktopTranscriptBatch) => void = (batch) => { store.accept(batch); }, + ): Promise => { + const consumerId = `window-${++nextConsumer}`; + const cancel = () => { void registry.closeTranscript(consumerId); }; + signal.addEventListener('abort', cancel, { once: true }); + if (signal.aborted) throw new Error('cancelled'); + try { + const opened = await registry.openTranscript(sessionId, consumerId, { + id: 9, once() {}, off() {}, + send(_channel, batch) { + if (!signal.aborted) receive(batch); + queueMicrotask(() => { + void (async () => { + await beforeAcknowledge?.(batch); + if (!signal.aborted) registry.acknowledgeTranscript(consumerId, batch.generation, batch.deliverySequence, 9); + })().catch((error) => errors.push(error)); + }); + }, + }, 'history', resumeFrom, position); + return { + ...opened, + acknowledgeTail: (through) => registry.acknowledgeTranscriptTail({ consumerId, sessionId, hostEpoch: opened.hostEpoch, through }, 9), + loadEarlier: (floor) => registry.loadEarlierTranscript(consumerId, 9, floor), + loadNewer: () => registry.loadNewerTranscript(consumerId, 9), + async close() { signal.removeEventListener('abort', cancel); await registry.closeTranscript(consumerId); }, + }; + } catch (error) { + signal.removeEventListener('abort', cancel); + throw error; + } + }, { initialTurnId, onError: (error) => errors.push(error) }); + t.after(async () => { + await controller.close(); + await registry.close(); + for (const instance of observers) await instance.close(); + await ledger.close(); + }); + await controller.ready(); + return { + store, controller, ledger, requests, markers, errors, + beforeAcknowledge(callback: (batch: DesktopTranscriptBatch) => Promise) { + beforeAcknowledge = callback; + }, + acknowledgeAs(targetId: number) { + observer.acknowledgeTranscriptTail({ + consumerId: 'window-1', sessionId, hostEpoch: 'host-1', through: Number.MAX_SAFE_INTEGER, + }, targetId); + }, + async reconnect(budget = initialBytes) { + initialBytes = budget; + const previous = observer; + registry.detach(previous); + observer = newObserver(); + const generation = store.range().generation; + await registry.attach(observer); + await waitFor(() => store.range().generation !== generation, { timeoutMs: 3000 }); + await previous.close(); + }, + async append(messageId: string) { + const throughSequence = await ledger.appendThrough(messageId); + updateSubscriberTranscriptHighWater(currentState, throughSequence); + currentSubscription.accept({ + kind: 'subscription.transcript_advanced', sessionId, hostEpoch: 'host-1', + subscriptionId: `subscription-${sessionId}`, sequence: frameSequence++, throughSequence: throughSequence!, + }); + await waitFor(() => replicaThrough === throughSequence, { timeoutMs: 1000 }); + }, + }; +} + +test('opens an indexed window, reads both directions, and exports without replacing or marking the old view read', async (t) => { + const h = await harness(t, Array.from({ length: 100 }, (_, i) => turn(`t${i}`)).flat(), 't40'); + const first = h.store.snapshot(); + assert.ok(first.messages.some((message) => message.turnId === 't40')); + assert.ok(first.messages.length < 20, 'opening must not fill forty Turns to the tail'); + assert.equal(first.hasNewer, true); + assert.equal(first.hasOlder, true); + assert.ok(h.requests.length < 12, 'the page work is bounded by the target neighborhood'); + assert.throws(() => h.acknowledgeAs(10), /another renderer/); + h.acknowledgeAs(9); + assert.deepEqual(h.markers, []); + const complete = await h.controller.readComplete(); + assert.deepEqual(complete.map(({ id }) => id), (await h.ledger.durableRecords()).map(({ message }) => message.id)); + assert.equal(h.store.snapshot(), first, 'export does not replace the reader'); + assert.deepEqual(h.markers, [], 'an explicit full read is not a visible tail acknowledgement'); + await h.controller.loadEarlier(); + assert.ok(h.store.range().oldestSequence! < first.oldestSequence!); + await h.controller.loadNewer(); + assert.ok(h.store.range().durableThrough! > first.durableThrough!); + const beforeReconnect = h.store.snapshot(); + await h.reconnect(); + assert.deepEqual(h.store.snapshot().messages, beforeReconnect.messages); + assert.equal(h.store.range().hasNewer, true); + assert.deepEqual(h.markers, []); + await h.controller.showLatest(); + assert.equal(h.store.range().hasNewer, false); + assert.ok(h.store.snapshot().messages.some((message) => message.turnId === 't99')); + await waitFor(() => h.markers.length > 0, { timeoutMs: 1000 }); + assert.deepEqual(h.errors, []); +}); + +for (const budget of [600, 4000]) { + test(`a seek catches concurrent tail growth only within its ${budget}-byte answer budget`, async (t) => { + const h = await harness(t, Array.from({ length: 10 }, (_, i) => turn(`t${i}`)).flat(), 't0', 'done-t6', budget); + const rows = await h.ledger.durableRecords(); + const high = await h.ledger.reader.readDurableHighWater(h.ledger.sessionId); + let appended = false; + h.beforeAcknowledge(async (batch) => { + if (appended || batch.ready || batch.durableThrough !== high) return; + appended = true; + await h.append('done-t7'); + }); + await h.controller.seek(rows.find(({ message }) => message.id === 'user-t6')!.sequence); + assert.equal(appended, true, 'tail growth must happen before the historical answer finishes'); + if (budget === 600) { + assert.equal(h.store.range().hasNewer, true, 'a full answer must not chase an advancing tail'); + assert.equal(h.store.hasDurableMessage('answer-t7'), false); + assert.deepEqual(h.markers, []); + await h.controller.loadNewer(); + } + assert.equal(h.store.range().hasNewer, false); + assert.equal(h.store.hasDurableMessage('answer-t7'), true); + await waitFor(() => h.markers.length > 0, { timeoutMs: 1000 }); + // After closing the delivery gap, later appends follow without reader input. + await h.append('done-t8'); + await waitFor(() => h.store.hasDurableMessage('answer-t8'), { timeoutMs: 1000 }); + assert.equal(h.store.range().hasNewer, false); + assert.deepEqual(h.errors, []); + }); +} + +test('reconnecting a parked window keeps its bounds despite concurrent tail growth', async (t) => { + const h = await harness(t, Array.from({ length: 20 }, (_, i) => turn(`t${i}`)).flat(), 't0', 'done-t18'); + const held = h.store.snapshot(); + assert.equal(held.hasNewer, true); + let appended = false; + h.beforeAcknowledge(async (batch) => { + if (appended || batch.ready || batch.durableThrough !== held.durableThrough) return; + appended = true; + await h.append('done-t19'); + }); + // A larger read allowance must not override the saved window's exact bounds. + await h.reconnect(4000); + assert.equal(appended, true); + assert.deepEqual(h.store.snapshot().messages, held.messages); + assert.equal(h.store.range().durableThrough, held.durableThrough); + assert.equal(h.store.range().hasNewer, true); + assert.deepEqual(h.markers, []); + assert.deepEqual(h.errors, []); +}); + +test('returning to latest preserves a positioned window that already reached the tail', async (t) => { + const h = await harness(t, Array.from({ length: 8 }, (_, i) => turn(`t${i}`)).flat(), 't3'); + for (const navigation of ['bookmark', 'seek']) { + if (navigation === 'seek') { + const rows = await h.ledger.durableRecords(); + await h.controller.seek(rows.find(({ message }) => message.id === 'user-t2')!.sequence); + } + await h.controller.loadEarlier(); + while (h.store.range().hasNewer) await h.controller.loadNewer(); + const held = h.store.snapshot(); + const requests = h.requests.length; + await h.controller.showLatest(); + assert.equal(h.store.snapshot(), held, 'sending at the tail must not discard previously loaded history'); + assert.equal(h.requests.length, requests, 'no reopen or additional history read is needed'); + } + assert.deepEqual(h.errors, []); +}); + +test('seeking a nested Turn includes the enclosing rows across both page edges', async (t) => { + const outer = turn('outer'); + const inner = turn('inner'); + const source = [outer[0]!, ...inner, outer[1]!, outer[2]!, ...turn('later'), ...turn('last')]; + const h = await harness(t, source, 'inner'); + const ids = h.store.snapshot().messages.map(({ id }) => id); + assert.ok(ids.includes('user-outer')); + assert.ok(ids.includes('user-inner')); + assert.ok(ids.includes('answer-inner')); + assert.ok(ids.includes('answer-outer')); + assert.equal(new Set(ids).size, ids.length); + const all = await h.ledger.durableRecords(); + assert.deepEqual(ids, all.filter(({ sequence }) => sequence <= h.store.range().durableThrough!).map(({ message }) => message.id)); + assert.deepEqual(h.errors, []); +}); + +test('new live-tail rows stay outside a parked window and can be checked for durability without navigation', async (t) => { + const source = Array.from({ length: 20 }, (_, i) => turn(`t${i}`)).flat(); + const h = await harness(t, source, 't0', 'done-t18'); + const parked = h.store.snapshot(); + assert.equal(parked.hasNewer, true); + const settled = h.controller.waitForDurableMessage('answer-t19', 1000); + await new Promise((resolve) => setImmediate(resolve)); + await h.append('done-t19'); + assert.equal(await settled, true); + assert.equal(h.store.snapshot(), parked); + assert.deepEqual(h.markers, []); + let reads = 0; + while (h.store.range().hasNewer) { + assert.ok(++reads < 50, 'forward paging must advance'); + await h.controller.loadNewer(); + } + assert.deepEqual(h.store.snapshot().messages.map(({ id }) => id), (await h.ledger.durableRecords()).map(({ message }) => message.id)); + assert.deepEqual(h.errors, []); +}); + +test('an indexed window reassembles a message spanning Host pages and Desktop IPC fragments', async (t) => { + const text = '跨页'.repeat(24 * 1024); + const large = turn('large').map((message) => message.type === 'assistant' ? { ...message, text } : message); + const h = await harness(t, [...turn('before'), ...large, ...Array.from({ length: 20 }, (_, i) => turn(`after-${i}`)).flat()], 'large'); + const messages = h.store.snapshot().messages; + const answer = messages.find((message) => message.id === 'answer-large'); + assert.equal(answer?.type === 'assistant' ? answer.text : undefined, text); + assert.ok(messages.some((message) => message.id === 'done-large')); + assert.equal(new Set(messages.map(({ id }) => id)).size, messages.length); + assert.equal(h.store.range().hasNewer, true); + assert.deepEqual(h.markers, []); + assert.deepEqual(h.errors, []); +}); + +test('the last navigation wins over a pending seek and tail jump', async (t) => { + const h = await harness(t, Array.from({ length: 40 }, (_, i) => turn(`t${i}`)).flat(), 't0'); + const rows = await h.ledger.durableRecords(); + const sequence = rows.find(({ message }) => message.id === 'user-t15')!.sequence; + const obsolete = h.controller.seek(rows.find(({ message }) => message.id === 'user-t30')!.sequence); + const tail = h.controller.showLatest(); + const final = h.controller.seek(sequence); + const results = await Promise.allSettled([obsolete, tail, final]); + assert.equal(results[2]!.status, 'fulfilled'); + assert.ok(h.store.snapshot().messages.some(({ id }) => id === 'user-t15')); + assert.ok(!h.store.snapshot().messages.some(({ id }) => id === 'user-t39')); + assert.equal(h.store.range().hasNewer, true); + assert.deepEqual(h.markers, []); + assert.deepEqual(h.errors, []); +}); diff --git a/apps/desktop/src/main/__tests__/workhub-coordination-transcript-preload.test.ts b/apps/desktop/src/main/__tests__/workhub-coordination-transcript-preload.test.ts index 3545fcf3987..2341740b2c7 100644 --- a/apps/desktop/src/main/__tests__/workhub-coordination-transcript-preload.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-coordination-transcript-preload.test.ts @@ -142,7 +142,11 @@ test('WorkHub loads earlier history through the preload with a fragmented answer text: 'Earlier coordination record '.repeat(8_000), }; const earlierReads: unknown[] = []; - const projections: Array<{ ids: string[]; hasOlder: boolean }> = []; + const projections: Array<{ + ids: string[]; + hasOlder: boolean; + historyComplete: boolean; + }> = []; const partialProjectionCounts: number[] = []; let bridge: MakaBridge | undefined; let consumerId: string; @@ -245,7 +249,11 @@ test('WorkHub loads earlier history through the preload with a fragmented answer }); const handle = await services.openTranscript( sessionId, - (snapshot) => projections.push({ ids: snapshot.messages.map((message) => message.id), hasOlder: snapshot.hasOlder }), + (snapshot) => projections.push({ + ids: snapshot.messages.map((message) => message.id), + hasOlder: snapshot.hasOlder, + historyComplete: snapshot.historyComplete, + }), new AbortController().signal, (error) => { throw error; }, ); @@ -258,8 +266,8 @@ test('WorkHub loads earlier history through the preload with a fragmented answer assert.ok(partialProjectionCounts.length > 0, 'the answer has to span more than one batch'); assert.ok(partialProjectionCounts.every((count) => count === 1)); assert.deepEqual(projections, [ - { ids: ['tail-message'], hasOlder: true }, - { ids: ['earlier-message', 'tail-message'], hasOlder: false }, + { ids: ['tail-message'], hasOlder: true, historyComplete: false }, + { ids: ['earlier-message', 'tail-message'], hasOlder: false, historyComplete: true }, ]); await handle.loadEarlier(); assert.equal(earlierReads.length, 1, 'nothing is read once no earlier history remains'); @@ -285,6 +293,7 @@ for (const initial of ['failure-before-ready', 'failure-after-ready', 'cached'] let onReady!: () => void; let onPhase!: (phase: 'pending' | 'ready') => void; let latest: readonly StoredMessage[] = []; + let latestHistoryComplete = false; const opening = deferred(); const errors: unknown[] = []; const services = createDesktopWorkHubServices({ @@ -323,16 +332,23 @@ for (const initial of ['failure-before-ready', 'failure-after-ready', 'cached'] }, } satisfies Pick, } as unknown as Parameters[0]); - const handle = await services.openTranscript(sessionId, (snapshot) => { latest = snapshot.messages; }, new AbortController().signal, (error) => errors.push(error)); + const handle = await services.openTranscript(sessionId, (snapshot) => { + latest = snapshot.messages; + latestHistoryComplete = snapshot.historyComplete; + }, new AbortController().signal, (error) => errors.push(error)); const unsubscribe = services.observe(sessionId, () => {}, (error) => errors.push(error), handle.observationChanged); try { if (initial === 'failure-after-ready') onReady(); opening.resolve(); if (initial !== 'cached') await waitFor(() => errors.length > 0, { timeoutMs: 5_000 }); - else assert.deepEqual(latest.map(({ id }) => id), ['cached-message']); + else { + assert.deepEqual(latest.map(({ id }) => id), ['cached-message']); + assert.equal(latestHistoryComplete, false); + } onPhase('pending'); onPhase('ready'); await waitFor(() => latest.some(({ id }) => id === 'live-message'), { timeoutMs: 5_000 }); + assert.equal(latestHistoryComplete, true); assert.equal(openCount, 2); assert.equal(closedCount, initial === 'cached' ? 1 : 0); assert.equal(errors.length, initial === 'cached' ? 0 : 1); diff --git a/apps/desktop/src/main/__tests__/workhub-send-visibility.test.ts b/apps/desktop/src/main/__tests__/workhub-send-visibility.test.ts index 9b2bd72875b..ee647563db8 100644 --- a/apps/desktop/src/main/__tests__/workhub-send-visibility.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-send-visibility.test.ts @@ -104,7 +104,7 @@ async function mountController(failFirstRead = false, overrides: Partial {}, loadEarlier: async () => { earlierLoads += 1; }, @@ -154,7 +154,9 @@ async function mountController(failFirstRead = false, overrides: Partial[0]) { observe(event); }, - publish(messages: StoredMessage[]) { publish({ messages, ready: true, hasOlder: false }); }, + publish(messages: StoredMessage[]) { + publish({ messages, ready: true, hasOlder: false, historyComplete: true }); + }, }; } @@ -761,6 +763,162 @@ test('loading earlier history reaches WorkHub’s transcript', async () => { assert.equal(h.earlierLoads, 1); }); +test('WorkHub completes transcript history before deriving whole-session state', async () => { + const targets: (number | undefined)[] = []; + const h = await mountController(false, { + openTranscript: async (_sessionId, handler) => { + const recent: StoredMessage = { + type: 'assistant', id: 'recent', turnId: 'recent-turn', ts: 2, + modelId: 'model', text: 'recent answer', + }; + handler({ messages: [recent], ready: true, hasOlder: true, historyComplete: false }); + return { + observationChanged() {}, + async loadEarlier(throughSequence) { + targets.push(throughSequence); + handler({ + messages: [ + { type: 'user', id: 'old', turnId: 'old-turn', ts: 1, text: 'old request' }, + recent, + ], + ready: true, + hasOlder: false, + historyComplete: true, + }); + }, + async close() {}, + }; + }, + }); + await act(async () => { await new Promise((resolve) => setImmediate(resolve)); }); + + assert.deepEqual(targets, [0]); + assert.equal(h.controller.transcript.historyComplete, true); + assert.deepEqual(h.controller.transcript.messages.map(({ id }) => id), ['old', 'recent']); +}); + +test('WorkHub retries an incomplete full-history read after the next ready snapshot', async () => { + let publishSnapshot!: Parameters[1]; + let loads = 0; + const recent: StoredMessage = { + type: 'assistant', id: 'recent', turnId: 'recent-turn', ts: 2, + modelId: 'model', text: 'recent answer', + }; + const incomplete = { + messages: [recent], ready: true, hasOlder: true, historyComplete: false, + }; + const h = await mountController(false, { + openTranscript: async (_sessionId, handler) => { + publishSnapshot = handler; + handler(incomplete); + return { + observationChanged() {}, + async loadEarlier() { + loads += 1; + if (loads === 1) return; + handler({ + messages: [ + { type: 'user', id: 'old', turnId: 'old-turn', ts: 1, text: 'old request' }, + recent, + ], + ready: true, + hasOlder: false, + historyComplete: true, + }); + }, + async close() {}, + }; + }, + }); + await act(async () => { await new Promise((resolve) => setImmediate(resolve)); }); + + assert.equal(loads, 1); + assert.equal( + h.controller.error, + 'Could not load the complete task history. Retry to restore WorkHub links and filters.', + ); + await act(async () => { + publishSnapshot(incomplete); + await new Promise((resolve) => setImmediate(resolve)); + }); + assert.equal(loads, 2); + assert.equal(h.controller.transcript.historyComplete, true); + assert.equal(h.controller.error, undefined); +}); + +test('WorkHub clears an incomplete-history error when a manual read completes history', async () => { + let publishSnapshot!: Parameters[1]; + const recent: StoredMessage = { + type: 'assistant', id: 'recent', turnId: 'recent-turn', ts: 2, + modelId: 'model', text: 'recent answer', + }; + let loads = 0; + const h = await mountController(false, { + openTranscript: async (_sessionId, handler) => { + publishSnapshot = handler; + handler({ messages: [recent], ready: true, hasOlder: true, historyComplete: false }); + return { + observationChanged() {}, + async loadEarlier() { + loads += 1; + if (loads > 1) { + publishSnapshot({ messages: [recent], ready: true, hasOlder: false, historyComplete: true }); + } + }, + async close() {}, + }; + }, + }); + await act(async () => { await new Promise((resolve) => setImmediate(resolve)); }); + assert.match(h.controller.error ?? '', /complete task history/); + + await act(async () => { await h.controller.loadEarlier(); }); + + assert.equal(h.controller.transcript.historyComplete, true); + assert.equal(h.controller.error, undefined); +}); + +test('WorkHub retries an uncertain send before an incomplete-history read', async () => { + const h = await mountController(false, { + openTranscript: async (_sessionId, handler) => { + handler({ messages: [], ready: true, hasOlder: true, historyComplete: false }); + return { + observationChanged() {}, + async loadEarlier() {}, + async close() {}, + }; + }, + }); + await act(async () => { await new Promise((resolve) => setImmediate(resolve)); }); + assert.match(h.controller.error ?? '', /complete task history/); + + let sent!: Promise; + await act(async () => { sent = h.controller.send('retain uncertain send', []); }); + const turnId = h.requests[0]!.turnId; + await act(async () => { + h.admission.reject(new RuntimeHostRequestInterruptedError( + 'workhub.coordination.answer', 'command', 'dispatched', 'connection_lost', + )); + assert.equal(await sent, true); + }); + assert.doesNotMatch(h.controller.error ?? '', /complete task history/); + + const openCount = h.openCount; + const requestCount = h.requests.length; + h.resetAdmission(); + await act(async () => { + h.controller.retry(); + await new Promise((resolve) => setImmediate(resolve)); + }); + assert.equal(h.openCount, openCount, 'send recovery does not reopen transcript history'); + assert.equal(h.requests.length, requestCount + 1); + await act(async () => { + h.admit(turnId); + h.admission.resolve({ turnId }); + await new Promise((resolve) => setImmediate(resolve)); + }); +}); + test('follow-up admission before an uncertain response keeps its successor placement', async () => { const h = await mountController(); await act(() => { h.admit('active-turn'); h.emit({ type: 'text_delta', id: 'live', turnId: 'active-turn', messageId: 'answer', ts: 1, text: 'Working' }); }); diff --git a/apps/desktop/src/main/desktop-transcript-ipc.ts b/apps/desktop/src/main/desktop-transcript-ipc.ts index c8b07f9f6b2..1e2be86831a 100644 --- a/apps/desktop/src/main/desktop-transcript-ipc.ts +++ b/apps/desktop/src/main/desktop-transcript-ipc.ts @@ -40,6 +40,7 @@ export interface TranscriptBatchContent { readonly durableThrough: number | null; readonly durable: readonly DesktopSequencedTranscriptMessage[]; readonly hasOlder?: boolean; + readonly hasNewer?: boolean; readonly beginsAtTurnBoundary?: boolean; readonly earlierThan?: number; readonly coversFrom?: number | null; @@ -102,6 +103,7 @@ export function* encodeDesktopTranscriptBatches( durableThrough: content.durableThrough, fragments: batchFragments, ...(content.hasOlder === undefined || !(last && content.ready) ? {} : { hasOlder: content.hasOlder }), + ...(content.hasNewer === undefined || !(last && content.ready) ? {} : { hasNewer: content.hasNewer }), ...(content.beginsAtTurnBoundary === undefined || !(last && content.ready) ? {} : { beginsAtTurnBoundary: content.beginsAtTurnBoundary }), diff --git a/apps/desktop/src/main/desktop-transcript-replica.ts b/apps/desktop/src/main/desktop-transcript-replica.ts index 3e70272a5a1..3591f75fba8 100644 --- a/apps/desktop/src/main/desktop-transcript-replica.ts +++ b/apps/desktop/src/main/desktop-transcript-replica.ts @@ -262,6 +262,7 @@ export class DesktopTranscriptReplica { async readOlderPage( throughSequence: number, cursor: string | null, + maxBytes = SESSION_TRANSCRIPT_PAGE_MAX_BYTES, ): Promise { this.#assertLive(); const page = await this.#handle.loadTranscriptPage({ @@ -269,7 +270,7 @@ export class DesktopTranscriptReplica { throughSequence, cursor, anchorSequence: null, - maxBytes: SESSION_TRANSCRIPT_PAGE_MAX_BYTES, + maxBytes: Math.min(maxBytes, SESSION_TRANSCRIPT_PAGE_MAX_BYTES), }); return this.#withDecodedPage(page, (decoded) => { this.#assertLive(); @@ -285,6 +286,33 @@ export class DesktopTranscriptReplica { }); } + /** A forward page for one contiguous reading window, including nested Turns. */ + async readNewerPage( + throughSequence: number, + cursor: string | null, + afterSequence: number | null, + maxBytes = SESSION_TRANSCRIPT_PAGE_MAX_BYTES, + ): Promise { + this.#assertLive(); + const page = await this.#handle.loadTranscriptPage({ + direction: 'newer', throughSequence, cursor, + anchorSequence: cursor === null ? afterSequence : null, + maxBytes: Math.min(maxBytes, SESSION_TRANSCRIPT_PAGE_MAX_BYTES), + }); + return this.#withDecodedPage(page, (decoded) => { + this.#assertLive(); + if (decoded.messages.length === 0 && decoded.nextCursor !== null) { + throw correlationError('Desktop transcript window returned an empty continuation'); + } + this.#acceptRange(decoded.messages); + return { + durable: decoded.messages.map((entry) => ({ sequence: entry.identity, message: entry.message })), + nextCursor: decoded.nextCursor, + endsAtTurnBoundary: page.endsAtTurnBoundary, + }; + }); + } + /** * Every durable row of one Turn this replica's watermark covers, read * forward across the extent the Host's Turn index gives it. Only the Turn's diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 37c11596fb0..74868c38579 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -1198,7 +1198,10 @@ const createLocalRuntimeHostManager = () => createRuntimeHostDesktopManager( emitSessionsChanged, cacheTranscript: (scope, snapshot) => sessionLocal.cacheTranscript(scope, snapshot), ...(e2eFixture?.scenario === "chat-partial-history" - ? { transcriptHistoryBytes: PARTIAL_HISTORY_TRANSCRIPT_BYTES } + ? { + transcriptHistoryBytes: PARTIAL_HISTORY_TRANSCRIPT_BYTES, + transcriptInitialHistoryBytes: PARTIAL_HISTORY_TRANSCRIPT_BYTES, + } : {}), retireCancelledMessages: (scope, sessionId, messageIds) => sessionLocal.retireCancelledMessages(scope, sessionId, messageIds), diff --git a/apps/desktop/src/main/runtime-host-desktop-candidate.ts b/apps/desktop/src/main/runtime-host-desktop-candidate.ts index 47202ab2170..ceee3919c1e 100644 --- a/apps/desktop/src/main/runtime-host-desktop-candidate.ts +++ b/apps/desktop/src/main/runtime-host-desktop-candidate.ts @@ -184,6 +184,7 @@ export interface DesktopRuntimeHostCandidateDeps { readonly notifyRun: (input: RunNotificationEvent) => Promise; readonly e2eInteractions?: RuntimeHostSessionExecutionIpcDeps["e2eInteractions"]; readonly transcriptHistoryBytes?: number; + readonly transcriptInitialHistoryBytes?: number; readonly renderer?: { send(channel: string, scope: DesktopTargetScope, payload: unknown): void; }; @@ -723,6 +724,7 @@ export async function createDesktopRuntimeHostCandidate( const sessionObserver = new RuntimeHostSessionObserver({ client, transcriptHistoryBytes: deps.transcriptHistoryBytes, + transcriptInitialHistoryBytes: deps.transcriptInitialHistoryBytes, onMessageRetraction: (sessionId, messageIds, proof) => { if (target.access !== 'owner' || !isTargetActive()) return; // Execution resolution is already positive Host proof. Preserve its diff --git a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts index eeb24552100..1c47fd362f9 100644 --- a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts @@ -78,6 +78,7 @@ import { } from "./runtime-host-session-observer.js"; import type { DesktopTranscriptOpenMode, + DesktopTranscriptPosition, DesktopTranscriptTailAcknowledgement, } from '../preload/transcript-contract.js'; import type { DesktopSessionStopResult } from '../preload/bridge-contract.js'; @@ -236,6 +237,7 @@ export interface RuntimeHostSessionObservationIpcDeps { RuntimeHostSessionObservationRegistry, | 'acknowledgeTranscriptTail' | 'loadEarlierTranscript' + | 'loadNewerTranscript' | 'observe' | 'trackRenderer' | 'openTranscript' @@ -269,7 +271,7 @@ export function registerRuntimeHostSessionObservationIpc( ); ipcMain.handle( 'sessions:transcript:open', - async (event, sessionId: unknown, consumerId: unknown, mode: unknown, resumeFrom: unknown) => { + async (event, sessionId: unknown, consumerId: unknown, mode: unknown, resumeFrom: unknown, position: unknown) => { deps.observations.trackRenderer(event.sender); return observationIpcResult( deps.observations.openTranscript( @@ -278,6 +280,7 @@ export function registerRuntimeHostSessionObservationIpc( event.sender as RuntimeHostTranscriptTarget, normalizeTranscriptOpenMode(mode), optionalSequence(resumeFrom, 'Desktop transcript resume position'), + normalizeTranscriptPosition(position), ), ); }, @@ -298,6 +301,9 @@ export function registerRuntimeHostSessionObservationIpc( (_event, sessionId: unknown, turnId: unknown) => deps.observations.readTranscriptTurn(requiredId(sessionId, 'Session'), requiredId(turnId, 'Turn')), ); + ipcMain.handle('sessions:transcript:load-newer', (event, consumerId: unknown) => + deps.observations.loadNewerTranscript(requiredId(consumerId, 'Transcript consumer'), event.sender.id), + ); ipcMain.handle('sessions:transcript:acknowledge-tail', async (event, input: unknown) => { await deps.observations.acknowledgeTranscriptTail( normalizeTranscriptTailAcknowledgement(input), @@ -873,6 +879,20 @@ function normalizeTranscriptOpenMode(mode: unknown): DesktopTranscriptOpenMode { throw new Error('Invalid Desktop transcript open mode'); } +function normalizeTranscriptPosition(value: unknown): DesktopTranscriptPosition | undefined { + if (value === undefined || value === null) return undefined; + if (typeof value !== 'object') throw new Error('Invalid Desktop transcript position'); + const position = value as Record; + if ('turnId' in position) return { turnId: requiredId(position.turnId, 'Transcript Turn') }; + const sequence = optionalSequence(position.sequence, 'Desktop transcript sequence'); + const throughSequence = optionalSequence(position.throughSequence, 'Desktop transcript range end'); + if (sequence === undefined || (throughSequence !== undefined && throughSequence < sequence) || + (position.hasOlder !== undefined && typeof position.hasOlder !== 'boolean')) { + throw new Error('Invalid Desktop transcript range'); + } + return { sequence, throughSequence, hasOlder: position.hasOlder as boolean | undefined }; +} + function optionalSequence(value: unknown, label: string): number | undefined { if (value === undefined || value === null) return undefined; if (Number.isSafeInteger(value) && (value as number) >= 0) return value as number; diff --git a/apps/desktop/src/main/runtime-host-session-observation-registry.ts b/apps/desktop/src/main/runtime-host-session-observation-registry.ts index 6b03f0bdc96..5e7af26cc88 100644 --- a/apps/desktop/src/main/runtime-host-session-observation-registry.ts +++ b/apps/desktop/src/main/runtime-host-session-observation-registry.ts @@ -28,6 +28,7 @@ import type { } from "./runtime-host-session-observer.js"; import type { DesktopTranscriptOpenMode, + DesktopTranscriptPosition, DesktopTranscriptOpenResult, DesktopTranscriptTailAcknowledgement, } from '../preload/transcript-contract.js'; @@ -40,6 +41,7 @@ type SessionObservationSource = Pick @@ -124,6 +126,7 @@ interface TranscriptRegistration { * than onto a fresh budget. */ deliveredFrom: number | null; + position?: DesktopTranscriptPosition; } interface TranscriptReadiness { @@ -410,6 +413,11 @@ export class RuntimeHostSessionObservationRegistry { registration.deliveredFrom = sequence; } } + if (payload.ready) { + registration.position = payload.hasNewer && registration.deliveredFrom !== null && payload.durableThrough !== null + ? { sequence: registration.deliveredFrom, throughSequence: payload.durableThrough, hasOlder: payload.hasOlder } + : undefined; + } target.send(channel, payload); }, once: (event, listener) => target.once(event, listener), @@ -423,6 +431,7 @@ export class RuntimeHostSessionObservationRegistry { target: RuntimeHostTranscriptTarget, mode: DesktopTranscriptOpenMode = 'tail', resumeFrom?: number, + position?: DesktopTranscriptPosition, ): Promise { this.#assertOpen(); if (this.#transcripts.has(consumerId)) { @@ -443,6 +452,7 @@ export class RuntimeHostSessionObservationRegistry { restoreOpened: false, lifecycle: 'pending', deliveredFrom: resumeFrom ?? null, + position, }; this.#transcripts.set(consumerId, registration); target.once('destroyed', destroyedListener); @@ -456,6 +466,7 @@ export class RuntimeHostSessionObservationRegistry { this.#trackDelivered(registration, this.#bindTarget(target)), mode, resumeFrom, + position, ); if (this.#source === source && this.#transcripts.get(consumerId) === registration) { registration.lifecycle = 'active'; @@ -488,6 +499,13 @@ export class RuntimeHostSessionObservationRegistry { return requireTranscriptSource(this.#source).readTranscriptTurn(sessionId, turnId); } + async loadNewerTranscript(consumerId: string, targetId?: number): Promise { + await this.#runTranscriptOperation({ consumerId }, (source) => { + if (!source.loadNewerTranscript) throw new Error('Runtime Host forward history is unavailable'); + return source.loadNewerTranscript(consumerId, targetId); + }); + } + async acknowledgeTranscriptTail( request: DesktopTranscriptTailAcknowledgement, targetId?: number, @@ -644,6 +662,7 @@ export class RuntimeHostSessionObservationRegistry { this.#trackDelivered(registration, this.#bindTarget(registration.target)), registration.mode, registration.deliveredFrom ?? undefined, + registration.position, ); if ( this.#source === source && diff --git a/apps/desktop/src/main/runtime-host-session-observer.ts b/apps/desktop/src/main/runtime-host-session-observer.ts index faee216c80e..5420becf425 100644 --- a/apps/desktop/src/main/runtime-host-session-observer.ts +++ b/apps/desktop/src/main/runtime-host-session-observer.ts @@ -44,10 +44,12 @@ import { DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, DESKTOP_TRANSCRIPT_GLOBAL_CACHE_MAX_BYTES, DESKTOP_TRANSCRIPT_HISTORY_MAX_BYTES, + DESKTOP_TRANSCRIPT_INITIAL_HISTORY_MAX_BYTES, DESKTOP_TRANSCRIPT_MESSAGE_MAX_BYTES, type DesktopTranscriptBatch, type DesktopTranscriptBatchPayload, type DesktopTranscriptOpenMode, + type DesktopTranscriptPosition, type DesktopTranscriptOpenResult, type DesktopTranscriptTailAcknowledgement, } from '../preload/transcript-contract.js'; @@ -119,6 +121,7 @@ export interface RuntimeHostSessionObserverDeps { emitSubscriptionRecovered?: (sessionId: string) => void; recoverConnectionClosed?: boolean; transcriptHistoryBytes?: number; + transcriptInitialHistoryBytes?: number; transcriptGlobalCacheMaxBytes?: number; now?: () => number; } @@ -161,6 +164,7 @@ interface TranscriptConsumer { resetRequested: boolean; /** An earlier read to send, reading down to `floor` when it is set. */ earlierRequested: false | { readonly floor: number | null }; + newerRequested?: boolean; readonly history?: TranscriptHistory; pendingChange?: PendingTranscriptChange; readonly pendingDeliveries: Map void; readonly #recoverConnectionClosed: boolean; readonly #transcriptHistoryBytes: number; + readonly #transcriptInitialHistoryBytes: number; readonly #transcriptGlobalCacheMaxBytes: number; readonly #now: () => number; #closed = false; @@ -271,6 +280,8 @@ export class RuntimeHostSessionObserver { deps.emitSubscriptionRecovered ?? (() => undefined); this.#recoverConnectionClosed = deps.recoverConnectionClosed ?? false; this.#transcriptHistoryBytes = deps.transcriptHistoryBytes ?? DESKTOP_TRANSCRIPT_HISTORY_MAX_BYTES; + this.#transcriptInitialHistoryBytes = + deps.transcriptInitialHistoryBytes ?? DESKTOP_TRANSCRIPT_INITIAL_HISTORY_MAX_BYTES; this.#transcriptGlobalCacheMaxBytes = deps.transcriptGlobalCacheMaxBytes ?? DESKTOP_TRANSCRIPT_GLOBAL_CACHE_MAX_BYTES; this.#now = deps.now ?? Date.now; @@ -287,6 +298,7 @@ export class RuntimeHostSessionObserver { * the first answer reads back down to here instead of to one budget. */ resumeFrom?: number, + position?: DesktopTranscriptPosition, ): Promise { this.#assertOpen(); if ( @@ -349,6 +361,8 @@ export class RuntimeHostSessionObserver { started: false, cursor: null, oldestSequence: resumeFrom ?? null, + coveredThrough: null, + position, }, } : {}), @@ -409,6 +423,20 @@ export class RuntimeHostSessionObserver { this.#touchReplica(state); } + /** Continue a reading window toward the tail without filling the entire gap. */ + async loadNewerTranscript(consumerId: string, targetId?: number): Promise { + const state = this.#transcriptConsumers.get(consumerId); + const consumer = state?.transcriptConsumers.get(consumerId); + if (!state || !consumer?.history) throw new Error('Desktop transcript history consumer does not exist'); + if (targetId !== undefined && consumer.target.id !== targetId) { + throw new Error('Desktop transcript consumer belongs to another renderer'); + } + consumer.newerRequested = true; + await this.#scheduleTranscriptDelivery(state, consumer); + if (consumer.newerRequested) await this.#scheduleTranscriptDelivery(state, consumer); + this.#touchReplica(state); + } + /** Every message of one Turn, whether or not any consumer holds it. */ async readTranscriptTurn(sessionId: string, turnId: string): Promise { this.#assertOpen(); @@ -467,6 +495,7 @@ export class RuntimeHostSessionObserver { if (targetId !== undefined && consumer.target.id !== targetId) { throw new Error('Desktop transcript consumer belongs to another renderer'); } + if (consumer.history?.newer) return; // Sequences only name the same rows within one Session and Host epoch. if (state.sessionId !== request.sessionId || replica.hostEpoch !== request.hostEpoch) return; const durableThrough = replica.durableThrough; @@ -1383,6 +1412,14 @@ export class RuntimeHostSessionObserver { } continue; } + if (consumer.newerRequested) { + consumer.newerRequested = false; + const replica = state.replica; + if (consumer.history?.newer && replica?.resident && replica.generation === consumer.generation) { + await this.#sendNewerHistory(state, consumer, consumer.history, replica); + } + continue; + } const pending = consumer.pendingChange; if (!pending) return; consumer.pendingChange = undefined; @@ -1407,6 +1444,7 @@ export class RuntimeHostSessionObserver { }, ), ); + if (consumer.history) consumer.history.coveredThrough = pending.durableThrough; } finally { this.#adjustTranscriptDeliveryBytes(consumer, -pending.encodedBytes); } @@ -1450,6 +1488,7 @@ export class RuntimeHostSessionObserver { ): Promise { let earlierThan: number | undefined; if (reset) { + if (history.position && await this.#sendTranscriptWindow(state, consumer, history, replica, history.position)) return; const snapshot = replica.snapshot(); floor = history.oldestSequence; Object.assign(history, { @@ -1457,12 +1496,19 @@ export class RuntimeHostSessionObserver { started: false, cursor: null, oldestSequence: null, + coveredThrough: snapshot.durableThrough, + newer: undefined, }); } else { if (history.oldestSequence === null || !historyHasOlder(history)) return; earlierThan = history.oldestSequence; } - const budget = this.#transcriptHistoryBytes; + // Opening near the live tail needs a small answer. A recovery must still + // restore everything already delivered, so it uses the earlier-read budget + // and continues through the saved floor even when that exceeds the budget. + const budget = reset && floor === null + ? this.#transcriptInitialHistoryBytes + : this.#transcriptHistoryBytes; const identity = { sessionId: replica.sessionId, generation: replica.generation, hostEpoch: replica.hostEpoch }; const isCurrent = () => state.replica === replica && @@ -1476,9 +1522,10 @@ export class RuntimeHostSessionObserver { await this.#sendTranscriptBatches( consumer, encodeDesktopTranscriptBatches(identity, { - durableThrough: history.throughSequence, + durableThrough: history.coveredThrough, durable, hasOlder: historyHasOlder(history), + hasNewer: history.newer !== undefined, beginsAtTurnBoundary, ...(earlierThan === undefined ? {} : { earlierThan }), reset: reset && first, @@ -1491,7 +1538,11 @@ export class RuntimeHostSessionObserver { while (history.throughSequence !== null && historyHasOlder(history)) { let page: Awaited>; try { - page = await replica.readOlderPage(history.throughSequence, history.cursor); + // The first page honors the small opening budget. Once the Host says + // that page cut through a Turn, finish that Turn with the regular page + // budget instead of paying one 128 KiB round trip at a time. + const pageBudget = beginsAtTurnBoundary ? budget : this.#transcriptHistoryBytes; + page = await replica.readOlderPage(history.throughSequence, history.cursor, pageBudget); } catch (error) { if (!isCurrent()) return; throw error; @@ -1523,7 +1574,141 @@ export class RuntimeHostSessionObserver { // however much of the budget has already been spent. if (bytes >= budget && reachedFloor && page.endsAtTurnBoundary) break; } - if (isCurrent()) await send([], true); + if (isCurrent()) { + rememberTranscriptWindow(history); + await send([], true); + } + } + + /** Seek with the existing page protocol. The backward prefix closes any + * enclosing Turn before the forward read, including interleaved history. */ + async #sendTranscriptWindow( + state: ObservedSessionState, + consumer: TranscriptConsumer, + history: TranscriptHistory, + replica: DesktopTranscriptReplica, + position: DesktopTranscriptPosition, + ): Promise { + const high = replica.durableThrough; + if (high === null) return false; + const isCurrent = () => state.replica === replica && + state.transcriptConsumers.get(consumer.consumerId) === consumer && !consumer.resetRequested; + history.newer = { throughSequence: high, cursor: null }; + const sequence = 'turnId' in position + ? (await this.#client.listSessionTurnLandmarks?.(state.sessionId, position.turnId))?.landmarks[0]?.sequence + : position.sequence; + if (!isCurrent()) return true; + if (sequence === undefined || sequence > high) { + history.position = undefined; + history.newer = undefined; + return false; + } + const restoring = 'throughSequence' in position && position.throughSequence !== undefined; + if (restoring) history.newer.throughSequence = Math.min(high, position.throughSequence); + Object.assign(history, { + throughSequence: sequence > 0 ? sequence - 1 : null, + started: restoring && position.hasOlder === false, + cursor: null, + oldestSequence: null, + coveredThrough: sequence > 0 ? sequence - 1 : null, + }); + let reset = true; + if (!restoring) { + // Even a nested Turn needs a safe start. One backward page (continued to + // a Turn boundary) supplies context without traversing from the tail. + let boundary = false; + while (history.throughSequence !== null && historyHasOlder(history) && !boundary) { + const page = await replica.readOlderPage(history.throughSequence, history.cursor, this.#transcriptInitialHistoryBytes); + if (!isCurrent()) return true; + history.started = true; + history.cursor = page.nextCursor; + boundary = page.endsAtTurnBoundary; + if (page.durable.length) history.oldestSequence = page.durable[0]!.sequence; + await this.#sendWindowRows(consumer, replica, history, page.durable, { reset, ready: false }); + reset = false; + } + } + await this.#sendNewerHistory( + state, consumer, history, replica, reset, + restoring ? position.throughSequence : undefined, + true, + ); + return true; + } + + async #sendNewerHistory( + state: ObservedSessionState, + consumer: TranscriptConsumer, + history: TranscriptHistory, + replica: DesktopTranscriptReplica, + reset = false, + restoreThrough?: number, + opening = false, + ): Promise { + let newer = history.newer; + if (!newer) return; + const isCurrent = () => state.replica === replica && + state.transcriptConsumers.get(consumer.consumerId) === consumer && !consumer.resetRequested; + const coversFrom = opening ? undefined : history.coveredThrough; + let bytes = 0; + let boundary = true; + const budget = opening ? this.#transcriptInitialHistoryBytes : this.#transcriptHistoryBytes; + while (true) { + const page = await replica.readNewerPage( + newer.throughSequence, newer.cursor, history.coveredThrough, + boundary ? budget : this.#transcriptHistoryBytes, + ); + if (!isCurrent()) return; + newer.cursor = page.nextCursor; + boundary = page.endsAtTurnBoundary; + bytes += page.durable.reduce((sum, row) => sum + encodedTranscriptMessageBytes(row.message), 0); + const first = page.durable[0]; + if (first && history.oldestSequence === null) history.oldestSequence = first.sequence; + history.coveredThrough = newer.cursor === null + ? newer.throughSequence : page.durable.at(-1)?.sequence ?? history.coveredThrough; + await this.#sendWindowRows(consumer, replica, history, page.durable, { reset, ready: false, coversFrom }); + reset = false; + if (!isCurrent()) return; + if (newer.cursor === null) { + const high = replica.durableThrough; + history.newer = high !== null && high > newer.throughSequence + ? { throughSequence: high, cursor: null } : undefined; + // Tail changes are withheld while reading a historical window. Close + // a gap created during delivery within this answer's remaining budget, + // but keep a reconnect's explicitly restored bounds fixed. + if (history.newer && restoreThrough === undefined && bytes < budget) { + newer = history.newer; + continue; + } + break; + } + if (boundary && bytes >= budget && + (restoreThrough === undefined || (history.coveredThrough ?? -1) >= restoreThrough)) break; + } + rememberTranscriptWindow(history); + await this.#sendWindowRows(consumer, replica, history, [], { reset, ready: true, coversFrom }); + } + + async #sendWindowRows( + consumer: TranscriptConsumer, + replica: DesktopTranscriptReplica, + history: TranscriptHistory, + durable: readonly DesktopSequencedTranscriptMessage[], + flags: { reset: boolean; ready: boolean; coversFrom?: number | null }, + ): Promise { + const bytes = durable.reduce((sum, row) => sum + encodedTranscriptMessageBytes(row.message), 0); + if (!this.#adjustTranscriptDeliveryBytes(consumer, bytes)) { + throw new Error('Desktop transcript delivery capacity was reached'); + } + try { + await this.#sendTranscriptBatches(consumer, encodeDesktopTranscriptBatches(replica, { + durable, durableThrough: history.coveredThrough, + hasOlder: historyHasOlder(history), hasNewer: history.newer !== undefined, + beginsAtTurnBoundary: true, ...flags, + })); + } finally { + this.#adjustTranscriptDeliveryBytes(consumer, -bytes); + } } /** Coalesces tail growth for one consumer; a change that does not join the pending one needs a reset. */ @@ -1532,6 +1717,9 @@ export class RuntimeHostSessionObserver { change: DesktopTranscriptReplicaChange, ): boolean { if (consumer.resetRequested) return true; + // A window before the tail must not receive a disconnected append. The + // subscription continues observing execution; reading forward closes the gap. + if (consumer.history?.newer) return true; const existing = consumer.pendingChange; if (existing && existing.durableThrough !== change.coversFrom) return false; const pending = existing ?? { @@ -1740,6 +1928,12 @@ function historyHasOlder(history: TranscriptHistory): boolean { return history.started ? history.cursor !== null : history.throughSequence !== null; } +function rememberTranscriptWindow(history: TranscriptHistory): void { + history.position = history.newer && history.oldestSequence !== null && history.coveredThrough !== null + ? { sequence: history.oldestSequence, throughSequence: history.coveredThrough, hasOlder: historyHasOlder(history) } + : undefined; +} + function resetDeliveryWorkingSetBytes(residentBytes: number): number { return ( Math.min(residentBytes, DESKTOP_TRANSCRIPT_MESSAGE_MAX_BYTES) + diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 5287b9e34f0..254ea55a1ae 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -116,6 +116,7 @@ import type { DesktopTranscriptBatch, DesktopTranscriptHandle, DesktopTranscriptOpenMode, + DesktopTranscriptPosition, } from './transcript-contract.js'; import type { PetPackManifestV1 } from '@maka/core/pet'; import type { @@ -1475,6 +1476,7 @@ export interface MakaBridge { mode?: DesktopTranscriptOpenMode, /** The oldest sequence the reader already holds; the first answer reads back down to it. */ resumeFrom?: number, + position?: DesktopTranscriptPosition, ): Promise; }; externalSessions: { diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index aba743dc2cd..800ba7c0401 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -105,6 +105,7 @@ import { type DesktopTranscriptBatch, type DesktopTranscriptHandle, type DesktopTranscriptOpenMode, + type DesktopTranscriptPosition, type DesktopTranscriptOpenResult, } from './transcript-contract.js'; import { @@ -2803,6 +2804,7 @@ const makaBridge = { registerCancellation?: (cancel: () => void) => void, mode: DesktopTranscriptOpenMode = 'history', resumeFrom?: number, + position?: DesktopTranscriptPosition, ): Promise { const consumerId = crypto.randomUUID(); const channel = `sessions:transcript:${consumerId}`; @@ -2864,6 +2866,7 @@ const makaBridge = { consumerId, mode, resumeFrom ?? null, + position ?? null, ) as Promise>, }; }); @@ -2902,6 +2905,7 @@ const makaBridge = { ...cachedIdentity, sessionId, readThroughMessageId: null, acknowledgeTail: unavailable, loadEarlier: unavailable, + loadNewer: unavailable, close: async () => {}, }; } @@ -2938,6 +2942,9 @@ const makaBridge = { consumerId, throughSequence ?? null, ) as Promise, + loadNewer: () => invokeWhenReady( + 'sessions:transcript:load-newer', consumerScope, consumerId, + ) as Promise, async close() { if (closed) return; requestClose(); diff --git a/apps/desktop/src/preload/transcript-contract.ts b/apps/desktop/src/preload/transcript-contract.ts index 3b3042f4673..ab7bcdd5925 100644 --- a/apps/desktop/src/preload/transcript-contract.ts +++ b/apps/desktop/src/preload/transcript-contract.ts @@ -23,8 +23,10 @@ export const DESKTOP_TRANSCRIPT_TAIL_MAX_BYTES = 512 * 1024; export const DESKTOP_TRANSCRIPT_TAIL_MAX_TURNS = 10; export const DESKTOP_TRANSCRIPT_MESSAGE_MAX_BYTES = 16 * 1024 * 1024; export const DESKTOP_TRANSCRIPT_GLOBAL_CACHE_MAX_BYTES = 64 * 1024 * 1024; -/** Projected message bytes of whole Turns one history read delivers: the first read and each "load earlier". */ -export const DESKTOP_TRANSCRIPT_HISTORY_MAX_BYTES = 64 * 1024 * 1024; +/** Recent whole Turns needed to open a reader; older history remains available on demand. */ +export const DESKTOP_TRANSCRIPT_INITIAL_HISTORY_MAX_BYTES = 128 * 1024; +/** Projected message bytes per continuation; reconnect restores the full held range. */ +export const DESKTOP_TRANSCRIPT_HISTORY_MAX_BYTES = 512 * 1024; export interface DesktopTranscriptFragment { readonly sequence: number; @@ -40,9 +42,9 @@ export interface DesktopTranscriptFragment { * * - `reset` starts a replacement of everything the consumer holds. * - `earlierThan` starts earlier history that ends just before that sequence. - * - `coversFrom` names the watermark a tail change read forward from. + * - `coversFrom` names the covered endpoint a forward page or tail change starts from. * - * One answer spans batches until `ready`; `hasOlder` is final only there. + * One answer spans batches until `ready`; `hasOlder` and `hasNewer` are final only there. */ export interface DesktopTranscriptBatchPayload { readonly earlierThan?: number; @@ -50,9 +52,12 @@ export interface DesktopTranscriptBatchPayload { readonly sessionId: string; readonly generation: string; readonly hostEpoch: string; + /** The displayed range's upper coverage, which may precede the subscription watermark. */ readonly durableThrough: number | null; readonly fragments: readonly DesktopTranscriptFragment[]; readonly hasOlder?: boolean; + /** The displayed range ends before the live tail. */ + readonly hasNewer?: boolean; /** * Whether the oldest Turn in this answer has all its rows in it. A * byte-bounded answer can begin inside a Turn, and no local rule tells the @@ -76,10 +81,15 @@ export interface DesktopTranscriptOpenResult { /** * Tail-only consumers get the Main tail cache; history consumers get the - * newest whole Turns up to the history budget and may ask for earlier ones. + * newest whole Turns or an explicit indexed window, then page in either direction. */ export type DesktopTranscriptOpenMode = 'tail' | 'history'; +/** A semantic target, or the contiguous range held across a reconnect. */ +export type DesktopTranscriptPosition = + | { readonly turnId: string } + | { readonly sequence: number; readonly throughSequence?: number; readonly hasOlder?: boolean }; + /** * The Renderer reporting that it now holds every durable row through * `through`. Main cannot derive this: a consumer only proves the Session is @@ -96,6 +106,7 @@ export interface DesktopTranscriptHandle extends DesktopTranscriptOpenResult { acknowledgeTail(through: number): Promise; /** One budget of earlier history, continuing in the same answer down to `throughSequence`. */ loadEarlier(throughSequence?: number): Promise; + loadNewer?(): Promise; close(): Promise; } @@ -114,6 +125,7 @@ export function assertDesktopTranscriptBatch(value: unknown): DesktopTranscriptB (batch.durableThrough !== null && !isSequence(batch.durableThrough)) || !Array.isArray(batch.fragments) || (batch.hasOlder !== undefined && typeof batch.hasOlder !== 'boolean') || + (batch.hasNewer !== undefined && typeof batch.hasNewer !== 'boolean') || (batch.beginsAtTurnBoundary !== undefined && typeof batch.beginsAtTurnBoundary !== 'boolean') || typeof batch.reset !== 'boolean' || diff --git a/apps/desktop/src/renderer/app-shell-command-actions.ts b/apps/desktop/src/renderer/app-shell-command-actions.ts index f59dc758a5c..a88d537d4eb 100644 --- a/apps/desktop/src/renderer/app-shell-command-actions.ts +++ b/apps/desktop/src/renderer/app-shell-command-actions.ts @@ -23,7 +23,7 @@ import type { PermissionMode } from '@maka/core/permission'; import type { SessionSummary } from '@maka/core/session'; import type { SettingsSection, ThemePreference } from '@maka/core/settings'; import type { UiLocale } from '@maka/core/ui-locale'; -import type { NavSelection } from "@maka/ui"; +import type { NavSelection, ToastApi } from "@maka/ui"; import type { DesktopManualDiagnosticTarget } from '../preload/diagnostics-contract.js'; import { defaultRuntimeHostDiagnosticTarget, @@ -41,17 +41,6 @@ import { getShellCopy } from "./locales/shell-copy.js"; import { memoryOpenFailureMessage } from "./locales/settings-memory-copy.js"; import { settingsTestResultMessage } from "./locales/settings-test-result-copy.js"; -type ToastApi = { - success(title: string, description?: string): void; - info(title: string, description?: string): void; - error( - title: string, - description?: string, - diagnosticDetails?: string, - diagnosticTarget?: { sessionId: string } | { profileId: string }, - ): void; -}; - type ComposerImportOwner = { sessionId: string | undefined; navSection: NavSelection["section"]; @@ -68,8 +57,8 @@ export interface AppShellCommandListOptions { clientPathsAccessible: boolean; connections: LlmConnection[]; defaultConnection: string | null; - /** Copy and Save's Markdown export of the published conversation. */ - renderPublishedConversation(sessionName: string, locale: UiLocale): string; + /** Copy and Save read the complete history without replacing the visible range. */ + renderCompleteConversation(sessionId: string, sessionName: string, locale: UiLocale): Promise; newTaskProfileId: string | undefined; settingsOpen: boolean; settingsProfileId: string | undefined; @@ -130,6 +119,13 @@ export function buildAppShellCommandList( const options = optionsRef.current; const locale = options.uiLocale; const copy = getShellCopy(locale).commandActions; + const completeConversation = async () => { + const { activeId, renderCompleteConversation, sessionCatalog } = optionsRef.current; + if (!activeId) return undefined; + const sessionName = sessionCatalog.getState().sessions.find((session) => session.id === activeId)?.name ?? copy.newConversation; + const markdown = await renderCompleteConversation(activeId, sessionName, locale); + return { markdown, sessionName }; + }; return buildCommandList({ locale, @@ -217,38 +213,43 @@ export function buildAppShellCommandList( : {}), onSelectModule: (selection) => optionsRef.current.setNavSelection(selection), onExportActiveConversation: async () => { - const { activeId, renderPublishedConversation, sessionCatalog, toastApi } = optionsRef.current; - if (!activeId) return; - const session = sessionCatalog.getState().sessions.find((s) => s.id === activeId); - const markdown = renderPublishedConversation(session?.name ?? copy.newConversation, locale); + const { toastApi } = optionsRef.current; try { - await navigator.clipboard.writeText(markdown); + const result = await completeConversation(); + if (!result) return; + const { markdown } = result; + try { + await navigator.clipboard.writeText(markdown); + } catch { + toastApi.error(copy.copyFailedTitle, copy.clipboardUnavailable); + return; + } toastApi.success( copy.conversationCopiedTitle, copy.lineCount(markdown.split("\n").length), ); - } catch { - toastApi.error(copy.copyFailedTitle, copy.clipboardUnavailable); + } catch (err) { + toastApi.error( + copy.copyFailedTitle, + commandPaletteActionErrorMessage(err, copy.exportFallback, locale), + ); } }, onSaveActiveConversationToFile: async () => { - const { activeId, paletteActions, renderPublishedConversation, sessionCatalog, toastApi } = optionsRef.current; - if (!activeId) return; - const session = sessionCatalog.getState().sessions.find((s) => s.id === activeId); - const sessionName = session?.name ?? copy.newConversation; - const markdown = renderPublishedConversation(sessionName, locale); + const { paletteActions, toastApi } = optionsRef.current; const now = new Date(); const yyyy = now.getFullYear(); const mm = String(now.getMonth() + 1).padStart(2, "0"); const dd = String(now.getDate()).padStart(2, "0"); - // Make the filename mostly portable: collapse whitespace - // and quote chars that some file pickers don't like. - const sanitizedSession = sessionName - .replace(/[\s ]+/g, "-") - .replace(/["<>:|?*]/g, "") - .slice(0, 80); - const defaultName = `maka-${sanitizedSession}-${yyyy}-${mm}-${dd}.md`; try { + const conversation = await completeConversation(); + if (!conversation) return; + const { markdown, sessionName } = conversation; + const sanitizedSession = sessionName + .replace(/[\s ]+/g, "-") + .replace(/["<>:|?*]/g, "") + .slice(0, 80); + const defaultName = `maka-${sanitizedSession}-${yyyy}-${mm}-${dd}.md`; const result = await paletteActions.saveConversationToFile({ markdown, defaultName, @@ -268,11 +269,7 @@ export function buildAppShellCommandList( } catch (err) { toastApi.error( copy.saveFailedTitle, - commandPaletteActionErrorMessage( - err, - copy.exportFallback, - locale, - ), + commandPaletteActionErrorMessage(err, copy.exportFallback, locale), ); } }, diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index caf41f824c8..ebb9905bc29 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -231,7 +231,7 @@ function AppShellContent({ }) { const toastApi = useToast(); const { - renderPublishedConversation, + renderCompleteConversation, refreshMessages, transcriptEmpty, transcriptHasHistory, @@ -297,7 +297,7 @@ function AppShellContent({ const composerStaging = useMemo(Conversation.createComposerStagingCommands, []); const composerSubmission = useMemo(Conversation.createComposerSubmissionCommands, []); const [petCompletionNonce, setPetCompletionNonce] = useState(0); - const [navigationState, setNavigationState] = useState(() => readNavigationState()); + const [navigationState, setNavigationState] = useState(readNavigationState); const navSelection = navigationState.selection; const sessionsSelected = navSelection.section === 'sessions'; const setNavSelection = useCallback>>((nextSelection) => { @@ -1036,7 +1036,7 @@ function AppShellContent({ : projectCapabilities.viewClientPath, connections: defaultHostConnections.snapshot.connections, defaultConnection: defaultHostConnections.snapshot.defaultConnection, - renderPublishedConversation, + renderCompleteConversation, newTaskProfileId: taskEntry.selectors.selectedProfileId, settingsOpen, settingsProfileId: overlays.selectors.settings.request.profileId, diff --git a/apps/desktop/src/renderer/features/conversation/README.md b/apps/desktop/src/renderer/features/conversation/README.md index de98b01255e..77f2073cd1c 100644 --- a/apps/desktop/src/renderer/features/conversation/README.md +++ b/apps/desktop/src/renderer/features/conversation/README.md @@ -49,10 +49,10 @@ submission owner reads them from the controller. Its published Session reference is a frozen getter, and consuming contracts declare it readonly. It has no map setters, range controller, publication callback, writable refs, or whole-state getter. -Copy and Save call `renderPublishedConversation(sessionName, locale)`, which -renders the **published range** as Markdown at invocation; the shell gets the -export, not the messages, and it is not a full-history promise. Revision commands -read the same range from the controller inside the Composer submission owner. +Copy and Save call `renderCompleteConversation(sessionId, sessionName, locale)`, +which reads complete history without changing the displayed window and returns +Markdown. The shell receives the export, not the messages. Revision commands +read the published range from the controller inside the Composer submission owner. The Desktop adapter supplies `ConversationObservationServices`. The feature never imports the Desktop range implementation or accesses `window.maka`. diff --git a/apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position-controller.tsx b/apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position-controller.tsx index 7843a36ba1b..0bacd671d7a 100644 --- a/apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position-controller.tsx +++ b/apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position-controller.tsx @@ -44,6 +44,8 @@ export interface TranscriptReadingPositionCommands { prepareSend(sessionId: string): boolean; captureAnchor(sessionId: string, turnId?: string): void; loadEarlier(throughSequence?: number): Promise; + loadNewer(): Promise; + showLatest(): Promise; } /** The conversation owns restoration lifetime; the shell supplies explicit ports. */ @@ -81,7 +83,10 @@ export function TranscriptReadingPositionController(props: { prepareSend(sessionId) { return prepareTranscriptForSend({ sessionId, currentSessionId: props.currentSessionId, cancel, - followLatest: props.sessionUi.transcriptViewportNavigation.followLatest, + followLatest: (id) => { + void props.rangeController.current?.showLatest?.().catch((error) => props.onRestoreError(error, id)); + props.sessionUi.transcriptViewportNavigation.followLatest(id); + }, }); }, captureAnchor(sessionId, turnId) { @@ -93,7 +98,15 @@ export function TranscriptReadingPositionController(props: { const controller = props.rangeController.current; const { sessionId } = props; if (!controller || !sessionId || !isCurrent(sessionId, controller)) return; - await controller.loadEarlier(throughSequence); + if (throughSequence !== undefined && controller.seek) await controller.seek(throughSequence); + else await controller.loadEarlier(throughSequence); + }, + async loadNewer() { await props.rangeController.current?.loadNewer?.(); }, + async showLatest() { + const { sessionId } = props; + if (!sessionId || props.currentSessionId.current !== sessionId) return; + cancel(sessionId, true); + await props.rangeController.current?.showLatest?.(); }, })); @@ -104,7 +117,7 @@ export function TranscriptReadingPositionController(props: { // New Turns land in the resident tail, so the index is read once per Session // and only once some history lies outside the resident range. const range = currentTranscriptRange(props.rangeController.current, props.sessionId); - const hasOlder = range?.hasOlder ?? false; + const hasOlder = Boolean(range?.hasOlder || range?.hasNewer); // A reopen reads the index again, so a lookup that failed does not stay failed. const generation = range?.ready ? range.generation : undefined; useEffect(() => { diff --git a/apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position.ts b/apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position.ts index 8ee7109353b..6d2c39b35f7 100644 --- a/apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position.ts +++ b/apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position.ts @@ -24,12 +24,17 @@ interface TranscriptRangeController { range(): { readonly sessionId: string; readonly hasOlder: boolean; + readonly hasNewer?: boolean; readonly ready: boolean; readonly generation?: string; }; snapshot(): { readonly messages: readonly Message[] }; }; loadEarlier(throughSequence?: number): Promise; + loadNewer?(): Promise; + seek?(sequence: number): Promise; + showLatest?(): Promise; + readComplete?(): Promise; } interface SearchTarget { @@ -127,6 +132,33 @@ export function currentTranscriptRange( + controllerRef: { readonly current: TranscriptRangeController | undefined }, + sessionRef: { readonly current: string | undefined }, + sessionId: string, +): Promise { + const controller = controllerRef.current; + const current = () => sessionRef.current === sessionId && controllerRef.current === controller; + const available = currentTranscriptRange(controller, sessionId); + if (!controller || !current()) { + throw new Error('The active task changed before its transcript could be exported'); + } + if (!available?.ready || available.generation?.startsWith('cached:')) { + throw new Error('The complete task transcript is not available'); + } + if (controller.readComplete) { + const messages = await controller.readComplete(); + if (!current()) throw new Error('The active task changed before its transcript could be exported'); + return messages; + } + await controller.loadEarlier(0); + const complete = currentTranscriptRange(controller, sessionId); + if (!current() || !complete?.ready || complete.generation?.startsWith('cached:') || complete.hasOlder || complete.hasNewer) { + throw new Error('The complete task transcript is not available'); + } + return controller.store.snapshot().messages; +} + export function transcriptRestoreTarget( anchor: TranscriptReadingAnchor | undefined, unavailableTurnId: string | undefined, @@ -179,7 +211,7 @@ export function restoreSessionTranscriptRange(options: { return; } const { lookupTurn } = options; - if (range.hasOlder && !command.loaded && lookupTurn) { + if ((range.hasOlder || range.hasNewer) && !command.loaded && lookupTurn) { const loading = {}; command.loading = loading; const current = () => options.lifecycle.isCurrent(command) && options.isCurrent(sessionId, controller); @@ -191,7 +223,7 @@ export function restoreSessionTranscriptRange(options: { void lookupTurn(sessionId, turnId) .then((sequence) => { if (sequence === undefined || !current()) return; - return controller.loadEarlier(sequence); + return controller.seek ? controller.seek(sequence) : controller.loadEarlier(sequence); }) .then( () => { diff --git a/apps/desktop/src/renderer/features/conversation/controller/use-composer-submission.ts b/apps/desktop/src/renderer/features/conversation/controller/use-composer-submission.ts index 1978e7891f7..1b7435865d9 100644 --- a/apps/desktop/src/renderer/features/conversation/controller/use-composer-submission.ts +++ b/apps/desktop/src/renderer/features/conversation/controller/use-composer-submission.ts @@ -85,6 +85,7 @@ export function useComposerSubmission(input: toastApi, shellCopy: getShellCopy(uiLocale).app, uiLocale, + onFollowLatest: commands.prepareSend, }); // Held for the whole of a send; see ChatComposerRegion. const [newTaskSendPending, setNewTaskSendPending] = useState(false); diff --git a/apps/desktop/src/renderer/features/conversation/controller/use-conversation-controller.ts b/apps/desktop/src/renderer/features/conversation/controller/use-conversation-controller.ts index 795cdd4da40..1fc2b007060 100644 --- a/apps/desktop/src/renderer/features/conversation/controller/use-conversation-controller.ts +++ b/apps/desktop/src/renderer/features/conversation/controller/use-conversation-controller.ts @@ -68,12 +68,9 @@ export function useConversationController() { }, settleAssistantStreaming: async (sessionId: string, messageId?: string) => events.current?.settleAssistantStreaming(sessionId, messageId), prepareSend: (sessionId: string) => readingCommands.current?.prepareSend(sessionId) ?? true, - /** - * Copy and Save's export of the published range, rendered as Markdown at - * invocation. Callers get the export, not the published messages. - */ - renderPublishedConversation: (sessionName: string, locale: UiLocale) => - renderConversationMarkdown(sessionName, workspace.commands.readMessages(), locale), + /** Copy and Save's complete history export, read without replacing the displayed window. */ + renderCompleteConversation: async (sessionId: string, sessionName: string, locale: UiLocale) => + renderConversationMarkdown(sessionName, await workspace.commands.readCompleteTranscript(sessionId), locale), }; }); return { workspace, commands, readingCommands, events, interactionHydration }; diff --git a/apps/desktop/src/renderer/features/conversation/controller/use-conversation-observation.ts b/apps/desktop/src/renderer/features/conversation/controller/use-conversation-observation.ts index 0421864baa2..999c3328cad 100644 --- a/apps/desktop/src/renderer/features/conversation/controller/use-conversation-observation.ts +++ b/apps/desktop/src/renderer/features/conversation/controller/use-conversation-observation.ts @@ -34,6 +34,7 @@ export function useConversationObservation(options: { services: ConversationObservationServices; uiLocale: UiLocale; activeId: string | undefined; + initialTranscriptTurnId?: string; observationAuthorityRevision: number; activeIdRef: Readonly>; handleEvent: (sessionId: string, event: SessionEvent) => void; @@ -125,7 +126,7 @@ export function useConversationObservation(options: { })); const controller = options.services.openTranscript(activeId, (error) => { if (!disposed) applyReadError(activeId, error); - }); + }, options.initialTranscriptTurnId); const observation = { sessionId: activeId, controller }; options.observationRef.current = observation; const transcript = controller.store; diff --git a/apps/desktop/src/renderer/features/conversation/controller/use-conversation-target.ts b/apps/desktop/src/renderer/features/conversation/controller/use-conversation-target.ts index 39732fe0938..fb17c226cf3 100644 --- a/apps/desktop/src/renderer/features/conversation/controller/use-conversation-target.ts +++ b/apps/desktop/src/renderer/features/conversation/controller/use-conversation-target.ts @@ -56,7 +56,7 @@ export function useConversationTarget() { clearOwnedSessionState: commands.clearOwnedSessionState, isSessionSelected: commands.isSessionSelected, retiredSessionIds: commands.retiredSessionIds, - renderPublishedConversation: commands.renderPublishedConversation, + renderCompleteConversation: commands.renderCompleteConversation, refreshMessages: commands.refreshMessages, recordSessionChange: commands.recordSessionChange, activeId, diff --git a/apps/desktop/src/renderer/features/conversation/controller/use-shell-resume.ts b/apps/desktop/src/renderer/features/conversation/controller/use-shell-resume.ts index cdaddfbd3c0..a58081dc8f2 100644 --- a/apps/desktop/src/renderer/features/conversation/controller/use-shell-resume.ts +++ b/apps/desktop/src/renderer/features/conversation/controller/use-shell-resume.ts @@ -68,6 +68,7 @@ export function useShellResume(options: { toastApi: ToastApi; shellCopy: ReturnType['app']; uiLocale: UiLocale; + onFollowLatest(sessionId: string): boolean; }): { safeResumeAction: { pending: boolean; detail: string | undefined; onResume(): void } | undefined; composerResumeAction: { pending: boolean; onResume(): void } | undefined; @@ -124,6 +125,7 @@ export function useShellResume(options: { if (!sessionId || resumePendingSessionId !== null) return; setResumePendingSessionId(sessionId); try { + options.onFollowLatest(sessionId); const result = await services.resume.start(sessionId); if (result.disposition === 'park') { tracker.settle(sessionId, false); diff --git a/apps/desktop/src/renderer/features/conversation/model/conversation-workspace.ts b/apps/desktop/src/renderer/features/conversation/model/conversation-workspace.ts index abb35ef92a6..f8328c1df8b 100644 --- a/apps/desktop/src/renderer/features/conversation/model/conversation-workspace.ts +++ b/apps/desktop/src/renderer/features/conversation/model/conversation-workspace.ts @@ -18,7 +18,7 @@ */ import { INITIAL_LIVE_CONTENT_SEED, visibleLiveContentGeneration, type LiveContentSeedState } from './observation-visibility.js'; -import { currentTranscriptRange } from '../controller/transcript-reading-position.js'; +import { currentTranscriptRange, readCompleteTranscript } from '../controller/transcript-reading-position.js'; import { selectLatestRequestUsage } from '../../../application/contracts/session-inspector/latest-request-usage.js'; import type { StoredMessage } from '@maka/core/session'; import type { TransientUserMessageProjection } from '@maka/ui'; @@ -117,6 +117,7 @@ export function createConversationWorkspace(catalog: SessionCatalogController, s removeTransientMessage: transaction(raw.removeTransientMessage), // Commands read on invocation; publication never flows through Shell render. readMessages: (): readonly StoredMessage[] => state.messages, + readCompleteTranscript: (sessionId: string) => readCompleteTranscript(transcriptRangeRef, activeIdRef, sessionId), }; const bootstrapSelectionLease = createBootstrapSelectionLease({ readActiveId: () => activeIdRef.current, diff --git a/apps/desktop/src/renderer/features/conversation/model/transcript-commands.ts b/apps/desktop/src/renderer/features/conversation/model/transcript-commands.ts index 812e122e440..ef12b533a5d 100644 --- a/apps/desktop/src/renderer/features/conversation/model/transcript-commands.ts +++ b/apps/desktop/src/renderer/features/conversation/model/transcript-commands.ts @@ -44,7 +44,9 @@ export function createTranscriptCommands(workspace: ConversationWorkspace, feedb const snapshot = controller.store.snapshot(); if (snapshot.sessionId !== sessionId) return false; ui.clearMessageLoadError(sessionId); - return required === undefined || snapshot.messages.some((message) => message.id === required && workspace.isMessagePublished(message)); + // A parked window has no live bubble; durable proof can settle it + // without publishing or acknowledging the unseen tail. + return required === undefined || snapshot.hasNewer === true || snapshot.messages.some((message) => message.id === required && workspace.isMessagePublished(message)); } catch (error) { if (isCurrent()) reportError(sessionId, error); return false; diff --git a/apps/desktop/src/renderer/features/conversation/testing.ts b/apps/desktop/src/renderer/features/conversation/testing.ts index a763940c099..601053b3b3a 100644 --- a/apps/desktop/src/renderer/features/conversation/testing.ts +++ b/apps/desktop/src/renderer/features/conversation/testing.ts @@ -37,6 +37,7 @@ export function createAppShellSessionUiStateController(initialState?: AppShellSe export { createTranscriptRestoreLifecycle, prepareTranscriptForSend, + readCompleteTranscript, restoreSessionTranscriptRange, } from './controller/transcript-reading-position.js'; export { localMessagePresentation } from './controller/local-message-presentation.js'; diff --git a/apps/desktop/src/renderer/features/conversation/transcript-ports.ts b/apps/desktop/src/renderer/features/conversation/transcript-ports.ts index 022247c64cc..4b3571d7f94 100644 --- a/apps/desktop/src/renderer/features/conversation/transcript-ports.ts +++ b/apps/desktop/src/renderer/features/conversation/transcript-ports.ts @@ -25,14 +25,18 @@ type SessionExecutionProjection = Parameters void): () => void; hasDurableMessage(messageId: string): boolean; }; ready(): Promise; waitForDurableMessage(messageId: string, timeoutMs: number): Promise; loadEarlier(throughSequence?: number): Promise; + loadNewer(): Promise; + seek(sequence: number): Promise; + showLatest(): Promise; + readComplete(): Promise; reload(): Promise; holdsCachedTranscript(): boolean; observationChanged(phase: 'pending' | 'ready'): void; @@ -40,7 +44,7 @@ export interface ConversationTranscriptController { } export interface ConversationObservationServices { - openTranscript(sessionId: string, onError: (error: unknown) => void): ConversationTranscriptController; + openTranscript(sessionId: string, onError: (error: unknown) => void, initialTurnId?: string): ConversationTranscriptController; subscribeEvents( sessionId: string, onEvent: (event: SessionEvent) => void, diff --git a/apps/desktop/src/renderer/features/conversation/ui/conversation-lifecycle.tsx b/apps/desktop/src/renderer/features/conversation/ui/conversation-lifecycle.tsx index e98851b7e9a..4565e4a9bc2 100644 --- a/apps/desktop/src/renderer/features/conversation/ui/conversation-lifecycle.tsx +++ b/apps/desktop/src/renderer/features/conversation/ui/conversation-lifecycle.tsx @@ -107,6 +107,9 @@ export function ConversationLifecycle(props: { useConversationObservation({ services: services.observation, uiLocale, toastApi, activeId: requested?.localState !== 'pending' ? requested?.id : undefined, + initialTranscriptTurnId: props.searchTarget?.sessionId === requestedId + ? props.searchTarget?.turnId + : requestedId ? ui.transcriptReadingAnchorBySessionRef.current[requestedId]?.turnId : undefined, observationAuthorityRevision: authority.current.generation, activeIdRef, transcriptRangeRef, observationRef: workspace.observationRef, handleEvent: handlers.handleEvent, diff --git a/apps/desktop/src/renderer/features/conversation/ui/conversation-readers.tsx b/apps/desktop/src/renderer/features/conversation/ui/conversation-readers.tsx index 17406926fb5..d6f67560cdb 100644 --- a/apps/desktop/src/renderer/features/conversation/ui/conversation-readers.tsx +++ b/apps/desktop/src/renderer/features/conversation/ui/conversation-readers.tsx @@ -48,6 +48,7 @@ type ChatProps = ComponentProps; type TranscriptProps = Pick & { @@ -96,7 +97,10 @@ export function ConversationTranscriptRegion

( onRetryMessages: sessionId ? () => { void commands.retryMessages(sessionId); } : undefined, viewportNavigation: workspace.ui.transcriptViewportNavigation, hasEarlierHistory: view.range?.sessionId === sessionId ? view.range?.hasOlder : undefined, + hasLaterHistory: view.range?.sessionId === sessionId ? view.range?.hasNewer : undefined, onLoadEarlierHistory: () => readingCommands.current?.loadEarlier(), + onLoadLaterHistory: () => readingCommands.current?.loadNewer(), + onLoadLatestHistory: () => readingCommands.current?.showLatest(), transcriptTurnIndex: view.turnIndex?.sessionId === sessionId ? view.turnIndex?.turns : undefined, onLoadTranscriptTurn: (turn) => readingCommands.current?.loadEarlier(turn.sequence), restoreTargetTurn: transcriptRestoreTarget(sessionId ? workspace.ui.transcriptReadingAnchorBySessionRef.current[sessionId] : undefined, load.unavailableTranscriptRestore), diff --git a/apps/desktop/src/renderer/features/workhub/controller/use-workhub-controller.ts b/apps/desktop/src/renderer/features/workhub/controller/use-workhub-controller.ts index 70e4923d4c6..e10a220448e 100644 --- a/apps/desktop/src/renderer/features/workhub/controller/use-workhub-controller.ts +++ b/apps/desktop/src/renderer/features/workhub/controller/use-workhub-controller.ts @@ -52,6 +52,7 @@ import type { WorkHubServices, WorkHubTranscript, WorkHubTranscriptSnapshot } fr const emptyTranscript: WorkHubTranscriptSnapshot = { messages: [], hasOlder: false, + historyComplete: false, ready: false, }; interface SendAttempt { @@ -103,6 +104,7 @@ export function useWorkHubController( const [error, setError] = useState(); const [modelSetupRequired, setModelSetupRequired] = useState(false); const [readError, setReadError] = useState(); + const [historyError, setHistoryError] = useState(); const [readRevision, setReadRevision] = useState(0); const retryResolution = useRef<() => void>(() => undefined); const refreshSessions = useRef<() => void>(() => undefined); @@ -336,6 +338,7 @@ export function useWorkHubController( settledBeforePublication.current.clear(); setTranscript(emptyTranscript); setReadError(undefined); + setHistoryError(undefined); const attempt = pendingSend.current; const pending = attempt && attempt.sessionId === sessionId && attempt.admission !== 'terminal' && attempt.admission !== 'rejected' ? attempt : undefined; setExecution(undefined); @@ -470,6 +473,30 @@ export function useWorkHubController( }, (projection) => { if (!disposed) setExecution(projection); }, ); + let fullHistoryRequested = false; + const loadFullHistory = () => { + if ( + disposed || + fullHistoryRequested || + !handle || + !transcriptRef.current.ready || + !transcriptRef.current.hasOlder + ) return; + fullHistoryRequested = true; + void handle.loadEarlier(0).then(() => { + if (disposed) return; + if (transcriptRef.current.historyComplete) { + setHistoryError(undefined); + return; + } + fullHistoryRequested = false; + setHistoryError(workHubLiveCopy[localeRef.current].historyIncomplete); + }, () => { + if (disposed) return; + fullHistoryRequested = false; + setHistoryError(workHubLiveCopy[localeRef.current].historyIncomplete); + }); + }; const opening = services.openTranscript(sessionId, (snapshot) => { if (disposed) return; const attempt = pendingSend.current; @@ -483,6 +510,8 @@ export function useWorkHubController( message.type === 'user' && message.id === queued.messageId)) queued.observed = true; transcriptRef.current = snapshot; setTranscript(snapshot); + if (snapshot.historyComplete) setHistoryError(undefined); + loadFullHistory(); if (snapshot.ready && observationPhase === 'ready') setReadError(undefined); setMessagePresentation((previous) => ({ ...previous, transientMessages: previous.transientMessages.filter((pending) => !snapshot.messages.some((message) => message.type === 'user' && @@ -503,6 +532,7 @@ export function useWorkHubController( else { range.current = opened; opened.observationChanged(observationPhase); + loadFullHistory(); } }) .catch(readFailed); @@ -673,6 +703,14 @@ export function useWorkHubController( const queuedEntryDraft = sessionId ? { retract: deleteQueuedEntry, restoreDraft: (draft: RestoredDraftContent) => restoreDraft(sessionId, draft) } : undefined; + const pendingAttempt = pendingSend.current; + const retryableSendError = + error && + pendingAttempt && + pendingAttempt.sessionId === sessionId && + (pendingAttempt.admission === 'unknown' || pendingAttempt.admission === 'rejected') + ? error + : undefined; return { services, sessionId, @@ -712,10 +750,10 @@ export function useWorkHubController( busy, sending, stopPending, - error: readError ?? error, + error: retryableSendError ?? historyError ?? readError ?? error, modelSetupRequired, modelSetupChoicesReady, - canRetry: Boolean(readError || (!sessionId && error) || (error && (pendingSend.current?.admission === 'unknown' || pendingSend.current?.admission === 'rejected'))), + canRetry: Boolean(historyError || readError || (!sessionId && error) || retryableSendError), send, stop, changeModel, @@ -730,13 +768,14 @@ export function useWorkHubController( }, retry: () => { const attempt = pendingSend.current; - if (readError && sessionId) { - setReadError(undefined); - setReadRevision((revision) => revision + 1); - } else if (attempt && attempt.sessionId === sessionId && attempt.admission === 'unknown') { + if (retryableSendError && attempt && attempt.sessionId === sessionId && attempt.admission === 'unknown') { void recoverSend(); - } else if (attempt && attempt.sessionId === sessionId && attempt.admission === 'rejected') { + } else if (retryableSendError && attempt && attempt.sessionId === sessionId && attempt.admission === 'rejected') { void send(attempt.input.text, attempt.input.attachments ?? []); + } else if ((historyError || readError) && sessionId) { + setHistoryError(undefined); + setReadError(undefined); + setReadRevision((revision) => revision + 1); } else retryResolution.current(); }, loadEarlier: () => range.current?.loadEarlier(), diff --git a/apps/desktop/src/renderer/features/workhub/locales/workhub-live-copy.ts b/apps/desktop/src/renderer/features/workhub/locales/workhub-live-copy.ts index 4702981ebf1..b77464f099f 100644 --- a/apps/desktop/src/renderer/features/workhub/locales/workhub-live-copy.ts +++ b/apps/desktop/src/renderer/features/workhub/locales/workhub-live-copy.ts @@ -38,6 +38,7 @@ export const workHubLiveCopy = resolveUiMessageCatalog(defineShippedUiMessageCat collapseConversation: 'Collapse conversation', expandConversation: 'Expand conversation', retry: 'Retry', selectModelToSend: 'Choose a model before sending.', configureModelToSend: 'Configure an available model before sending.', loadingModels: 'Loading models…', noModelsAvailable: 'No usable models are available.', + historyIncomplete: 'Could not load the complete task history. Retry to restore WorkHub links and filters.', reloadRequired: 'WorkHub needs to reload', welcome: 'What can I help with?', hint: 'Ask a question, manage your tasks, or ask me to work in Maka.', @@ -62,6 +63,7 @@ export const workHubLiveCopy = resolveUiMessageCatalog(defineShippedUiMessageCat collapseConversation: '收起对话', expandConversation: '展开对话', retry: '重试', selectModelToSend: '请先选择模型', configureModelToSend: '请先配置可用模型', loadingModels: '正在加载模型…', noModelsAvailable: '暂无可用模型', + historyIncomplete: '无法加载完整任务历史。请重试以恢复 WorkHub 关联和筛选。', reloadRequired: '工作台需要重新加载', welcome: '有什么可以帮你?', hint: '问个问题、管理任务,或让我帮你操作 Maka。', @@ -86,6 +88,7 @@ export const workHubLiveCopy = resolveUiMessageCatalog(defineShippedUiMessageCat collapseConversation: '收起對話', expandConversation: '展開對話', retry: '重試', selectModelToSend: '請先選擇模型', configureModelToSend: '請先設定可用模型', loadingModels: '正在載入模型…', noModelsAvailable: '暫無可用模型', + historyIncomplete: '無法載入完整任務歷史。請重試以恢復 WorkHub 關聯與篩選。', reloadRequired: '工作台需要重新載入', welcome: '有什麼可以幫你?', hint: '問個問題、管理任務,或讓我幫你操作 Maka。', diff --git a/apps/desktop/src/renderer/features/workhub/ports.ts b/apps/desktop/src/renderer/features/workhub/ports.ts index 59a81bcf963..2d50d00aa9a 100644 --- a/apps/desktop/src/renderer/features/workhub/ports.ts +++ b/apps/desktop/src/renderer/features/workhub/ports.ts @@ -32,11 +32,12 @@ import type { WorkHubWorkspaceServices } from '../../application/contracts/workh export interface WorkHubTranscriptSnapshot { readonly messages: readonly StoredMessage[]; readonly hasOlder: boolean; + readonly historyComplete: boolean; readonly ready: boolean; } export interface WorkHubTranscript { observationChanged(phase: 'pending' | 'ready'): void; - loadEarlier(): Promise; + loadEarlier(throughSequence?: number): Promise; close(): Promise; } export interface WorkHubServices extends WorkHubWorkspaceServices { diff --git a/apps/desktop/src/renderer/features/workhub/ui/workhub-root.tsx b/apps/desktop/src/renderer/features/workhub/ui/workhub-root.tsx index e6eb0d74eae..9b46c4e2487 100644 --- a/apps/desktop/src/renderer/features/workhub/ui/workhub-root.tsx +++ b/apps/desktop/src/renderer/features/workhub/ui/workhub-root.tsx @@ -274,7 +274,12 @@ export function WorkHubRoot() { focus(); }; }, [services]); - const links = useMemo(() => workHubLinkedWork(transcript.messages, controller.sessions, t.work, controller.sessionId), [transcript.messages, controller.sessions, t.work, controller.sessionId]); + const links = useMemo( + () => transcript.historyComplete + ? workHubLinkedWork(transcript.messages, controller.sessions, t.work, controller.sessionId) + : [], + [transcript.historyComplete, transcript.messages, controller.sessions, t.work, controller.sessionId], + ); const delegatedSessionIds = links.map((link) => link.targetSessionId); const call = (task: Promise) => { void task.catch(controller.report); diff --git a/apps/desktop/src/renderer/platform/desktop/create-conversation-observation-services.ts b/apps/desktop/src/renderer/platform/desktop/create-conversation-observation-services.ts index b0f86502831..b42025abb68 100644 --- a/apps/desktop/src/renderer/platform/desktop/create-conversation-observation-services.ts +++ b/apps/desktop/src/renderer/platform/desktop/create-conversation-observation-services.ts @@ -25,12 +25,12 @@ export function createDesktopConversationObservationServices( bridge: Pick = window.maka, ): ConversationObservationServices { return { - openTranscript(sessionId, onError) { + openTranscript(sessionId, onError, initialTurnId) { const store = new DesktopTranscriptRangeStore(sessionId); return createDesktopTranscriptRangeController(store, openDesktopTranscriptHistory(bridge.transcripts.open, sessionId, (batch) => { try { store.accept(batch); } catch (error) { onError(error); } - }), { onError }); + }), { onError, initialTurnId }); }, subscribeEvents: (...args) => bridge.sessions.subscribeEvents(...args), listActiveInteractions: (sessionId) => bridge.sessions.listActiveInteractions(sessionId), diff --git a/apps/desktop/src/renderer/platform/desktop/create-workhub-services.ts b/apps/desktop/src/renderer/platform/desktop/create-workhub-services.ts index 8d0a349fcf3..6240c4dd714 100644 --- a/apps/desktop/src/renderer/platform/desktop/create-workhub-services.ts +++ b/apps/desktop/src/renderer/platform/desktop/create-workhub-services.ts @@ -129,7 +129,17 @@ export function createDesktopWorkHubServices( }, async openTranscript(sessionId, handler, cancellation, onError) { const store = new DesktopTranscriptRangeStore(sessionId); - const unsubscribe = store.subscribe(() => handler(store.snapshot())); + const unsubscribe = store.subscribe(() => { + const snapshot = store.snapshot(); + handler({ + ...snapshot, + historyComplete: + snapshot.ready && + !snapshot.hasOlder && + !snapshot.hasNewer && + !snapshot.generation.startsWith('cached:'), + }); + }); const controller = createDesktopTranscriptRangeController( store, openDesktopTranscriptHistory(bridge.transcripts.open, sessionId, (batch) => store.accept(batch)), @@ -140,7 +150,7 @@ export function createDesktopWorkHubServices( if (cancellation.aborted) cancel(); return { observationChanged: controller.observationChanged, - loadEarlier: () => controller.loadEarlier(), + loadEarlier: (throughSequence) => controller.loadEarlier(throughSequence), close: () => { cancellation.removeEventListener('abort', cancel); unsubscribe(); diff --git a/apps/desktop/src/renderer/platform/desktop/desktop-transcript-range-store.ts b/apps/desktop/src/renderer/platform/desktop/desktop-transcript-range-store.ts index 14268912b26..786ee33ce92 100644 --- a/apps/desktop/src/renderer/platform/desktop/desktop-transcript-range-store.ts +++ b/apps/desktop/src/renderer/platform/desktop/desktop-transcript-range-store.ts @@ -25,6 +25,7 @@ import type { DesktopTranscriptBatchPayload, DesktopTranscriptFragment, DesktopTranscriptHandle, + DesktopTranscriptPosition, } from '../../../preload/transcript-contract.js'; import { projectDesktopStoredMessage } from '../../../shared/desktop-session-projection.js'; import { parseDesktopSessionKey } from '../../../shared/runtime-host-identity.js'; @@ -39,6 +40,10 @@ export interface DesktopTranscriptRangeController { waitForDurableMessage(messageId: string, timeoutMs: number): Promise; /** One budget of earlier history, or everything down to `throughSequence` in one answer. */ loadEarlier(throughSequence?: number): Promise; + loadNewer(): Promise; + seek(sequence: number): Promise; + showLatest(): Promise; + readComplete(): Promise; reload(): Promise; /** Whether the range holds the cached transcript, over which a failed reload is not reported. */ holdsCachedTranscript(): boolean; @@ -121,11 +126,11 @@ export function openDesktopTranscriptHistory( open: MakaBridge['transcripts']['open'], sessionId: string, accept: (batch: DesktopTranscriptBatch) => void, -): (signal: AbortSignal, resumeFrom?: number) => Promise { - return (signal, resumeFrom) => open( +): DesktopTranscriptOpener { + return (signal, resumeFrom, position, receive = accept) => open( sessionId, (batch) => { - if (!signal.aborted) accept(batch); + if (!signal.aborted) receive(batch); }, (cancel) => { if (signal.aborted) cancel(); @@ -133,18 +138,29 @@ export function openDesktopTranscriptHistory( }, 'history', resumeFrom, + position, ); } +type DesktopTranscriptOpener = ( + signal: AbortSignal, + resumeFrom?: number, + position?: DesktopTranscriptPosition, + receive?: (batch: DesktopTranscriptBatch) => void, +) => Promise; + export function createDesktopTranscriptRangeController( store: DesktopTranscriptRangeStore, /** `resumeFrom` is the oldest sequence held, which a reopen must read back down to. */ - open: (signal: AbortSignal, resumeFrom?: number) => Promise, - options: { onError(error: unknown): void }, + open: DesktopTranscriptOpener, + options: { onError(error: unknown): void; initialTurnId?: string }, ): DesktopTranscriptRangeController { let closed = false; + let positioning = Boolean(options.initialTurnId); + let latest: Promise | undefined; let openController = new AbortController(); - let handle = open(openController.signal); + let handle = open(openController.signal, undefined, + options.initialTurnId ? { turnId: options.initialTurnId } : undefined); const current = async () => { if (closed) throw new Error('Desktop transcript range is closed'); return handle; @@ -167,7 +183,7 @@ export function createDesktopTranscriptRangeController( let acknowledged: number | undefined; const acknowledgeTail = () => { const held = range(); - if (!held?.ready || held.durableThrough === null || held.generation.startsWith('cached:')) return; + if (!held?.ready || held.hasNewer || held.durableThrough === null || held.generation.startsWith('cached:')) return; const through = held.durableThrough; if (acknowledged === through) return; acknowledged = through; @@ -179,23 +195,36 @@ export function createDesktopTranscriptRangeController( } })(); }; - const reopen = async () => { + const replace = async (resumeFrom?: number, position?: DesktopTranscriptPosition) => { + positioning = position !== undefined; const previous = handle; acknowledged = undefined; openController.abort(); - const held = range(); - const resumeFrom = held?.ready ? held.oldestSequence ?? undefined : undefined; + const cancellation = new AbortController(); + openController = cancellation; const replacement = previous .then((value) => value.close()) .catch(() => undefined) .then(() => { - if (closed) throw new Error('Desktop transcript range is closed'); - openController = new AbortController(); - return open(openController.signal, resumeFrom); + if (closed || cancellation.signal.aborted) throw new Error('Desktop transcript read was superseded'); + return open(cancellation.signal, resumeFrom, position); }); handle = replacement; - await replacement; - requireLive(); + try { + await replacement; + if (!cancellation.signal.aborted) { + requireLive(); + positioning = false; + } + } catch (error) { + if (!cancellation.signal.aborted) throw error; + } + }; + const reopen = () => { + const held = range(); + return held?.ready && held.hasNewer && held.oldestSequence !== null && held.durableThrough !== null + ? replace(undefined, { sequence: held.oldestSequence, throughSequence: held.durableThrough, hasOlder: held.hasOlder }) + : replace(held?.ready ? held.oldestSequence ?? undefined : undefined); }; const recovery = createDesktopTranscriptReconnectRecovery({ reload: reopen, @@ -203,11 +232,12 @@ export function createDesktopTranscriptRangeController( if (!cached()) options.onError(error); }, }); - // A reopen can land on the cached transcript; recovery must still hear of it. - const reload = () => reopen().catch((error: unknown) => { + // A read can fail or land on cached data; recovery must still hear of it. + const readFailed = (error: unknown): never => { recovery.transcriptFailed(error); throw error; - }); + }; + const reload = () => reopen().catch(readFailed); let gapReload: Promise | undefined; const unsubscribe = store.subscribe(() => { acknowledgeTail(); @@ -216,8 +246,17 @@ export function createDesktopTranscriptRangeController( .catch(() => undefined) .finally(() => { gapReload = undefined; }); }); - void handle.then(requireLive).catch(recovery.transcriptFailed); + const initial = handle; + void initial.then(() => { + if (!closed && handle === initial) { + requireLive(); + positioning = false; + } + }).catch((error) => { + if (!closed && handle === initial) recovery.transcriptFailed(error); + }); let earlier: Promise | undefined; + let newer: Promise | undefined; const loadEarlier = (throughSequence?: number): Promise => { if (earlier) { return throughSequence === undefined ? earlier : earlier.then(() => loadEarlier(throughSequence)); @@ -239,14 +278,86 @@ export function createDesktopTranscriptRangeController( earlier = task; return task; }; + // Explicit complete reads and off-window settlement reuse the same reader; + // they neither replace the visible window nor acknowledge the tail as read. + const readDetached = async ( + floor?: number, + waitFor?: { messageId: string; timeoutMs: number }, + ): Promise => { + const detached = new DesktopTranscriptRangeStore(store.sessionId); + let reader: DesktopTranscriptHandle | undefined; + const parent = openController.signal; + const cancellation = new AbortController(); + const cancel = () => cancellation.abort(); + const signal = cancellation.signal; + if (parent.aborted) cancel(); + else parent.addEventListener('abort', cancel, { once: true }); + try { + reader = await open(signal, floor, undefined, (batch) => detached.accept(batch)); + const result = detached.snapshot(); + if (signal.aborted || !result.ready || result.hasNewer || result.generation.startsWith('cached:')) { + throw new Error('The requested task transcript is not available'); + } + if (waitFor) await detached.waitForDurableMessage(waitFor.messageId, waitFor.timeoutMs); + if (signal.aborted) throw new Error('Desktop transcript read was superseded'); + return detached.snapshot(); + } finally { + parent.removeEventListener('abort', cancel); + try { await reader?.close(); } finally { cancel(); } + } + }; return { store, async ready() { await current(); }, async waitForDurableMessage(messageId, timeoutMs) { - await current(); - return store.waitForDurableMessage(messageId, timeoutMs); + const signal = openController.signal; + try { + await current(); + if (signal.aborted) return false; + if (range()?.hasNewer) { + return (await readDetached(undefined, { messageId, timeoutMs })).messages.some((message) => message.id === messageId); + } + return store.waitForDurableMessage(messageId, timeoutMs); + } catch (error) { + // Navigation supersedes this background confirmation, not the send. + if (signal.aborted) return false; + throw error; + } }, loadEarlier, + loadNewer() { + if (newer) return newer; + if (!range()?.hasNewer || cached()) return Promise.resolve(); + const reading = handle; + const task = current().then((value) => { + if (!value.loadNewer) throw new Error('Forward transcript reading is unavailable'); + return value.loadNewer(); + }).catch((error: unknown) => { + if (!closed && reading === handle) { options.onError(error); throw error; } + }).finally(() => { if (newer === task) newer = undefined; }); + newer = task; + return task; + }, + seek(sequence) { + latest = undefined; + return replace(undefined, { sequence }).catch(readFailed); + }, + showLatest() { + if (latest) return latest; + if (!positioning && !range()?.hasNewer) return current().then(() => undefined); + const task = replace().catch(readFailed).finally(() => { if (latest === task) latest = undefined; }); + latest = task; + return task; + }, + async readComplete() { + await current(); + requireLive(); + const held = store.snapshot(); + if (held.ready && !held.hasOlder && !held.hasNewer) return held.messages; + const result = await readDetached(0); + if (result.hasOlder) throw new Error('The complete task transcript is not available'); + return result.messages; + }, reload, holdsCachedTranscript: cached, observationChanged: recovery.observationChanged, @@ -280,6 +391,7 @@ export interface DesktopTranscriptRangeState { readonly oldestSequence: number | null; /** Earlier durable history exists that the Renderer has not loaded. */ readonly hasOlder: boolean; + readonly hasNewer: boolean; /** * Whether the oldest Turn held has all its rows. An answer is bounded by * bytes, so it can begin inside a Turn, and no local rule says that it did. @@ -297,6 +409,7 @@ interface TranscriptValue { readonly rows: ReadonlyMap; readonly order: readonly number[]; readonly hasOlder: boolean; + readonly hasNewer: boolean; readonly beginsAtTurnBoundary: boolean; readonly through: number | null; } @@ -314,6 +427,7 @@ interface TranscriptAssembly { readonly coversFrom: number | null | undefined; durableThrough: number | null; hasOlder: boolean | undefined; + hasNewer: boolean | undefined; beginsAtTurnBoundary: boolean | undefined; readonly fragments: Map; readonly rows: Map; @@ -323,6 +437,7 @@ const EMPTY_VALUE: TranscriptValue = { rows: new Map(), order: [], hasOlder: false, + hasNewer: false, beginsAtTurnBoundary: true, through: null, }; @@ -380,6 +495,7 @@ export class DesktopTranscriptRangeStore { coversFrom: batch.coversFrom, durableThrough: batch.durableThrough, hasOlder: undefined, + hasNewer: undefined, beginsAtTurnBoundary: undefined, fragments: new Map(), rows: new Map(), @@ -387,6 +503,7 @@ export class DesktopTranscriptRangeStore { this.#assembly = assembly; } if (batch.hasOlder !== undefined) assembly.hasOlder = batch.hasOlder; + if (batch.hasNewer !== undefined) assembly.hasNewer = batch.hasNewer; if (batch.beginsAtTurnBoundary !== undefined) { assembly.beginsAtTurnBoundary = batch.beginsAtTurnBoundary; } @@ -426,6 +543,7 @@ export class DesktopTranscriptRangeStore { answer.hasOlder ?? false, answer.beginsAtTurnBoundary ?? true, answer.durableThrough, + answer.hasNewer ?? false, ); } if (answer.kind === 'earlier') { @@ -435,6 +553,7 @@ export class DesktopTranscriptRangeStore { answer.hasOlder ?? value.hasOlder, answer.beginsAtTurnBoundary ?? value.beginsAtTurnBoundary, value.through, + value.hasNewer, ); } if (!this.#ready || answer.coversFrom !== value.through) return undefined; @@ -443,6 +562,7 @@ export class DesktopTranscriptRangeStore { value.hasOlder, value.beginsAtTurnBoundary, answer.durableThrough ?? value.through, + answer.hasNewer ?? value.hasNewer, ); } @@ -480,6 +600,7 @@ export class DesktopTranscriptRangeStore { // A cached snapshot cannot serve earlier reads; the live answer replaces // it rather than continuing it. hasOlder: this.#value.hasOlder && !this.#generation.startsWith('cached:'), + hasNewer: this.#value.hasNewer, beginsAtTurnBoundary: this.#value.beginsAtTurnBoundary, ready: this.#ready, }; @@ -591,11 +712,13 @@ function makeValue( hasOlder: boolean, beginsAtTurnBoundary: boolean, through: number | null, + hasNewer = false, ): TranscriptValue { return { rows, order: [...rows.keys()].sort((left, right) => left - right), hasOlder, + hasNewer, beginsAtTurnBoundary, through, }; diff --git a/apps/desktop/stories/app-shell.stories.tsx b/apps/desktop/stories/app-shell.stories.tsx index fb7a62ba068..6e842bced04 100644 --- a/apps/desktop/stories/app-shell.stories.tsx +++ b/apps/desktop/stories/app-shell.stories.tsx @@ -3073,14 +3073,18 @@ function SettledTranscriptHarness({ * `HISTORY_BATCH` Turns. The loader settles a frame later, the way an answer * delivered over IPC does. */ +let releaseHistoryPage: (() => void) | undefined; + function HistoryHarness({ turns, olderTurns = 0, mixed = false, -}: { turns: number; olderTurns?: number; mixed?: boolean }) { + holdFirstPage = false, +}: { turns: number; olderTurns?: number; mixed?: boolean; holdFirstPage?: boolean }) { const [from, setFrom] = useState(0); useEffect(() => { historyLoads.length = 0; + releaseHistoryPage = undefined; }, []); return ( -olderTurns, onLoadEarlierHistory: async () => { historyLoads.push(firstResidentTurnId() ?? '(none)'); + // A pending remote page lets the story measure the reading anchor + // before delivering the same production history update. + if (holdFirstPage && historyLoads.length === 1) { + await new Promise((resolve) => { releaseHistoryPage = resolve; }); + } setFrom((current) => Math.max(-olderTurns, current - HISTORY_BATCH)); await painted(2); }, @@ -3231,21 +3240,39 @@ export const DockAffordanceReturnsToTail: Story = { }, }; +// Real path: scroll a tool's nested output near the beginning of loaded conversation history. export const NestedScrollerNearHistoryBoundaryAsksForNothing: Story = { render: () => , play: async () => { await tailSettled(); const nested = injectNestedScroller(messageList()); - scrollAsReader(tailScroller(), 0); + const root = tailScroller(); + // End the setup's real scroll before moving the viewport without reader + // input. An early synthetic scrollend can precede its queued scroll event + // and leave that upward gesture active during the boundary setup. + const setupScrollEnded = new Promise((resolve) => { + const onEnd = (event: Event) => { + if (event.target !== root) return; + root.removeEventListener('scrollend', onEnd); + resolve(); + }; + root.addEventListener('scrollend', onEnd); + }); + expect(scrollAsReader(root, root.clientHeight * 2)).not.toBe(0); + await setupScrollEnded; + await painted(2); + root.scrollTop = 0; + root.dispatchEvent(new Event('scroll')); await painted(6); + expect(historyLoads, 'preparing the boundary without reader input must not load history').toEqual([]); wheelUp(nested); - wheelUp(tailScroller()); await painted(6); - // Scrolling never loads history; only the explicit control does. expect(historyLoads).toEqual([]); expect(nested.scrollTop).toBe(600); + wheelUp(root); + await waitFor(() => expect(historyLoads).toHaveLength(1)); }, }; @@ -3578,8 +3605,9 @@ export const UpwardTraversalHoldsTurnGeometry: Story = { }, }; +// Real path: open a long conversation, scroll upward, and receive an older history page. export const HistoryAtTheTopStillLandsAboveTheReader: Story = { - render: () => , + render: () => , play: async () => { const root = tailScroller(); // Measure history publication against rendered content, not the cold @@ -3593,8 +3621,8 @@ export const HistoryAtTheTopStillLandsAboveTheReader: Story = { expect(settled.scrollTop, JSON.stringify(settled)).toBeGreaterThan(0); expect(settled.distance, JSON.stringify(settled)).toBeLessThanOrEqual(4); }); - // The one position where the browser declines to anchor, and the one the - // load-earlier control sits at. Rows mounted by the scroll still carry the + expect(historyLoads).toEqual([]); + // The one position where the browser declines to anchor. Rows mounted by the scroll still carry the // cold Markdown layout — measure the anchor only after they settle, the // same rule as the tail state above. scrollAsReader(root, 0); @@ -3602,13 +3630,16 @@ export const HistoryAtTheTopStillLandsAboveTheReader: Story = { await painted(4); const before = firstResidentTurnId(); const reading = anchorInView(); - within(document.body).getByRole('button', { name: '载入更早的记录' }).click(); + await waitFor(() => expect(releaseHistoryPage).toBeDefined()); + releaseHistoryPage!(); await waitFor(() => expect(firstResidentTurnId()).not.toBe(before)); await waitFor(() => expect(document.querySelector('.maka-markdown-pending')).toBeNull()); await painted(6); - expect(historyLoads).toEqual([before]); + // The request can precede the virtualizer mounting the rows at the new + // scroll offset; its previous first DOM row is not the history boundary. + expect(historyLoads).toHaveLength(1); expect(Math.abs(turnTop(reading.turnId) - reading.top)).toBeLessThanOrEqual(1); expect(within(document.body).queryByRole('button', { name: '载入更早的记录' })).toBeNull(); }, @@ -3657,11 +3688,12 @@ async function measureEveryRow(): Promise { * batch along: the document's extent goes wrong, and rows the reader has * already been past push them when they come back. */ +// Real path: scroll into older history, then revisit already measured Turns in both directions. export const PrependedHistoryKeepsMeasuredHeights: Story = { // Shallower than the other history stories: this one walks the whole // transcript twice, and a slid measurement shows on the first row past the // prepend as well as on the hundredth. - render: () => , + render: () => , play: async () => { const root = tailScroller(); await document.fonts.ready; @@ -3673,7 +3705,8 @@ export const PrependedHistoryKeepsMeasuredHeights: Story = { await measureEveryRow(); const before = firstResidentTurnId(); - within(document.body).getByRole('button', { name: '载入更早的记录' }).click(); + await waitFor(() => expect(releaseHistoryPage).toBeDefined()); + releaseHistoryPage!(); await waitFor(() => expect(firstResidentTurnId()).not.toBe(before)); await waitFor(() => expect(document.querySelector('.maka-markdown-pending')).toBeNull()); await painted(6); @@ -4813,7 +4846,9 @@ export const CompletedProcessCollapsed: Story = { await expect(process!.open).toBe(false); const answer = await within(canvasElement).findByText('已修复登录状态恢复。'); await expect(answer).toBeVisible(); - await expect(await within(canvasElement).findByText('我先检查登录状态的存储和恢复逻辑。')).not.toBeVisible(); + // A completed process that has never been opened does not mount its body. + await expect(within(canvasElement).queryByText('我先检查登录状态的存储和恢复逻辑。')).toBeNull(); + await expect(process!.querySelector('.maka-processing-body')!.childElementCount).toBe(0); const summary = process!.querySelector('summary')!; await expect(getComputedStyle(process!).borderTopWidth).toBe('0px'); await expect(getComputedStyle(process!).backgroundColor).toBe('rgba(0, 0, 0, 0)'); diff --git a/apps/desktop/stories/workhub.stories.tsx b/apps/desktop/stories/workhub.stories.tsx index 7475765cbb6..61bcfc30aa0 100644 --- a/apps/desktop/stories/workhub.stories.tsx +++ b/apps/desktop/stories/workhub.stories.tsx @@ -74,7 +74,7 @@ function makeServices(failFirst: boolean, withHistory: boolean | 'usage', colore let questionPending = question; let pendingForm: import('@maka/core/events').FormRequestEvent | undefined; const publishExecution = () => updateExecution?.({ type: 'host_execution', available: true, rootTurn: pendingForm ? { sessionId, turnId: pendingForm.turnId, runId: 'selection-run', status: 'waiting_for_user' } : questionPending ? { sessionId, turnId: 'question-turn', runId: 'question-run', status: 'waiting_for_user' } : null }); - const publish = () => { publishExecution(); updateTranscript?.({ messages, hasOlder: false, ready: true }); }; + const publish = () => { publishExecution(); updateTranscript?.({ messages, hasOlder: false, historyComplete: true, ready: true }); }; return { inspector: { context: async () => ({ ok: true, data: { status: 'available', completedAt: 1, modelId: session.model, providerId: 'openai', inputTokens: 1000, contextWindow: 100_000 } }), @@ -576,13 +576,19 @@ function PagedWorkConversation() { ; } +// Real path: filter a WorkHub conversation to one Work and scroll upward into its older Turns. export const FilterWorkHistoryPages: Story = { render: () => , play: async ({ canvasElement }) => { const canvas = within(canvasElement); await waitFor(() => expect(canvas.getByText('继续补充异常场景。')).toBeInTheDocument()); expect(canvas.queryByText('请检查支付回调幂等性。')).toBeNull(); - await userEvent.click(canvas.getByRole('button', { name: '载入更早的记录' })); + expect(canvas.getByRole('button', { name: '载入更早的记录' })).toBeInTheDocument(); + const scroller = canvasElement.querySelector('[data-chat-scroll-container]'); + expect(scroller).not.toBeNull(); + scroller!.dispatchEvent(new WheelEvent('wheel', { bubbles: true, deltaY: -120 })); + scroller!.scrollTop = 0; + scroller!.dispatchEvent(new Event('scroll')); await waitFor(() => expect(canvas.getByText('请检查支付回调幂等性。')).toBeInTheDocument()); expect(canvas.queryByText('先讨论一下整体计划。')).toBeNull(); expect(canvas.queryByRole('button', { name: '载入更早的记录' })).toBeNull(); diff --git a/packages/runtime/src/__tests__/session-manager-shell-resources.test.ts b/packages/runtime/src/__tests__/session-manager-shell-resources.test.ts new file mode 100644 index 00000000000..94279c73b70 --- /dev/null +++ b/packages/runtime/src/__tests__/session-manager-shell-resources.test.ts @@ -0,0 +1,321 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test, { type TestContext } from 'node:test'; +import type { ShellRunRecord } from '@maka/core/shell-run'; +import type { SessionHeaderPatch } from '@maka/core/session'; +import type { RuntimeEvent } from '@maka/core/runtime-event'; +import { openInteractiveExecutionStoresForWrite } from '@maka/storage/execution-stores'; +import { resolveStorageRoot, tryAcquireInteractiveRootOwner } from '@maka/storage/root-authority'; +import { openInteractiveShellRunStoreForWrite } from '@maka/storage/shell-run-authority'; +import { BackendRegistry, SessionManager } from '../session-manager.js'; +import { ShellRunProcessManager } from '../shell-run-manager.js'; +import { shellRunContent, shellRunUpdate } from '../shell-run-tool-result.js'; +import { seedInvocation } from './invocation-fixture.js'; + +test('root resource reads preserve own states without reading transcript history', async (t) => { + const f = await fixture(t); + const session = await f.session(); + const records = ['starting', 'running', 'completed', 'cancelled'].map((status, index) => + record(session.id, `own-${index}`, { + status: status as ShellRunRecord['status'], + ...(status === 'completed' ? { completedAt: 2, exitCode: 0 } : {}), + ...(status === 'cancelled' ? { completedAt: 2, exitCode: 130 } : {}), + }), + ); + records[0]!.visibility = 'user'; + for (const item of records) await f.shellStore.createShellRun(item); + const noHistory = t.mock.method(f.manager, 'getMessages', async () => { + throw new Error('Resource query must not read transcript history'); + }); + + assert.deepEqual(await f.manager.listShellRunUpdates(session.id), records.map(shellRunUpdate)); + for (const item of records) { + assert.deepEqual( + await f.manager.getShellRunUpdate(session.id, shellRunContent(item).ref), + shellRunUpdate(item), + ); + } + assert.equal( + await f.manager.getShellRunUpdate( + session.id, + shellRunContent(record(session.id, 'missing')).ref, + ), + null, + ); + assert.deepEqual(await f.manager.listShellRunUpdates((await f.session()).id), []); + assert.equal(noHistory.mock.callCount(), 0); +}); + +test('a failed lineage read is not an empty resource answer, while own lookup stays independent', async (t) => { + const f = await fixture(t); + const session = await f.session(); + const own = record(session.id, 'own'); + await f.shellStore.createShellRun(own); + const manager = new SessionManager({ + ...f.options, + store: { + ...f.stores.sessionStore, + readHeader: async () => { + throw new Error('header unavailable'); + }, + }, + }); + assert.deepEqual( + await manager.getShellRunUpdate(session.id, shellRunContent(own).ref), + shellRunUpdate(own), + ); + await assert.rejects(manager.listShellRunUpdates(session.id), /header unavailable/); + await assert.rejects( + manager.getShellRunUpdate(session.id, shellRunContent(record(session.id, 'missing')).ref), + /header unavailable/, + ); +}); + +test('root resources do not declare staged transcript history ready', async (t) => { + const f = await fixture(t); + const session = await f.session({ transcriptLedgerVersion: 0 }); + const own = record(session.id, 'staged-own'); + await f.shellStore.createShellRun(own); + const noHistory = t.mock.method(f.manager, 'getMessages', async () => { + throw new Error('Resource query must not read transcript history'); + }); + assert.deepEqual(await f.manager.listShellRunUpdates(session.id), [shellRunUpdate(own)]); + assert.equal( + await f.manager.getShellRunUpdate( + session.id, + shellRunContent(record(session.id, 'missing')).ref, + ), + null, + ); + assert.equal(noHistory.mock.callCount(), 0); + assert.equal((await f.stores.sessionStore.readHeader(session.id)).transcriptLedgerVersion, 0); + await assert.rejects( + f.manager.ensureTranscriptLedgerForRead(session.id), + /Imported Session history is still being prepared/, + ); +}); + +for (const lineage of ['parentSessionId', 'revisionParentSessionId'] as const) { + test(`${lineage} resources retain ancestor ownership, own priority and copied-history scope`, async (t) => { + const f = await fixture(t); + const ancestor = await f.session(); + const parent = await f.session({ parentSessionId: ancestor.id }); + const decoy = await f.session(); + const child = await f.session({ + parentSessionId: lineage === 'parentSessionId' ? parent.id : decoy.id, + ...(lineage === 'revisionParentSessionId' + ? { + revisionParentSessionId: parent.id, + revisionRootSessionId: ancestor.id, + revisionOfTurnId: 'history-turn', + revisionIndex: 2, + revisionState: 'committed' as const, + } + : {}), + }); + const inherited = record(ancestor.id, 'inherited'); + const shadowed = record(ancestor.id, 'shadowed'); + const outsideCopy = record(ancestor.id, 'outside-copy'); + for (const item of [inherited, shadowed, outsideCopy]) await f.shellStore.createShellRun(item); + const own = record(child.id, 'own', { sourceToolCallId: shadowed.sourceToolCallId }); + await f.shellStore.createShellRun(own); + await f.history(child.id, [inherited, shadowed]); + // The inherited snapshot says running, while its real owner has since finished. + await f.shellStore.updateShellRun(ancestor.id, inherited.shellRunId, { + status: 'completed', + exitCode: 0, + completedAt: 2, + updatedAt: 2, + }); + const expected = { + sessionId: child.id, + ownership: { kind: 'source_owned', sourceSessionId: parent.id, ownerSessionId: ancestor.id }, + sourceTurnId: 'history-turn', + sourceToolCallId: inherited.sourceToolCallId, + result: await f.shellRuns.inspectResource(ancestor.id, shellRunContent(inherited).ref), + }; + assert.deepEqual(await f.manager.listShellRunUpdates(child.id), [ + shellRunUpdate(own), + expected, + ]); + assert.deepEqual( + await f.manager.getShellRunUpdate(child.id, shellRunContent(inherited).ref), + expected, + ); + assert.equal( + await f.manager.getShellRunUpdate(child.id, shellRunContent(outsideCopy).ref), + null, + ); + }); +} + +test('missing inherited owners and cyclic ancestry preserve source-unavailable snapshots', async (t) => { + const f = await fixture(t); + const parent = await f.session(); + const child = await f.session({ parentSessionId: parent.id }); + await f.stores.sessionStore.updateHeader(parent.id, { parentSessionId: child.id }); + const missing = record(parent.id, 'gone'); + await f.history(child.id, [missing]); + const expected = { + sessionId: child.id, + ownership: { kind: 'source_unavailable', sourceSessionId: parent.id }, + sourceTurnId: 'history-turn', + sourceToolCallId: missing.sourceToolCallId, + result: shellRunContent(missing), + }; + assert.deepEqual(await f.manager.listShellRunUpdates(child.id), [expected]); + assert.deepEqual(await f.manager.getShellRunUpdate(child.id, expected.result.ref), expected); +}); + +test('inherited discovery excludes non-Bash and terminal snapshots', async (t) => { + const f = await fixture(t); + const parent = await f.session(); + const child = await f.session({ parentSessionId: parent.id }); + const terminal = record(parent.id, 'terminal', { + status: 'completed', + completedAt: 2, + exitCode: 0, + }); + const otherTool = record(parent.id, 'other-tool'); + await f.history(child.id, [terminal, otherTool], ['Bash', 'Other']); + assert.deepEqual(await f.manager.listShellRunUpdates(child.id), []); + for (const item of [terminal, otherTool]) { + assert.equal(await f.manager.getShellRunUpdate(child.id, shellRunContent(item).ref), null); + } +}); + +async function fixture(t: TestContext) { + const base = await mkdtemp(join(tmpdir(), 'maka-shell-resource-read-')); + const capability = await resolveStorageRoot({ path: join(base, 'root'), kind: 'interactive' }); + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + const stores = await openInteractiveExecutionStoresForWrite(owner.lease); + const shellStore = await openInteractiveShellRunStoreForWrite(owner.lease); + const shellRuns = new ShellRunProcessManager({ + store: shellStore, + newId: randomUUID, + now: Date.now, + }); + t.after(async () => { + await shellRuns.terminateAll(); + shellStore.close(); + await stores.sessionStore.close?.(); + await owner.close(); + await rm(base, { recursive: true, force: true }); + }); + const options = { + store: stores.sessionStore, + runStore: stores.agentRunStore, + runtimeEventStore: stores.runtimeEventStore, + shellRuns, + backends: new BackendRegistry(), + newId: randomUUID, + now: Date.now, + }; + const manager = new SessionManager(options); + return { + stores, + shellStore, + shellRuns, + manager, + options, + session: async (patch: SessionHeaderPatch = {}) => { + const session = await stores.sessionStore.create({ + cwd: base, + llmConnectionId: 'fake-connection', + llmConnectionSlug: 'fake', + model: 'fake-model', + permissionMode: 'ask', + }); + return stores.sessionStore.updateHeader(session.id, { transcriptLedgerVersion: 1, ...patch }); + }, + history: async (sessionId: string, records: ShellRunRecord[], names: string[] = []) => { + const identity = await seedInvocation(stores.runtimeEventStore, { + sessionId, + runId: 'history-run', + turnId: 'history-turn', + openedAt: 1, + }); + let sequence = 1; + const append = (patch: Partial) => + stores.runtimeEventStore.appendRuntimeEvent(sessionId, identity.runId, { + ...identity, + id: `event-${++sequence}`, + ts: sequence, + partial: false, + role: 'system', + author: 'system', + ...patch, + }); + for (const [index, item] of records.entries()) { + const name = names[index] ?? 'Bash'; + await append({ + role: 'model', + author: 'agent', + content: { kind: 'function_call', id: item.sourceToolCallId, name, args: {} }, + }); + await append({ + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: item.sourceToolCallId, + name, + result: shellRunContent(item), + }, + }); + } + await append({ status: 'completed', actions: { endInvocation: true } }); + }, + }; +} + +function record( + sessionId: string, + shellRunId: string, + patch: Partial = {}, +): ShellRunRecord { + return { + sessionId, + shellRunId, + sourceTurnId: 'resource-turn', + sourceToolCallId: `call-${shellRunId}`, + cwd: '/tmp', + command: 'fixture', + status: 'running', + startedAt: 1, + updatedAt: 1, + revision: 1, + output: { + mode: 'pipes', + stdout: '', + stderr: '', + stdoutTruncated: false, + stderrTruncated: false, + redacted: false, + }, + ...patch, + }; +} diff --git a/packages/runtime/src/session-manager.ts b/packages/runtime/src/session-manager.ts index 91651d57d6c..d834e333062 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -1422,6 +1422,12 @@ export class SessionManager { if (!shellRuns) return []; const ownUpdates = await shellRuns.listSessionUpdates(sessionId); + // Only a branched or revised Session can inherit resources from its history. + // Ordinary Sessions already have their complete answer in the ShellRun store. + const inheritedFrom = await this.deps.store.readHeader(sessionId); + const parentSessionId = inheritedFrom.revisionParentSessionId ?? inheritedFrom.parentSessionId; + if (!parentSessionId) return ownUpdates; + const ownToolCalls = new Set(ownUpdates.map((update) => update.sourceToolCallId)); const messages = await this.readShellRunProjectionMessages(sessionId); if (!messages) return ownUpdates; @@ -1454,9 +1460,6 @@ export class SessionManager { } if (inherited.size === 0) return ownUpdates; - const inheritedFrom = await this.deps.store.readHeader(sessionId); - const parentSessionId = inheritedFrom.revisionParentSessionId ?? inheritedFrom.parentSessionId; - if (!parentSessionId) return ownUpdates; const inheritedUpdates = await Promise.all( [...inherited.values()].map(async (candidate) => { const owner = await this.resolveShellRunOwner(parentSessionId, candidate.ref); @@ -1484,6 +1487,10 @@ export class SessionManager { const own = await shellRuns.getSessionUpdate(sessionId, ref); if (own) return own; + const inheritedFrom = await this.deps.store.readHeader(sessionId); + const parentSessionId = inheritedFrom.revisionParentSessionId ?? inheritedFrom.parentSessionId; + if (!parentSessionId) return null; + const messages = await this.readShellRunProjectionMessages(sessionId); if (!messages) return null; const bashToolCalls = shellRunBashToolCallIds(messages); @@ -1508,9 +1515,6 @@ export class SessionManager { } if (!candidate) return null; - const inheritedFrom = await this.deps.store.readHeader(sessionId); - const parentSessionId = inheritedFrom.revisionParentSessionId ?? inheritedFrom.parentSessionId; - if (!parentSessionId) return null; const owner = await this.resolveShellRunOwner(parentSessionId, ref); return { sessionId, diff --git a/packages/ui/src/__tests__/chat-turn-answer-identity.test.tsx b/packages/ui/src/__tests__/chat-turn-answer-identity.test.tsx index 5c9a4ce7cc4..5b4561ffbd8 100644 --- a/packages/ui/src/__tests__/chat-turn-answer-identity.test.tsx +++ b/packages/ui/src/__tests__/chat-turn-answer-identity.test.tsx @@ -106,6 +106,14 @@ function renderTurn( }) as unknown as Promise; } +async function openProcess(container: ReturnType['container']) { + const process = container.querySelector('details.maka-processing-sequence'); + const summary = process?.querySelector('summary'); + if (summary && !process?.hasAttribute('open')) { + await act(() => { summary.dispatchEvent(new window.Event('click', { bubbles: true, cancelable: true })); }); + } +} + const ANSWER: TurnTimelineItem = { kind: 'text', text: 'the answer', @@ -197,6 +205,7 @@ test('keeps reasoning expanded when its last neighboring tool is projected away' kind: 'thinking', messageId: 'reason-1', text: 'First observation', live: false, }; await renderTurn(root, turnWith([thinking, RUNNING_TOOL, ANSWER])); + await openProcess(container); const header = container.querySelector('[data-slot="activity-card-header"]'); assert.ok(header); await act(() => { header.dispatchEvent(new window.Event('click', { bubbles: true })); }); @@ -218,7 +227,7 @@ test('redacts secrets before rendering a settled collapsed reasoning preview', a live: false, }, ])); - + await openProcess(container); const header = container.querySelector('[data-slot="activity-card-header"]'); assert.ok(header); assert.match(header.textContent ?? '', //); @@ -235,7 +244,7 @@ test('preserves currency in a settled collapsed reasoning preview', async () => live: false, }, ])); - + await openProcess(container); const header = container.querySelector('[data-slot="activity-card-header"]'); assert.ok(header); assert.match(header.textContent ?? '', /cost is \$5, not x \+ 1/); @@ -249,6 +258,7 @@ test('expanded truncated reasoning shows the current tail without replaying its text: thinking.text, truncated: thinking.truncated, }])); await renderThinking(); + await openProcess(container); const header = container.querySelector('[data-slot="activity-card-header"]'); assert.ok(header); await act(() => { header.dispatchEvent(new window.Event('click', { bubbles: true })); }); @@ -272,7 +282,7 @@ test('preserves a model-authored single newline in plain reasoning', async () => live: false, }, ])); - + await openProcess(container); const header = container.querySelector('[data-slot="activity-card-header"]'); assert.ok(header); await act(() => { header.dispatchEvent(new window.Event('click', { bubbles: true })); }); @@ -828,16 +838,21 @@ test('collapses the whole completed process and leaves the final answer outside' // The finish time lives in the footer as a semantic timestamp — the fact // that makes a transcript reviewable after the fact. assert.ok(container.querySelector('.maka-turn-footer time')); - assert.match(process.textContent ?? '', /Checking the login state/); + assert.doesNotMatch(process.textContent ?? '', /Checking the login state/, 'unopened history does not mount hidden work'); assert.doesNotMatch(process.textContent ?? '', /the answer/); - const answer = container.querySelectorAll('.maka-chat-message-bubble-assistant')[1]; + const answer = container.querySelector('.maka-assistant-answer'); assert.ok(answer); await act(() => { summary.dispatchEvent(new window.Event('click', { bubbles: true, cancelable: true })); }); assert.equal(process.hasAttribute('open'), true); assert.equal(summary.getAttribute('aria-expanded'), 'true'); + assert.match(process.textContent ?? '', /Checking the login state/); + const mounted = process.querySelector('.maka-processing-body')?.firstElementChild; + assert.ok(mounted); await act(() => { summary.dispatchEvent(new window.Event('click', { bubbles: true, cancelable: true })); }); assert.equal(process.hasAttribute('open'), false); - assert.equal(container.querySelectorAll('.maka-chat-message-bubble-assistant')[1]?.isSameNode(answer), true); + assert.equal(container.querySelector('.maka-assistant-answer')?.isSameNode(answer), true); + assert.equal(process.querySelector('.maka-processing-body')?.firstElementChild?.isSameNode(mounted), true, + 'collapsing an opened process preserves its mounted detail'); }); test('moves the live clock into the process and settles it to recorded duration without remounting the answer', async (context) => { @@ -910,6 +925,7 @@ test('keeps running work expanded and allows manual disclosure after settlement' assert.equal(process.hasAttribute('open'), true); await renderTurn(root, { ...turnWith([PROCESS_TEXT, COMPLETED_TOOL, { ...ANSWER, live: false }]), status: 'completed' }); assert.equal(process.hasAttribute('open'), false); + assert.match(process.textContent ?? '', /Checking the login state/, 'settlement keeps previously displayed live work mounted'); assert.equal(summary.hasAttribute('aria-disabled'), false); assert.equal(summary.getAttribute('tabindex'), '0'); await click(); diff --git a/packages/ui/src/__tests__/chat-turn-steering-order.test.ts b/packages/ui/src/__tests__/chat-turn-steering-order.test.ts index 77bb8abaaff..dced134f479 100644 --- a/packages/ui/src/__tests__/chat-turn-steering-order.test.ts +++ b/packages/ui/src/__tests__/chat-turn-steering-order.test.ts @@ -19,7 +19,7 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { createElement } from 'react'; +import { act, createElement } from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { parseHTML } from 'linkedom'; import { TurnView } from '../chat-turn.js'; @@ -29,26 +29,27 @@ import { materializeTurns, overlayLiveTurn, type TurnTimelineItem, type TurnView import { createTranscriptProjection } from '../transcript-projection.js'; import { ChatView } from '../chat-view.js'; import { Composer } from '../composer.js'; -import { renderTranscriptMarkup } from './transcript-test-dom.js'; +import { installTranscriptDom, renderTranscriptMarkup } from './transcript-test-dom.js'; import { ChatSurfaceLayout } from '../chat-surface-layout.js'; import { armLiveTurn, type LiveTurnProjection } from '../live-turn-projection.js'; import { applyLiveTurnEvent } from './live-turn-zh.js'; import type { SessionSummary, StoredMessage } from '@maka/core/session'; -test('renders a thinking-only interruption as a divider without an empty answer bubble', () => { +test('renders a thinking-only interruption as a divider without an empty answer bubble', async () => { const messages: StoredMessage[] = [ { type: 'user', id: 'user', turnId: 'turn', ts: 1, text: 'request' }, { type: 'assistant', id: 'partial', turnId: 'turn', ts: 2, modelId: 'mock', text: '', interrupted: true, thinking: { text: 'partial thought' } }, ]; const [turn] = materializeTurns(messages, 'en'); assert.ok(turn); - const markup = renderToStaticMarkup(createElement(LocaleProvider, { - locale: 'en', children: createElement(TurnView, { turn }), - })); - const { document } = parseHTML(`${markup}`); - assert.equal(document.querySelectorAll('.maka-chat-message-bubble-assistant').length, 0); - assert.match(document.body.textContent, /partial thought/); - assert.match(document.body.textContent, /Response stream ended before completion/); + const dom = installTranscriptDom(); + try { + await dom.render(createElement(LocaleProvider, { locale: 'en', children: createElement(TurnView, { turn }) })); + assert.equal(dom.document.querySelectorAll('.maka-chat-message-bubble-assistant').length, 0); + assert.match(dom.container.textContent, /Response stream ended before completion/); + await act(() => { dom.document.querySelector('.maka-processing-summary')!.dispatchEvent(new dom.window.Event('click', { bubbles: true, cancelable: true })); }); + assert.match(dom.container.textContent, /partial thought/); + } finally { await dom.cleanup(); } }); test('renders steering where it arrived in the assistant timeline', () => { diff --git a/packages/ui/src/__tests__/chat-view-empty-compaction.test.tsx b/packages/ui/src/__tests__/chat-view-empty-compaction.test.tsx index 82196df8a41..bd1151ae31c 100644 --- a/packages/ui/src/__tests__/chat-view-empty-compaction.test.tsx +++ b/packages/ui/src/__tests__/chat-view-empty-compaction.test.tsx @@ -28,7 +28,7 @@ import { ChatSurfaceLayout } from '../chat-surface-layout.js'; import { ChatView } from '../chat-view.js'; import type { LiveTurnProjection } from '../live-turn-projection.js'; import { LocaleProvider } from '../locale-context.js'; -import { renderTranscriptMarkup } from './transcript-test-dom.js'; +import { installTranscriptDom, renderTranscriptMarkup } from './transcript-test-dom.js'; const activeSession = { id: 'session-1', @@ -73,13 +73,27 @@ test('retains tool and compaction evidence without activity after observation lo { toolUseId: 'bash', toolName: 'Bash', args: { command: 'echo retained' }, status: 'running' }, ] }] }; const compact: LiveTurnProjection = { turnId: 'compact', rootExecutionKind: 'context_compact', steps: [] }; - for (const observed of [true, false, true]) { - const toolDocument = parseHTML(await renderChat(tool, { messages, activeTurn: observed ? { turnId: tool.turnId } : undefined })).document; - assert.equal(toolDocument.querySelector('.maka-tool-activity-card')?.getAttribute('data-activity-observed'), String(observed)); - assert.match(toolDocument.querySelector('.maka-tool-activity-card')?.textContent ?? '', /echo retained/); - const compactDocument = parseHTML(await renderChat(compact, { messages, activeTurn: observed ? { turnId: compact.turnId, compacting: true } : undefined })).document; - assert.equal(compactDocument.querySelector('[data-compaction-state]')?.getAttribute('data-compaction-state'), observed ? 'running' : 'unavailable'); - assert.equal(compactDocument.querySelectorAll('.maka-compaction-status .astryx-spinner').length, observed ? 1 : 0); + for (const live of [tool, compact]) { + const dom = installTranscriptDom(); + try { + let retained: Element | null = null; + for (const observed of [true, false, true]) { + await dom.render( + undefined} activeTurn={observed ? { turnId: live.turnId!, compacting: live === compact } : undefined} /> + ); + if (live === tool) { + const card = dom.document.querySelector('.maka-tool-activity-card'); + assert.equal(card?.getAttribute('data-activity-observed'), String(observed)); + assert.match(card?.textContent ?? '', /echo retained/); + if (retained) assert.equal(card, retained, 'observation loss preserves already displayed work'); + retained = card; + } else { + assert.equal(dom.document.querySelector('[data-compaction-state]')?.getAttribute('data-compaction-state'), observed ? 'running' : 'unavailable'); + assert.equal(dom.document.querySelectorAll('.maka-compaction-status .astryx-spinner').length, observed ? 1 : 0); + } + } + } finally { await dom.cleanup(); } } assert.equal(tool.steps[0]?.tools[0]?.status, 'running', 'availability never rewrites retained execution evidence'); }); @@ -218,3 +232,22 @@ test('shared turn presentation only exposes lineage when the surface supports na assert.equal(document.querySelectorAll('.maka-turn-lineage-row .astryx-button').length, canNavigate ? 1 : 0); } }); + +test('Resume is offered only when the displayed window covers the session tail', async () => { + for (const hasLaterHistory of [true, false]) { + const markup = await renderChat(undefined, { + messages: [ + { type: 'user', id: 'ask', turnId: 'stopped', text: 'Ask', ts: 1 }, + { type: 'turn_state', id: 'stopped-state', turnId: 'stopped', status: 'failed', failureMessage: 'Provider unavailable', ts: 2 }, + ], + hasLaterHistory, + safeResumeAction: { pending: false, onResume() {} }, + deriveTurnPresentation: () => ({ + footerActionsByTurn: {}, failedReasonLabels: { stopped: 'Provider unavailable' }, failedSeverities: {}, failedExecutionStateLabels: {}, + resumeCandidateTurnId: 'stopped', + lineageBadgesByTurn: {}, + }), + }); + assert.equal(markup.includes('Continue this turn'), !hasLaterHistory); + } +}); diff --git a/packages/ui/src/__tests__/chat-view-load-earlier.test.tsx b/packages/ui/src/__tests__/chat-view-load-earlier.test.tsx index f55b86ad876..a634828849b 100644 --- a/packages/ui/src/__tests__/chat-view-load-earlier.test.tsx +++ b/packages/ui/src/__tests__/chat-view-load-earlier.test.tsx @@ -106,22 +106,19 @@ function harness() { }, loadButton(): HTMLButtonElement | null { return [...current.container.querySelectorAll('button')] - .find((button) => button.textContent === 'Load earlier history') ?? null; + .find((button) => button.textContent === 'Load earlier history' || button.textContent === 'Retry') ?? null; }, readerScrollTo(scroller: HTMLElement, top: number): void { const wheel = new current.window.Event('wheel', { bubbles: true }); - Object.defineProperty(wheel, 'deltaY', { value: top < scroller.scrollTop ? -120 : 120 }); + Object.defineProperty(wheel, 'deltaY', { value: top <= scroller.scrollTop ? -120 : 120 }); scroller.dispatchEvent(wheel); scroller.scrollTop = top; scroller.dispatchEvent(new current.window.Event('scroll')); }, - click(element: Element): void { - element.dispatchEvent(new current.window.Event('click', { bubbles: true })); - }, }; } -test('the load-earlier button exists only while earlier history does, and waits for its load', async () => { +test('upward reading near the beginning loads one range while retaining the accessible button', async () => { const view = harness(); await view.render({ messages: turnMessages(4, 8), onLoadEarlierHistory: () => {} }); assert.equal(view.loadButton(), null, 'no button without earlier history'); @@ -132,27 +129,77 @@ test('the load-earlier button exists only while earlier history does, and waits loads += 1; return new Promise((resolve) => { finish = resolve; }); }; - await view.render({ messages: turnMessages(4, 8), hasEarlierHistory: true, onLoadEarlierHistory }); - const button = view.loadButton(); - assert.ok(button); - await act(async () => { view.click(button); }); + const scroller = await view.render({ messages: turnMessages(4, 8), hasEarlierHistory: true, onLoadEarlierHistory }); + assert.ok(view.loadButton()); + assert.equal(loads, 0, 'opening a conversation must not read all older history'); + scroller.scrollTop = 1000; + await act(async () => { view.readerScrollTo(scroller, 800); }); + assert.equal(loads, 0, 'reading far from the boundary does not load'); + await act(async () => { view.readerScrollTo(scroller, 400); }); assert.equal(loads, 1); - assert.equal(view.loadButton()?.disabled, true, 'a pending load cannot be requested again'); + assert.equal(view.loadButton()?.disabled, true); + await act(async () => { view.readerScrollTo(scroller, 200); }); + assert.equal(loads, 1, 'a pending load cannot be requested again'); await act(async () => { finish(); }); assert.equal(view.loadButton()?.disabled, false); + assert.equal(loads, 1, 'settling a page does not recursively prefetch the rest'); + await act(async () => { view.readerScrollTo(scroller, 300); }); + assert.equal(loads, 1, 'downward reading does not load older history'); + await act(async () => { view.readerScrollTo(scroller, 100); }); + assert.equal(loads, 2); + await act(async () => { finish(); }); }); /** * A visible transcript with nothing in it is exactly when the reader most needs - * the control: WorkHub filters the list to one Work, and a Work whose Turns are - * all in unloaded history filters it down to nothing. The control is the only - * way to load those Turns, so it cannot be inside the non-empty branch. + * an upward gesture: WorkHub may filter every loaded Turn out of the view. + * Even when there is no scroll distance, the reader can request older rows. */ -test('offers to load earlier history even with nothing to show', async () => { +test('upward input and the accessible control reach earlier history even with nothing to show', async () => { + const view = harness(); + let loads = 0; + const scroller = await view.render({ messages: [], hasEarlierHistory: true, onLoadEarlierHistory: () => { loads++; } }); + assert.equal(loads, 0); + await act(async () => { view.readerScrollTo(scroller, 0); }); + assert.equal(loads, 1); + assert.ok(view.loadButton()); +}); + +test('a previous conversation finishing does not unlock another conversation pending page', async () => { + const view = harness(); + const finishes: Array<() => void> = []; + const onLoadEarlierHistory = () => new Promise((resolve) => { finishes.push(resolve); }); + const props = { messages: turnMessages(4, 8), hasEarlierHistory: true, onLoadEarlierHistory }; + const scroller = await view.render(props); + await act(async () => { view.readerScrollTo(scroller, 0); }); + assert.equal(finishes.length, 1); + await view.render({ ...props, activeSession: { ...activeSession, id: 'other-session' } }); + await act(async () => { view.readerScrollTo(scroller, 0); }); + assert.equal(finishes.length, 2); + await act(async () => { finishes[0]!(); }); + await act(async () => { view.readerScrollTo(scroller, 0); }); + assert.equal(finishes.length, 2, 'the old completion must not release the new pending request'); + await act(async () => { finishes[1]!(); }); + await view.render({ ...props, activeSession: { ...activeSession, id: 'other-session' }, hasEarlierHistory: false }); + await act(async () => { view.readerScrollTo(scroller, 0); }); + assert.equal(finishes.length, 2, 'exhausted history does not load again'); +}); + +test('a failed automatic read retries only after another upward gesture', async () => { const view = harness(); - await view.render({ messages: [], hasEarlierHistory: true, onLoadEarlierHistory: () => {} }); - assert.ok(view.loadButton(), 'an empty message list hid the only way to load the rest'); + let loads = 0; + const scroller = await view.render({ + messages: turnMessages(4, 8), hasEarlierHistory: true, + onLoadEarlierHistory: async () => { loads++; throw new Error('disconnected'); }, + }); + await act(async () => { view.readerScrollTo(scroller, 0); }); + assert.equal(loads, 1); + await act(async () => {}); + assert.equal(loads, 1, 'failure must not start a retry loop'); + assert.equal(view.loadButton()?.textContent, 'Retry'); + await act(async () => { view.readerScrollTo(scroller, 0); }); + assert.equal(loads, 2); }); test('prepended Turns keep a released reader on their Turn without re-pinning', async () => { @@ -163,10 +210,7 @@ test('prepended Turns keep a released reader on their Turn without re-pinning', assert.equal(view.authority.getSnapshot().pinned, false); assert.equal(view.anchors.at(-1), 'turn-5'); - // The reader asks for history, which is the only way it arrives. - const button = view.loadButton(); - assert.ok(button); - await act(async () => { view.click(button); }); + // The upward gesture requested history; its eventual arrival is not input. await view.render({ messages: turnMessages(2, 8), hasEarlierHistory: false, onLoadEarlierHistory: () => {} }); await act(() => { scroller.dispatchEvent(new dom!.window.Event('scroll')); }); assert.equal(view.loadButton(), null); @@ -185,10 +229,12 @@ test('a selection from outside every Turn keeps every Turn it spans mounted', as const rows = mounted(); assert.ok(rows.length < 20, 'the fixture mounts only some of the Turns'); - const button = view.loadButton(); + const selectionStart = dom!.document.createElement('p'); + selectionStart.textContent = 'Text before the transcript'; + scroller.prepend(selectionStart); const last = dom!.container.querySelector(`[data-transcript-turn-id="${rows.at(-1)}"]`); - assert.ok(button && last); - const selection = { isCollapsed: false, anchorNode: button, focusNode: last }; + assert.ok(last); + const selection = { isCollapsed: false, anchorNode: selectionStart, focusNode: last }; dom!.document.getSelection = () => selection as unknown as Selection; await act(async () => { dom!.document.dispatchEvent(new dom!.window.Event('selectionchange')); }); await act(() => { view.readerScrollTo(scroller, 16 * TURN_HEIGHT); }); diff --git a/packages/ui/src/__tests__/transcript-scroll-authority.test.ts b/packages/ui/src/__tests__/transcript-scroll-authority.test.ts index 5a00adcdd07..62420ea1ac8 100644 --- a/packages/ui/src/__tests__/transcript-scroll-authority.test.ts +++ b/packages/ui/src/__tests__/transcript-scroll-authority.test.ts @@ -516,6 +516,44 @@ function frames(frame: () => void, count = 30): void { for (let step = 0; step < count; step += 1) frame(); } +test('downward input pages a parked window without pinning to its temporary end', () => { + withObservers((resize, frame) => { + const root = fakeRoot(); + const list = turnList(root, ['a', 'b', 'c']); + let hasNewer = true; + let reads = 0; + let latest = 0; + const authority = createTranscriptScrollAuthority(); + authority.attach(root as unknown as HTMLElement, { + ...list.layout, + hasNewer: () => hasNewer, + readNewer: () => { reads += 1; return hasNewer; }, + readLatest: () => { latest += 1; }, + }); + authority.releasePin(); + root.scrollTop = root.scrollHeight; + root.input(100); + root.end(); + frames(frame); + assert.equal(reads, 1); + assert.equal(latest, 0); + assert.equal(authority.getSnapshot().pinned, false); + assert.equal(authority.getSnapshot().awayFromTail, true); + const top = root.scrollTop; + list.set(['a', 'b', 'c', 'd']); + authority.turnsChanged('append'); + resize(); + assert.equal(root.scrollTop, top, 'an arriving page does not drag the reader to its end'); + authority.pinToTail(); + assert.equal(latest, 1); + hasNewer = false; + authority.turnsChanged('reset'); + resize(); + assert.equal(authority.getSnapshot().awayFromTail, false); + assert.equal(authority.getSnapshot().pinned, true); + }); +}); + test('a change to the list other than growth at its tail keeps the reader on their Turn', () => { withObservers((_resize, frame) => { const root = fakeRoot(); diff --git a/packages/ui/src/chat-turn.tsx b/packages/ui/src/chat-turn.tsx index cbbf1cf738b..35dda619228 100644 --- a/packages/ui/src/chat-turn.tsx +++ b/packages/ui/src/chat-turn.tsx @@ -1476,7 +1476,7 @@ const ProcessingBlock = memo(function ProcessingBlock(props: { }) { const copy = getConversationCopy(useUiLocale()).messages; return ( - : copy.processDetails}> {(open) => <> {props.entries.map((entry, index) => ( diff --git a/packages/ui/src/chat-view.tsx b/packages/ui/src/chat-view.tsx index 4622341dd02..a3eed693be9 100644 --- a/packages/ui/src/chat-view.tsx +++ b/packages/ui/src/chat-view.tsx @@ -276,9 +276,12 @@ export function ChatView(props: { hasEarlierHistory?: boolean; /** Prepends whole earlier Turns to `messages`. */ onLoadEarlierHistory?(): void | Promise; + hasLaterHistory?: boolean; + onLoadLaterHistory?(): void | Promise; + onLoadLatestHistory?(): void | Promise; /** Turns outside `messages`, from the Session's Turn index, oldest first. */ transcriptTurnIndex?: ReadonlyArray<{ turnId: string; sequence: number; label: string }>; - /** Loads `messages` back to the start of an indexed Turn. */ + /** Opens a reading range containing an indexed Turn. */ onLoadTranscriptTurn?(turn: { turnId: string; sequence: number }): void | Promise; /** Optional identity decorations shared with a host's work navigation. */ promptRailDecorations?: ReadonlyMap>; @@ -338,7 +341,8 @@ export function ChatView(props: { const locale = useUiLocale(); const conversationCopy = getConversationCopy(locale); const copy = conversationCopy.chat; - const drainingStepIdsKey = (props.liveTurns ?? []) + const visibleLiveTurns = props.hasLaterHistory ? undefined : props.liveTurns; + const drainingStepIdsKey = (visibleLiveTurns ?? []) .flatMap((turn) => turn.steps.flatMap((step) => (step.text ? [step.stepId] : []))) .join('\u0000'); const drainingMessageIds = useMemo( @@ -361,7 +365,7 @@ export function ChatView(props: { ), [visibleMessages], ); - const transientMessages = (props.transientMessages ?? []).filter((message) => message.transientPlacement === 'transcript'); + const transientMessages = (props.hasLaterHistory ? [] : props.transientMessages ?? []).filter((message) => message.transientPlacement === 'transcript'); // The projection owns the derived turns, so a turn nothing said anything // about keeps its object identity and its memoized TurnView skips — across // deltas AND across the message refreshes that fire at every step/tool @@ -370,7 +374,7 @@ export function ChatView(props: { sessionId: props.activeSession?.id, locale, messages: visibleMessages, - liveTurns: props.liveTurns, + liveTurns: visibleLiveTurns, shellRunUpdates: props.shellRunUpdates, }); // Derived FROM the projected turns, not beside them: the consumer keys its @@ -396,15 +400,15 @@ export function ChatView(props: { // footer on a still-running answer (review P2-B). A tool-only tail renders the // running tool from its timeline with no empty live bubble. // Execution identity comes from the Host. Buffered output can outlive it. - const activeContent = props.liveTurns?.find((turn) => turn.turnId === props.activeTurn?.turnId); - const isCompactionLive = props.activeTurn?.compacting === true; + const activeContent = visibleLiveTurns?.find((turn) => turn.turnId === props.activeTurn?.turnId); + const isCompactionLive = !props.hasLaterHistory && props.activeTurn?.compacting === true; // overlayLiveTurn renders one "compacting" system row for a live compaction // Turn that has no assistant steps — including in a session with no settled // chat messages yet. The empty-state decision (below) keys off // `hasVisibleChatContent`, which does not see that overlaid row, so it must // treat this as visible content or the row is hidden behind the empty hero. const hasLiveCompactionRow = isCompactionLive && (activeContent?.steps.length ?? 0) === 0; - const streamingActive = props.activeTurn !== undefined && !isCompactionLive; + const streamingActive = !props.hasLaterHistory && props.activeTurn !== undefined && !isCompactionLive; const tailTurnId = streamingActive ? props.activeTurn?.turnId : undefined; const runningStatus = streamingActive && !props.activeTurn?.awaitingInput; const hasRenderedLiveTurn = tailTurnId !== undefined && turns.some((turn) => turn.turnId === tailTurnId); @@ -553,6 +557,38 @@ export function ChatView(props: { ) : null; const { startMargin, listRef, measureStartMargin } = useTranscriptStartMargin(scrollRef); + const earlierReader = useRef({ sessionId: props.activeSession?.id, pending: false }); + const [loadingEarlierHistory, setLoadingEarlierHistory] = useState(false); + const [earlierHistoryLoadFailed, setEarlierHistoryLoadFailed] = useState(false); + if (earlierReader.current.sessionId !== props.activeSession?.id) { + earlierReader.current = { sessionId: props.activeSession?.id, pending: false }; + } + useEffect(() => { + setLoadingEarlierHistory(false); + setEarlierHistoryLoadFailed(false); + }, [props.activeSession?.id]); + const loadEarlierHistory = (): boolean => { + if (!props.hasEarlierHistory || !props.onLoadEarlierHistory) return false; + const reader = earlierReader.current; + if (reader.pending) return true; + reader.pending = true; + setLoadingEarlierHistory(true); + const settled = (failed: boolean) => { + if (earlierReader.current !== reader) return; + reader.pending = false; + setLoadingEarlierHistory(false); + setEarlierHistoryLoadFailed(failed); + }; + try { + void Promise.resolve(props.onLoadEarlierHistory()).then( + () => settled(false), + () => settled(true), + ); + } catch { + settled(true); + } + return true; + }; const { highlightedTurnId, placed, commandTurnId, revealTurnAtStart, measurement } = useChatScroll({ scrollRef, measureStartMargin, @@ -563,6 +599,14 @@ export function ChatView(props: { restoreTarget: props.restoreTargetTurn, viewportNavigation: props.viewportNavigation, onReadingAnchorChange: props.onReadingAnchorChange, + onReadEarlier: loadEarlierHistory, + hasLaterHistory: props.hasLaterHistory, + onReadLater: () => { + if (!props.hasLaterHistory || !props.onLoadLaterHistory) return false; + void Promise.resolve(props.onLoadLaterHistory()).catch(() => undefined); + return true; + }, + onReadLatest: () => { void Promise.resolve(props.onLoadLatestHistory?.()).catch(() => undefined); }, behavior: props.scrollBehavior, }); const onLoadTranscriptTurnRef = useRef(props.onLoadTranscriptTurn); @@ -591,16 +635,6 @@ export function ChatView(props: { keepMountedIndexes.add(index); } } - const [loadingEarlierHistory, setLoadingEarlierHistory] = useState(false); - const loadEarlierHistory = (): void => { - const pending = props.onLoadEarlierHistory?.(); - if (!pending) return; - setLoadingEarlierHistory(true); - void pending.then( - () => setLoadingEarlierHistory(false), - () => setLoadingEarlierHistory(false), - ); - }; const { quote: selectionQuote, clear: clearSelectionQuote } = useMessageSelectionQuote( scrollRef, Boolean(props.onQuoteSelection || props.onAskAboutSelection), @@ -898,7 +932,7 @@ export function ChatView(props: { type="button" variant="ghost" size="sm" - label={copy.loadEarlierHistory} + label={earlierHistoryLoadFailed ? copy.retryLoad : copy.loadEarlierHistory} isDisabled={loadingEarlierHistory} onClick={loadEarlierHistory} /> @@ -1011,7 +1045,7 @@ export function ChatView(props: { failedExecutionStateLabel={ turnPresentation?.failedExecutionStateLabels[turn.turnId] } - safeResumeAction={turnPresentation?.resumeCandidateTurnId === turn.turnId + safeResumeAction={!props.hasLaterHistory && turnPresentation?.resumeCandidateTurnId === turn.turnId ? props.safeResumeAction : undefined} lineageBadges={props.onLineageBadgeClick ? turnPresentation?.lineageBadgesByTurn[turn.turnId] : undefined} diff --git a/packages/ui/src/transcript-disclosure.tsx b/packages/ui/src/transcript-disclosure.tsx index e16d0cfb32e..7468248c2d7 100644 --- a/packages/ui/src/transcript-disclosure.tsx +++ b/packages/ui/src/transcript-disclosure.tsx @@ -28,9 +28,13 @@ export function TranscriptDisclosure(props: { statusBar?: boolean; status?: string; className?: string; + /** Mount process contents on first expansion, then retain their identity. */ + deferMount?: boolean; }) { const [manualOpen, setManualOpen] = useState(false); const open = Boolean(props.running) || manualOpen; + const [hasOpened, setHasOpened] = useState(open); + if (open && !hasOpened) setHasOpened(true); const chevron = props.running ? null : ; return

{props.label}{chevron} : <>{props.label}{chevron}} -
{typeof props.children === 'function' ? props.children(open) : props.children}
+
{(!props.deferMount || open || hasOpened) && (typeof props.children === 'function' ? props.children(open) : props.children)}
; } diff --git a/packages/ui/src/transcript-scroll-authority.tsx b/packages/ui/src/transcript-scroll-authority.tsx index 07bb6751ea1..90043552d8e 100644 --- a/packages/ui/src/transcript-scroll-authority.tsx +++ b/packages/ui/src/transcript-scroll-authority.tsx @@ -63,6 +63,11 @@ export interface TranscriptLayout { reveal(turnId: string, options: { align: 'start' | 'center'; smooth: boolean }): void; /** The list knows its viewport; until then it renders no Turns. */ measured(): boolean; + /** Upward reader input near the beginning; true while earlier history is available. */ + readEarlier?(): boolean; + hasNewer?(): boolean; + readNewer?(): boolean; + readLatest?(): void; } /** How a list of Turns changed: where Turns were added, if anywhere but in place. */ @@ -150,7 +155,6 @@ function reachesTranscript(event: Event, root: HTMLElement, direction: 'up' | 'd export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { let root: HTMLElement | null = null; let pinned = true; - let awayFromTail = false; // Geometry belongs to a known input operation, never the other way around. // scrollend also covers smooth keyboard scrolling and touchpad inertia. let gesture: { top: number; direction?: 'up' | 'down' } | undefined; @@ -169,7 +173,7 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { framesLeft: number; frame?: number; } | undefined; - let snapshot: TranscriptScrollSnapshot = { pinned, positioning: false, awayFromTail, readingTurnId }; + let snapshot: TranscriptScrollSnapshot = { pinned, positioning: false, awayFromTail: false, readingTurnId }; const listeners = new Set<() => void>(); const distanceToTail = (): number => root ? root.scrollHeight - root.scrollTop - root.clientHeight : 0; @@ -181,6 +185,7 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { return turnId; }; const publish = (): void => { + const awayFromTail = layout?.hasNewer?.() === true || distanceToTail() > BUTTON_THRESHOLD_PX; const next = positioning !== undefined; if (snapshot.pinned === pinned && snapshot.positioning === next && snapshot.awayFromTail === awayFromTail && snapshot.readingTurnId === readingTurnId) return; @@ -195,7 +200,6 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { const writeToTail = (): void => { if (!root) return; root.scrollTop = root.scrollHeight; - awayFromTail = false; publish(); }; /** Puts the Turn in place once; `false` while it is not in the list. */ @@ -251,11 +255,25 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { layout = nextLayout; const previousOverflowAnchor = target.style.overflowAnchor; target.style.overflowAnchor = 'none'; + const readEarlier = (): void => { + if (!positioning && target.scrollTop <= target.clientHeight && layout?.readEarlier?.()) { + pinned = false; + readingTurnId = readTurn(); + } + }; + const readNewer = (): void => { + if (!positioning && distanceToTail() <= target.clientHeight && layout?.readNewer?.()) { + pinned = false; + readingTurnId = readTurn(); + } + }; const begin = (event: Event, direction: 'up' | 'down'): void => { if (event.defaultPrevented || !reachesTranscript(event, target, direction)) return; endPositioning(); const remaining = direction === 'up' ? target.scrollTop : distanceToTail(); gesture = { top: gesture?.top ?? target.scrollTop, direction }; + if (direction === 'up') readEarlier(); + else readNewer(); // An edge gesture produces no scroll and therefore no scrollend. A // passive wheel can arrive after the threaded scroll it caused, already // at the top edge; that input did move the reader. @@ -327,7 +345,6 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { onScrollEnd(); }; const onScroll = (): void => { - awayFromTail = distanceToTail() > BUTTON_THRESHOLD_PX; readingTurnId = readTurn(); if (gesture) { const delta = target.scrollTop - gesture.top; @@ -342,6 +359,8 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { if ((delta < 0 ? 'up' : 'down') === direction) { gesture.direction = direction; pinned = false; + if (direction === 'up') readEarlier(); + else readNewer(); } } } @@ -359,7 +378,7 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { if (gesture !== ended || ended.top !== top || pointer !== undefined || touchHeld) return; // Input that can scroll and observed reader movement already release // the pin. Settling an unmoved edge gesture must not release it too. - pinned = pinned || (ended.direction === 'down' && distanceToTail() <= PIN_THRESHOLD_PX); + pinned = pinned || (ended.direction === 'down' && !layout?.hasNewer?.() && distanceToTail() <= PIN_THRESHOLD_PX); gesture = undefined; publish(); if (pinned) writeToTail(); @@ -385,7 +404,6 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { const box = new ResizeObserver(() => { if (pinned && !gesture) writeToTail(); else if (positioning) place(); - awayFromTail = distanceToTail() > BUTTON_THRESHOLD_PX; readingTurnId = readTurn(); publish(); }); @@ -428,6 +446,7 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { }; }, pinToTail() { + if (layout?.hasNewer?.()) layout.readLatest?.(); endPositioning(); gesture = undefined; pointer = undefined; @@ -440,7 +459,6 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { endPositioning(); gesture = undefined; pinned = false; - awayFromTail = distanceToTail() > BUTTON_THRESHOLD_PX; publish(); }, navigate(navigation) { @@ -469,6 +487,7 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { navigation.arrival?.then(settled, settled); }, turnsChanged(change) { + publish(); if (change === 'same' || change === 'append') { settle(); return; diff --git a/packages/ui/src/use-chat-scroll.ts b/packages/ui/src/use-chat-scroll.ts index 592e1e35f65..cb6b733c348 100644 --- a/packages/ui/src/use-chat-scroll.ts +++ b/packages/ui/src/use-chat-scroll.ts @@ -73,6 +73,10 @@ export function useChatScroll(input: { restoreTarget?: { turnId: string; unavailable?: boolean }; viewportNavigation?: TranscriptViewportNavigation; onReadingAnchorChange?(turnId?: string): void; + onReadEarlier?(): boolean; + hasLaterHistory?: boolean; + onReadLater?(): boolean; + onReadLatest?(): void; behavior: ScrollBehavior; /** `useTranscriptStartMargin`'s live measurement, for reads within a commit. */ measureStartMargin(): number; @@ -81,6 +85,10 @@ export function useChatScroll(input: { const authority = useTranscriptScrollAuthority(); const turnIdsRef = useRef(input.turnIds); turnIdsRef.current = input.turnIds; + const readEarlierRef = useRef(input.onReadEarlier); + readEarlierRef.current = input.onReadEarlier; + const laterRef = useRef(input); + laterRef.current = input; /** * `virtua` caches measured heights by position. Growth at the tail leaves @@ -145,6 +153,10 @@ export function useChatScroll(input: { const measureStartMarginRef = useRef(input.measureStartMargin); measureStartMarginRef.current = input.measureStartMargin; const [layout] = useState((): TranscriptLayout => ({ + readEarlier: () => readEarlierRef.current?.() ?? false, + hasNewer: () => laterRef.current.hasLaterHistory === true, + readNewer: () => laterRef.current.onReadLater?.() ?? false, + readLatest: () => laterRef.current.onReadLatest?.(), turnAt(scrollTop) { const handle = input.virtualizerRef.current; const turnIds = turnIdsRef.current; @@ -199,7 +211,7 @@ export function useChatScroll(input: { useLayoutEffect(() => { authority.turnsChanged(list.current.change); - }, [authority, input.turnIds]); + }, [authority, input.turnIds, input.hasLaterHistory]); useEffect(() => input.viewportNavigation?.subscribe((sessionId) => { if (activation.current?.sessionId !== sessionId) return;