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 25db639bcd..d1b8c7939a 100644 --- a/apps/desktop/src/main/__tests__/composer-submission-owner.test.ts +++ b/apps/desktop/src/main/__tests__/composer-submission-owner.test.ts @@ -181,7 +181,7 @@ function harness(options: { hasDurableMessage: (id) => messages.some((message) => message.id === id), }, ready: async () => {}, waitForDurableMessage: async () => true, - reload: async () => {}, loadEarlier: async () => {}, observationChanged: () => {}, + reload: async () => {}, holdsCachedTranscript: () => false, loadEarlier: async () => {}, observationChanged: () => {}, close: async () => {}, }; }; diff --git a/apps/desktop/src/main/__tests__/conversation-compaction.test.ts b/apps/desktop/src/main/__tests__/conversation-compaction.test.ts index 7d4330fba2..be313c9b30 100644 --- a/apps/desktop/src/main/__tests__/conversation-compaction.test.ts +++ b/apps/desktop/src/main/__tests__/conversation-compaction.test.ts @@ -151,7 +151,7 @@ describe('Conversation owner context compaction', () => { hasDurableMessage: () => false, }, ready: async () => {}, waitForDurableMessage: async () => true, - reload: async () => {}, loadEarlier: async () => {}, observationChanged: () => {}, + reload: async () => {}, holdsCachedTranscript: () => false, loadEarlier: async () => {}, observationChanged: () => {}, 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 69892c3280..8f10c4a119 100644 --- a/apps/desktop/src/main/__tests__/conversation-owner.test.ts +++ b/apps/desktop/src/main/__tests__/conversation-owner.test.ts @@ -74,7 +74,7 @@ function harness(options: { hasDurableMessage: (id) => messages.some((message) => message.id === id), }, ready: async () => {}, waitForDurableMessage: async () => true, - reload: async () => {}, loadEarlier: async () => {}, observationChanged: () => {}, + reload: async () => {}, holdsCachedTranscript: () => false, loadEarlier: async () => {}, observationChanged: () => {}, close: async () => { resource.closed = true; }, }; }; diff --git a/apps/desktop/src/main/__tests__/conversation-transcript-retry.test.ts b/apps/desktop/src/main/__tests__/conversation-transcript-retry.test.ts new file mode 100644 index 0000000000..d601c8c0aa --- /dev/null +++ b/apps/desktop/src/main/__tests__/conversation-transcript-retry.test.ts @@ -0,0 +1,303 @@ +/* + * 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 { afterEach, beforeEach, describe, it, mock } from 'node:test'; +import { act, createElement, Fragment } from 'react'; +import { LocaleProvider, ToastProvider, useToast } from '@maka/ui'; +import { deferred } from '@maka/core/test-only/async-primitives'; +import type { StoredMessage } from '@maka/core/session'; +import type { DesktopTranscriptHandle } from '../../preload/transcript-contract.js'; +import type { DesktopSessionSummary } from '../../shared/desktop-session-projection.js'; +import { parseDesktopSessionKey } from '../../shared/runtime-host-identity.js'; +import { encodeDesktopTranscriptSnapshot } from '../desktop-transcript-ipc.js'; +import { getDesktopConversationCopy } from '../../renderer/application/contracts/conversation-copy.js'; +import { transcriptRefreshTitle } from '../../renderer/application/contracts/transcript-copy.js'; +import { createSessionCatalogController, SessionCatalogContext } from '../../renderer/application/contracts/session-catalog/session-catalog-state.js'; +import { ConversationProvider, ConversationServicesProvider, ConversationLifecycle, ConversationTranscriptRegion, useAppShellSessionUiState } from '../../renderer/features/conversation/index.js'; +import { stubConversationServices, useConversationOwner } from '../../renderer/features/conversation/testing.js'; +import { createDesktopTranscriptRangeController, DesktopTranscriptRangeStore, openDesktopTranscriptHistory } from '../../renderer/platform/desktop/desktop-transcript-range-store.js'; +import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; +import { withComposerSubmission } from './composer-submission-fixture.js'; + +const A = JSON.stringify(['host', 'A']); +const B = JSON.stringify(['host', 'B']); +const row = (id: string): DesktopSessionSummary => ({ + id, name: id, isFlagged: false, isArchived: false, labels: [], hasUnread: false, + status: 'active', backend: 'ai-sdk', revision: 1, runtimeHostId: 'host', + profileId: 'local', profileName: 'Local', profileKind: 'local', + llmConnectionSlug: 'test', connectionLocked: false, model: 'test', permissionMode: 'ask', +}); + +type TranscriptRead = { + sessionId: string; + cancelled: boolean; + closed: boolean; + /** A `cached` answer is the snapshot Main serves while the Host reconnects. */ + succeed(text: string, source?: 'live' | 'cached'): void; + fail(): void; +}; +type TranscriptView = { + messages: readonly StoredMessage[]; + messageLoadError?: string; + messageLoadRetryPending: boolean; + onRetryMessages?: () => void; +}; + +function harness() { + const { root } = installReactRenderer(); + const catalog = createSessionCatalogController(); + catalog.commitSessions([A, B].map(row)); + const reads: TranscriptRead[] = []; + /** Error toasts, as title then description. */ + const toastErrors: [string, string | undefined][] = []; + const recordedToasts = new WeakSet(); + const services = stubConversationServices(); + services.observation.openTranscript = (sessionKey, onError) => { + const store = new DesktopTranscriptRangeStore(sessionKey); + return createDesktopTranscriptRangeController(store, + openDesktopTranscriptHistory((_sessionKey, accept, registerCancellation) => { + const result = deferred(); + const { sessionId } = parseDesktopSessionKey(sessionKey); + const index = reads.length; + const read: TranscriptRead = { + sessionId: sessionKey, cancelled: false, closed: false, + succeed(text, source = 'live') { + const generation = `${source === 'cached' ? 'cached:' : ''}read-${index}`; + let deliverySequence = 0; + for (const batch of encodeDesktopTranscriptSnapshot({ + sessionId, generation, hostEpoch: 'epoch', durableThrough: 1, + beginsAtTurnBoundary: true, hasOlder: false, + durable: [{ sequence: 1, message: { type: 'user', id: generation, text, turnId: generation, ts: 1 } }], + })) accept({ ...batch, deliverySequence: ++deliverySequence }); + result.resolve({ + sessionId, generation, hostEpoch: 'epoch', readThroughMessageId: null, + close: async () => { read.closed = true; }, + acknowledgeTail: async () => {}, loadEarlier: async () => {}, + }); + }, + fail() { result.reject(new Error('transient transcript read failure')); }, + }; + registerCancellation?.(() => { read.cancelled = true; }); + reads.push(read); + return result.promise; + }, sessionKey, (batch) => { store.accept(batch); }), { onError }); + }; + services.observation.subscribeEvents = (_sessionId, _event, phase) => { + phase('ready'); + return () => {}; + }; + let owner!: ReturnType; + let target!: ReturnType; + let transcript!: TranscriptView; + function Transcript(props: TranscriptView) { transcript = props; return null; } + function Shell() { + const toast = useToast(); + if (!recordedToasts.has(toast)) { + recordedToasts.add(toast); + const error = toast.error; + toast.error = (title, description, ...rest) => { + toastErrors.push([title, description]); + return error(title, description, ...rest); + }; + } + target = useAppShellSessionUiState(); + owner = useConversationOwner(); + return createElement(Fragment, null, + createElement(ConversationLifecycle, { + refreshSessions: async () => [], onExecutionBoundaryChanged() {}, + showModelSetupToast() {}, onTurnCompleted() {}, searchTarget: null, clearSearchTarget() {}, + }), + createElement(ConversationTranscriptRegion, { surface: Transcript, localInteractionAvailable: true }), + ); + } + act(() => root.render(createElement(LocaleProvider, { locale: 'en', children: + createElement(ToastProvider, { children: + createElement(SessionCatalogContext.Provider, { value: catalog, children: + createElement(ConversationServicesProvider, { services, children: + createElement(ConversationProvider, { children: withComposerSubmission(createElement(Shell)) }), + }), + }), + }), + }))); + return { + root, catalog, reads, toastErrors, + get owner() { return owner; }, get target() { return target; }, get transcript() { return transcript; }, + async failInitialReads() { + await act(async () => target.setActiveId(A)); + await act(async () => reads[0]!.fail()); + assert.equal(reads.length, 2, 'observation readiness permits one automatic recovery'); + await act(async () => reads[1]!.fail()); + assert.ok(transcript.messageLoadError); + assert.deepEqual(transcript.messages, []); + assert.equal(owner.workspace.transcriptRangeRef.current, undefined); + }, + }; +} + +describe('Conversation transcript retry', () => { + beforeEach(() => { mock.method(console, 'error', () => undefined); }); + afterEach(() => mock.restoreAll()); + afterEach(cleanupFakeDom); + + it('recovers through the reader Retry after the initial read and automatic recovery fail', async () => { + const h = harness(); + await h.failInitialReads(); + await act(async () => h.transcript.onRetryMessages?.()); + assert.equal(h.reads.length, 3, 'Retry must issue another transcript read before any publication'); + assert.equal(h.transcript.messageLoadRetryPending, true); + assert.equal(h.owner.workspace.transcriptRangeRef.current, undefined, 'Retry does not publish an unreadable controller'); + await act(async () => h.reads[2]!.succeed('restored A')); + assert.deepEqual(h.transcript.messages.map((message) => message.type === 'user' ? message.text : undefined), ['restored A']); + assert.equal(h.transcript.messageLoadError, undefined); + assert.equal(h.transcript.messageLoadRetryPending, false); + }); + + it('keeps automatic recovery after one initial failure', async () => { + const h = harness(); + await act(async () => h.target.setActiveId(A)); + await act(async () => h.reads[0]!.fail()); + assert.equal(h.reads.length, 2); + await act(async () => h.reads[1]!.succeed('automatic recovery')); + assert.deepEqual(h.transcript.messages.map((message) => message.type === 'user' ? message.text : undefined), ['automatic recovery']); + assert.equal(h.transcript.messageLoadError, undefined); + }); + + it('coalesces repeated Retry clicks and permits another attempt after rejection', async () => { + const h = harness(); + await h.failInitialReads(); + const readError = h.transcript.messageLoadError; + const reported = h.toastErrors.length; + await act(async () => { h.transcript.onRetryMessages?.(); h.transcript.onRetryMessages?.(); }); + assert.equal(h.reads.length, 3); + await act(async () => h.reads[2]!.fail()); + assert.deepEqual(h.toastErrors.slice(reported), [[getDesktopConversationCopy('en').actions.messageReadFailedTitle, readError]], + 'a failed Retry is reported once'); + assert.equal(h.transcript.messageLoadError, readError, 'a failed Retry keeps the read error'); + assert.equal(h.transcript.messageLoadRetryPending, false); + await act(async () => h.transcript.onRetryMessages?.()); + assert.equal(h.reads.length, 4); + await act(async () => h.reads[3]!.succeed('manual recovery')); + assert.deepEqual(h.transcript.messages.map((message) => message.type === 'user' ? message.text : undefined), ['manual recovery']); + assert.equal(h.transcript.messageLoadError, undefined); + assert.equal(h.toastErrors.length, reported + 1, 'a successful Retry reports nothing'); + }); + + it('reports a failed Retry once while the cached transcript is shown', async () => { + const h = harness(); + await act(async () => h.target.setActiveId(A)); + await act(async () => h.reads[0]!.succeed('cached A', 'cached')); + assert.equal(h.reads.length, 2, 'a cached answer still asks for the live transcript'); + await act(async () => h.reads[1]!.fail()); + assert.deepEqual(h.toastErrors, [], 'the controller withholds failures over the cached transcript'); + let refreshed: boolean | undefined; + await act(async () => { refreshed = await h.owner.commands.refreshMessages(A); }); + assert.equal(refreshed, false, 'a later refresh meets the failed live read'); + const refreshError = h.transcript.messageLoadError; + assert.ok(refreshError); + const reported = h.toastErrors.length; + await act(async () => h.transcript.onRetryMessages?.()); + assert.equal(h.reads.length, 3); + await act(async () => h.reads[2]!.fail()); + assert.deepEqual(h.toastErrors.slice(reported), [[transcriptRefreshTitle('en'), refreshError]], + 'a failed Retry over the cached transcript is reported once'); + assert.equal(h.transcript.messageLoadError, refreshError); + assert.equal(h.transcript.messageLoadRetryPending, false); + assert.deepEqual(h.transcript.messages.map((message) => message.type === 'user' ? message.text : undefined), ['cached A']); + await act(async () => h.transcript.onRetryMessages?.()); + await act(async () => h.reads[3]!.succeed('live A')); + assert.deepEqual(h.transcript.messages.map((message) => message.type === 'user' ? message.text : undefined), ['live A']); + assert.equal(h.transcript.messageLoadError, undefined); + assert.equal(h.toastErrors.length, reported + 1); + }); + + it('does not retry the published Session after another Session is requested', async () => { + const h = harness(); + await act(async () => h.target.setActiveId(A)); + await act(async () => h.reads[0]!.succeed('published A')); + const retryA = h.transcript.onRetryMessages; + await act(async () => { h.target.setActiveId(B); retryA?.(); }); + assert.deepEqual(h.reads.map((read) => read.sessionId), [A, B]); + assert.deepEqual(h.transcript.messages.map((message) => message.type === 'user' ? message.text : undefined), ['published A']); + assert.equal(h.reads[0]!.cancelled, true); + await act(async () => h.reads[1]!.succeed('published B')); + assert.deepEqual(h.transcript.messages.map((message) => message.type === 'user' ? message.text : undefined), ['published B']); + }); + + for (const outcome of ['success', 'failure'] as const) { + it(`ignores a late ${outcome} from Retry after switching Sessions`, async () => { + const h = harness(); + await h.failInitialReads(); + await act(async () => h.transcript.onRetryMessages?.()); + assert.equal(h.reads.length, 3); + await act(async () => h.target.setActiveId(B)); + assert.equal(h.reads[2]!.cancelled, true); + await act(async () => h.reads[3]!.succeed('current B')); + await act(async () => outcome === 'success' ? h.reads[2]!.succeed('late A') : h.reads[2]!.fail()); + assert.equal(h.target.activeId, B); + assert.deepEqual(h.transcript.messages.map((message) => message.type === 'user' ? message.text : undefined), ['current B']); + assert.equal(h.transcript.messageLoadError, undefined); + assert.equal(h.transcript.messageLoadRetryPending, false); + await act(async () => h.transcript.onRetryMessages?.()); + assert.equal(h.reads[4]!.sessionId, B); + await act(async () => h.reads[4]!.succeed('refreshed B')); + assert.deepEqual(h.transcript.messages.map((message) => message.type === 'user' ? message.text : undefined), ['refreshed B']); + }); + } + + for (const outcome of ['success', 'failure'] as const) { + it(`ignores a late ${outcome} from an observation replaced within the same Session`, async () => { + const h = harness(); + await h.failInitialReads(); + await act(async () => h.transcript.onRetryMessages?.()); + assert.equal(h.reads.length, 3); + await act(async () => h.catalog.commitSessions([{ ...row(A), profileId: 'replacement' }, row(B)])); + assert.equal(h.reads.length, 4, 'a changed observation authority opens a new controller'); + assert.equal(h.reads[2]!.cancelled, true); + await act(async () => h.reads[3]!.succeed('replacement A')); + await act(async () => outcome === 'success' ? h.reads[2]!.succeed('retired A') : h.reads[2]!.fail()); + assert.deepEqual(h.transcript.messages.map((message) => message.type === 'user' ? message.text : undefined), ['replacement A']); + assert.equal(h.transcript.messageLoadError, undefined); + assert.equal(h.transcript.messageLoadRetryPending, false); + await act(async () => h.transcript.onRetryMessages?.()); + assert.equal(h.reads.length, 5, 'Retry now belongs to the replacement observation'); + await act(async () => h.reads[4]!.succeed('refreshed replacement A')); + assert.deepEqual(h.transcript.messages.map((message) => message.type === 'user' ? message.text : undefined), ['refreshed replacement A']); + }); + } + + for (const outcome of ['success', 'failure'] as const) { + it(`retires the retry controller on unmount and ignores late ${outcome}`, async () => { + const h = harness(); + await h.failInitialReads(); + await act(async () => h.transcript.onRetryMessages?.()); + assert.equal(h.reads.length, 3); + const retryA = h.transcript.onRetryMessages; + await act(async () => h.root.unmount()); + assert.equal(h.reads[2]!.cancelled, true); + await act(async () => { + retryA?.(); + if (outcome === 'success') h.reads[2]!.succeed('retired A'); + else h.reads[2]!.fail(); + }); + assert.equal(h.reads.length, 3); + if (outcome === 'success') assert.equal(h.reads[2]!.closed, true); + }); + } +}); 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 3265e3778d..0421864baa 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 @@ -48,6 +48,7 @@ export function useConversationObservation(options: { ConversationTranscriptController >; transcriptRangeRef: RefBox; + observationRef: import('../model/conversation-workspace.js').ConversationWorkspace['observationRef']; setSessionEventHealthBySession: SessionEventHealthUpdater; toastApi: Pick; }) { @@ -125,6 +126,8 @@ export function useConversationObservation(options: { const controller = options.services.openTranscript(activeId, (error) => { if (!disposed) applyReadError(activeId, error); }); + const observation = { sessionId: activeId, controller }; + options.observationRef.current = observation; const transcript = controller.store; const unsubscribeTranscript = transcript.subscribe(() => applyTranscript(activeId, controller, () => !disposed)); @@ -175,6 +178,7 @@ export function useConversationObservation(options: { subscribeSessionEvents(); return () => { disposed = true; + if (options.observationRef.current === observation) options.observationRef.current = undefined; options.endObservation(activeId); observationAttempt += 1; if (observationRetryTimer !== undefined) { 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 258ac69b7b..d488d7f9bc 100644 --- a/apps/desktop/src/renderer/features/conversation/model/conversation-workspace.ts +++ b/apps/desktop/src/renderer/features/conversation/model/conversation-workspace.ts @@ -46,6 +46,10 @@ export function createConversationWorkspace(catalog: SessionCatalogController, s const activeIdRef = { current: undefined as string | undefined }; const messagesRef = { current: [] as StoredMessage[] }; const transcriptRangeRef = { current: undefined as ConversationTranscriptController | undefined }; + const observationRef = { current: undefined as { + readonly sessionId: string; + readonly controller: ConversationTranscriptController; + } | undefined }; const transientMessagesBySessionRef = { current: new Map>() }; const selectionRevisionRef = { current: 0 }; let seed = INITIAL_LIVE_CONTENT_SEED; @@ -121,7 +125,7 @@ export function createConversationWorkspace(catalog: SessionCatalogController, s }); if (hasNewTaskReloadIntent()) bootstrapSelectionLease.release(); return { - ui, activeIdRef, transcriptRangeRef, bootstrapSelectionLease, commands, + ui, activeIdRef, transcriptRangeRef, observationRef, bootstrapSelectionLease, commands, publishedSession: Object.freeze({ get current() { return activeIdRef.current; } }), messages: reader((value) => value.messages), usage: (model: string | undefined, connectionId: string | undefined) => reader((value) => 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 3066fadda9..812e122e44 100644 --- a/apps/desktop/src/renderer/features/conversation/model/transcript-commands.ts +++ b/apps/desktop/src/renderer/features/conversation/model/transcript-commands.ts @@ -22,7 +22,7 @@ import type { UiLocale } from '@maka/core/ui-locale'; import type { ConversationWorkspace } from './conversation-workspace.js'; import { transcriptErrorMessage, transcriptRefreshTitle } from '../../../application/contracts/transcript-copy.js'; export function createTranscriptCommands(workspace: ConversationWorkspace, feedback: { current: { locale: UiLocale; toast: Pick, 'error'> } }) { - const { ui, activeIdRef, transcriptRangeRef } = workspace; + const { ui, activeIdRef, transcriptRangeRef, observationRef } = workspace; const reportError = (sessionId: string, error: unknown) => { const { locale, toast } = feedback.current; const message = transcriptErrorMessage(error, locale, 'refresh'); @@ -51,10 +51,16 @@ export function createTranscriptCommands(workspace: ConversationWorkspace, feedb } }, async retryMessages(sessionId: string) { - if (activeIdRef.current !== sessionId || !ui.messageRetryPending.claim(sessionId)) return; - const controller = transcriptRangeRef.current; - try { await controller?.reload(); } - catch (error) { if (activeIdRef.current === sessionId && transcriptRangeRef.current === controller) reportError(sessionId, error); } + const observation = observationRef.current; + if (!observation || observation.sessionId !== sessionId || !workspace.commands.isSessionSelected(sessionId) + || !ui.messageRetryPending.claim(sessionId)) return; + // The controller reports a failed reload through the observation's onError, as for its own + // reloads, except over the cached transcript, where only this Retry can answer the click. + try { await observation.controller.reload(); } + catch (error) { + if (observation.controller.holdsCachedTranscript() && observationRef.current === observation + && workspace.commands.isSessionSelected(sessionId)) reportError(sessionId, error); + } finally { ui.messageRetryPending.release(sessionId); } }, }; diff --git a/apps/desktop/src/renderer/features/conversation/transcript-ports.ts b/apps/desktop/src/renderer/features/conversation/transcript-ports.ts index e83eaa025d..022247c64c 100644 --- a/apps/desktop/src/renderer/features/conversation/transcript-ports.ts +++ b/apps/desktop/src/renderer/features/conversation/transcript-ports.ts @@ -34,6 +34,7 @@ export interface ConversationTranscriptController { waitForDurableMessage(messageId: string, timeoutMs: number): Promise; loadEarlier(throughSequence?: number): Promise; reload(): Promise; + holdsCachedTranscript(): boolean; observationChanged(phase: 'pending' | 'ready'): void; close(): Promise; } 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 ad1373e185..e98851b7e9 100644 --- a/apps/desktop/src/renderer/features/conversation/ui/conversation-lifecycle.tsx +++ b/apps/desktop/src/renderer/features/conversation/ui/conversation-lifecycle.tsx @@ -108,7 +108,7 @@ export function ConversationLifecycle(props: { services: services.observation, uiLocale, toastApi, activeId: requested?.localState !== 'pending' ? requested?.id : undefined, observationAuthorityRevision: authority.current.generation, - activeIdRef, transcriptRangeRef, + activeIdRef, transcriptRangeRef, observationRef: workspace.observationRef, handleEvent: handlers.handleEvent, publishTranscript: workspace.publishTranscript, commitTranscript: workspace.commitTranscript, 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 ced7f8ca8b..14268912b2 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 @@ -40,6 +40,8 @@ export interface DesktopTranscriptRangeController { /** One budget of earlier history, or everything down to `throughSequence` in one answer. */ loadEarlier(throughSequence?: number): Promise; reload(): Promise; + /** Whether the range holds the cached transcript, over which a failed reload is not reported. */ + holdsCachedTranscript(): boolean; observationChanged(phase: 'pending' | 'ready'): void; close(): Promise; } @@ -246,6 +248,7 @@ export function createDesktopTranscriptRangeController( }, loadEarlier, reload, + holdsCachedTranscript: cached, observationChanged: recovery.observationChanged, async close() { if (closed) return;