From 737197b0921d0240c2ebdfd8434bb80a14eeef6a Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 21 Sep 2026 15:45:05 +0800 Subject: [PATCH 1/2] fix(desktop): stop cached transcript previews offering earlier history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit transcripts.open replays the locally cached tail before the live history answer so the previous content is visible while the Host reads. That snapshot carries the replica's tail bound as hasOlder, so the view rendered a load-earlier control for a generation that cannot serve the read (loadEarlier no-ops on cached:), then replaced it wholesale once the live answer arrived — the "load earlier history" flash on every session switch. A cached generation is provisional by contract and every live-only affordance already gates on it. Close the two leaks: range() reports hasOlder only for generations that can answer earlier reads, and the reading-position restore waits for the live answer instead of concluding a bookmarked Turn is unreachable from the cached tail. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> Generated-by: Devin --- .../desktop-transcript-range-store.test.ts | 3 + .../transcript-open-cached-preview.test.ts | 124 ++++++++++++++++++ ...script-reading-position-controller.test.ts | 59 +++++++++ .../controller/transcript-reading-position.ts | 4 +- .../desktop/desktop-transcript-range-store.ts | 4 +- 5 files changed, 192 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/transcript-open-cached-preview.test.ts 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 b7813e50b9..28f7285d0f 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 @@ -1345,6 +1345,8 @@ test('cached fallback remains readable and retries once per observation generati await settle(); assert.equal(opens, 1); assert.equal(store.range().generation, 'cached:generation'); + assert.equal(store.range().hasOlder, false, 'a cached transcript offers no earlier history to load'); + assert.equal(store.snapshot().hasOlder, false); await controller.loadEarlier(); assert.equal(earlierReads, 0, 'a cached transcript has no Host to read earlier history from'); controller.observationChanged('ready'); @@ -1359,6 +1361,7 @@ test('cached fallback remains readable and retries once per observation generati await settle(); assert.equal(opens, 3); assert.equal(store.range().generation, 'live-generation'); + assert.equal(store.range().hasOlder, true); assert.deepEqual(errors, []); await controller.close(); }); diff --git a/apps/desktop/src/main/__tests__/transcript-open-cached-preview.test.ts b/apps/desktop/src/main/__tests__/transcript-open-cached-preview.test.ts new file mode 100644 index 0000000000..968b855a54 --- /dev/null +++ b/apps/desktop/src/main/__tests__/transcript-open-cached-preview.test.ts @@ -0,0 +1,124 @@ +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; +import { runInNewContext } from 'node:vm'; +import test from 'node:test'; +import { build } from 'esbuild'; +import type { StoredMessage } from '@maka/core/session'; +import type { MakaBridge } from '../../preload/bridge-contract.js'; +import type { DesktopTranscriptBatch } from '../../preload/transcript-contract.js'; +import { encodeDesktopTranscriptSnapshot } from '../desktop-transcript-ipc.js'; +import { DesktopTranscriptRangeStore } from '../../renderer/platform/desktop/desktop-transcript-range-store.js'; +import { desktopSessionKey } from '../../shared/runtime-host-identity.js'; +import { waitFor } from '@maka/core/test-only/async-primitives'; + +// transcripts.open replays the locally cached tail before the live history +// answer so the previous content is visible while the Host reads. That preview +// must never advertise earlier history: nothing can answer the read until the +// live generation replaces it. +test('the cached preview publishes no earlier history before the live answer replaces it', async () => { + const owner = { + hostId: 'host-1', targetEpoch: 'epoch-1', profileId: 'local', + profileName: 'Local', profileKind: 'local', profileAccess: 'owner', readiness: 'ready', + }; + const sessionId = desktopSessionKey({ hostId: owner.hostId, sessionId: 'session-1' }); + const message = (id: string, turnId: string): StoredMessage => ({ + type: 'user', id, turnId, ts: 1, text: id, + }); + const cachedTail = [ + { sequence: 19, message: message('m19', 't19') }, + { sequence: 20, message: message('m20', 't20') }, + ]; + const full = Array.from({ length: 20 }, (_, index) => ({ + sequence: index + 1, message: message(`m${index + 1}`, `t${index + 1}`), + })); + let bridge: MakaBridge | undefined; + let consumerId = ''; + let deliverySequence = 0; + const listeners = new Map void>(); + const deliver = (batch: Omit) => { + listeners.get(`sessions:transcript:${consumerId}`)?.({}, owner, { + ...batch, deliverySequence: ++deliverySequence, + }); + }; + const ipcRenderer = { + on(channel: string, listener: (...args: unknown[]) => void) { listeners.set(channel, listener); }, + off(channel: string) { listeners.delete(channel); }, + send() {}, + async invoke(channel: string, ...args: unknown[]): Promise { + if (channel === 'runtime-host:activeIdentity') return owner; + if (channel === 'runtime-host:identities') return [owner]; + if (channel === 'session-local:transcript') { + return { + cachedAt: 1, + batches: [...encodeDesktopTranscriptSnapshot({ + beginsAtTurnBoundary: true, + sessionId: 'session-1', generation: 'cached:g1', hostEpoch: 'epoch-1', + durableThrough: 20, durable: cachedTail, hasOlder: true, + })], + }; + } + if (channel === 'sessions:transcript:open') { + consumerId = args[2] as string; + setImmediate(() => { + for (const batch of encodeDesktopTranscriptSnapshot({ + beginsAtTurnBoundary: true, + sessionId: 'session-1', generation: 'live-1', hostEpoch: 'epoch-1', + durableThrough: 20, durable: full, hasOlder: false, + })) deliver(batch); + }); + return { kind: 'ready', value: { + sessionId: 'session-1', generation: 'live-1', hostEpoch: 'epoch-1', + readThroughMessageId: null, + } }; + } + if ( + channel === 'sessions:transcript:ack' || + channel === 'sessions:transcript:acknowledge-tail' || + channel === 'sessions:transcript:close' + ) return; + throw new Error(`Unexpected channel: ${channel}`); + }, + }; + const bundle = await build({ + entryPoints: [fileURLToPath(new URL('../../../src/preload/preload.ts', import.meta.url))], + bundle: true, write: false, platform: 'node', format: 'cjs', external: ['electron'], + }); + const require = createRequire(import.meta.url); + runInNewContext(bundle.outputFiles[0]!.text, { + require: (id: string) => id === 'electron' ? { + ipcRenderer, + contextBridge: { exposeInMainWorld(name: string, value: MakaBridge) { + if (name === 'maka') bridge = value; + } }, + } : require(id), + process: { env: {} }, Buffer, console, setTimeout, clearTimeout, TextEncoder, TextDecoder, + Uint8Array, crypto: globalThis.crypto, + }); + assert.ok(bridge); + + const store = new DesktopTranscriptRangeStore(sessionId); + const publications: Array<{ ids: string[]; hasOlder: boolean }> = []; + store.subscribe(() => { + const snapshot = store.snapshot(); + if (snapshot.ready) { + publications.push({ + ids: snapshot.messages.map((entry) => entry.id), + hasOlder: snapshot.hasOlder, + }); + } + }); + const handle = await bridge.transcripts.open( + sessionId, + (batch) => store.accept(batch), + () => {}, + 'history', + ); + await waitFor(() => publications.length === 2, { timeoutMs: 5_000 }); + await handle.close(); + + assert.deepEqual(publications, [ + { ids: ['m19', 'm20'], hasOlder: false }, + { ids: full.map((entry) => entry.message.id), hasOlder: false }, + ]); +}); 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 07bd13194d..1064164562 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 @@ -119,6 +119,65 @@ test('a bookmark the Turn index does not know, or cannot be asked about, is unav } }); +test('a cached transcript keeps a stored bookmark pending until the live answer replaces it', async () => { + const store = new DesktopTranscriptRangeStore(SESSION_ID); + for (const batch of encodeDesktopTranscriptSnapshot({ + beginsAtTurnBoundary: true, + ...IDENTITY, generation: 'cached:generation', durableThrough: 30, + durable: [{ sequence: 30, message: answer('c') }], hasOlder: true, + })) store.accept(batch); + const reads: (number | undefined)[] = []; + const lookups: string[] = []; + const controller = createDesktopTranscriptRangeController(store, async () => handle({ + async loadEarlier(throughSequence) { + reads.push(throughSequence); + for (const batch of encodeDesktopTranscriptBatches(IDENTITY, { + durableThrough: 30, + durable: [{ sequence: 10, message: answer('a') }, { sequence: 20, message: answer('b') }], + hasOlder: false, earlierThan: 30, reset: false, ready: true, + })) store.accept(batch); + }, + }), { onError: (error) => assert.fail(String(error)) }); + let anchor: { turnId: string } | undefined = { turnId: 'a' }; + const unavailable: string[] = []; + const lifecycle = createTranscriptRestoreLifecycle(); + const restore = () => restoreSessionTranscriptRange({ + lifecycle, sessionId: SESSION_ID, controller, readingAnchor: { turnId: 'a' }, + isCurrent: () => true, + lookupTurn: async (_sessionId, turnId) => { lookups.push(turnId); return 10; }, + setReadingAnchor: (_sessionId, next) => { anchor = next; }, + onRestoreUnavailable: (_sessionId, turnId) => { unavailable.push(turnId); }, + onError: (error) => assert.fail(String(error)), + }); + try { + await controller.ready(); + restore(); + for (let tick = 0; tick < 4; tick += 1) await settle(); + restore(); + await settle(); + assert.deepEqual(lookups, []); + assert.deepEqual(reads, []); + assert.deepEqual(anchor, { turnId: 'a' }); + assert.deepEqual(unavailable, []); + for (const batch of encodeDesktopTranscriptSnapshot({ + beginsAtTurnBoundary: true, + ...IDENTITY, durableThrough: 30, + durable: [{ sequence: 30, message: answer('c') }], hasOlder: true, + })) store.accept(batch); + restore(); + for (let tick = 0; tick < 4; tick += 1) await settle(); + restore(); + await settle(); + assert.deepEqual(lookups, ['a']); + assert.deepEqual(reads, [10]); + assert.deepEqual(store.snapshot().messages.map(({ turnId }) => turnId), ['a', 'b', 'c']); + assert.deepEqual(anchor, { turnId: 'a' }); + assert.deepEqual(unavailable, []); + } finally { + await controller.close(); + } +}); + test('sending before transcript open completes cancels the queued bookmark without delaying admission', { timeout: 5_000 }, async () => { const store = new DesktopTranscriptRangeStore(SESSION_ID); const opening = deferred(); 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 9791c1e864..186899f3b2 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 @@ -168,7 +168,9 @@ export function restoreSessionTranscriptRange(options: { const command = options.lifecycle.request(options); if (!command || command.loading || !controller || !sessionId || !options.isCurrent(sessionId, controller)) return; const range = currentTranscriptRange(controller, sessionId); - if (!range?.ready) return; + // A cached range is replaced wholesale by the live answer; only that answer + // can say whether the target Turn is reachable. + if (!range?.ready || range.generation?.startsWith('cached:')) return; const { turnId } = command.target; if (controller.store.snapshot().messages.some((message) => message !== null && typeof message === 'object' && 'turnId' in message && message.turnId === turnId, 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 2e57ad0610..ced7f8ca8b 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 @@ -474,7 +474,9 @@ export class DesktopTranscriptRangeStore { hostEpoch: this.#hostEpoch, durableThrough: this.#value.through, oldestSequence: this.#value.order[0] ?? null, - hasOlder: this.#value.hasOlder, + // A cached snapshot cannot serve earlier reads; the live answer replaces + // it rather than continuing it. + hasOlder: this.#value.hasOlder && !this.#generation.startsWith('cached:'), beginsAtTurnBoundary: this.#value.beginsAtTurnBoundary, ready: this.#ready, }; From 99cb762903d51d40ee7c0ab9d91997eea3dd6389 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 21 Sep 2026 16:12:08 +0800 Subject: [PATCH 2/2] fix(desktop): publish session transcripts once, behind a fade swap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switching sessions used to paint the locally cached tail first — a prefix-truncated copy whose hasOlder flag rendered a dead load-earlier control — then replace it wholesale when the live answer arrived. The cache now stands in only when the live open fails, which also removes a per-open session-local disk read. The switch keeps the previous transcript inert and dimmed while the live read is in flight, then fades the new transcript in on its keyed remount. Generated-by: Devin --- .../transcript-open-cached-preview.test.ts | 124 ++++++++++++------ apps/desktop/src/preload/preload.ts | 46 ++++--- .../src/renderer/styles/chat-message.css | 14 ++ packages/ui/src/chat-view.tsx | 2 +- 4 files changed, 123 insertions(+), 63 deletions(-) diff --git a/apps/desktop/src/main/__tests__/transcript-open-cached-preview.test.ts b/apps/desktop/src/main/__tests__/transcript-open-cached-preview.test.ts index 968b855a54..aab907cc84 100644 --- a/apps/desktop/src/main/__tests__/transcript-open-cached-preview.test.ts +++ b/apps/desktop/src/main/__tests__/transcript-open-cached-preview.test.ts @@ -1,3 +1,22 @@ +/* + * 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 { createRequire } from 'node:module'; import { fileURLToPath } from 'node:url'; @@ -12,32 +31,36 @@ import { DesktopTranscriptRangeStore } from '../../renderer/platform/desktop/des import { desktopSessionKey } from '../../shared/runtime-host-identity.js'; import { waitFor } from '@maka/core/test-only/async-primitives'; -// transcripts.open replays the locally cached tail before the live history -// answer so the previous content is visible while the Host reads. That preview -// must never advertise earlier history: nothing can answer the read until the -// live generation replaces it. -test('the cached preview publishes no earlier history before the live answer replaces it', async () => { - const owner = { - hostId: 'host-1', targetEpoch: 'epoch-1', profileId: 'local', - profileName: 'Local', profileKind: 'local', profileAccess: 'owner', readiness: 'ready', - }; - const sessionId = desktopSessionKey({ hostId: owner.hostId, sessionId: 'session-1' }); - const message = (id: string, turnId: string): StoredMessage => ({ - type: 'user', id, turnId, ts: 1, text: id, - }); - const cachedTail = [ - { sequence: 19, message: message('m19', 't19') }, - { sequence: 20, message: message('m20', 't20') }, - ]; - const full = Array.from({ length: 20 }, (_, index) => ({ - sequence: index + 1, message: message(`m${index + 1}`, `t${index + 1}`), - })); +const OWNER = { + hostId: 'host-1', targetEpoch: 'epoch-1', profileId: 'local', + profileName: 'Local', profileKind: 'local', profileAccess: 'owner', readiness: 'ready', +}; +const SESSION_ID = desktopSessionKey({ hostId: OWNER.hostId, sessionId: 'session-1' }); + +const message = (id: string, turnId: string): StoredMessage => ({ + type: 'user', id, turnId, ts: 1, text: id, +}); +const CACHED_TAIL = [ + { sequence: 19, message: message('m19', 't19') }, + { sequence: 20, message: message('m20', 't20') }, +]; +const FULL = Array.from({ length: 20 }, (_, index) => ({ + sequence: index + 1, message: message(`m${index + 1}`, `t${index + 1}`), +})); + +interface Harness { + readonly bridge: MakaBridge; + readonly state: { cacheReads: number }; +} + +async function preloadHarness(options: { liveOpenFails?: boolean }): Promise { let bridge: MakaBridge | undefined; let consumerId = ''; let deliverySequence = 0; + const state = { cacheReads: 0 }; const listeners = new Map void>(); const deliver = (batch: Omit) => { - listeners.get(`sessions:transcript:${consumerId}`)?.({}, owner, { + listeners.get(`sessions:transcript:${consumerId}`)?.({}, OWNER, { ...batch, deliverySequence: ++deliverySequence, }); }; @@ -46,25 +69,27 @@ test('the cached preview publishes no earlier history before the live answer rep off(channel: string) { listeners.delete(channel); }, send() {}, async invoke(channel: string, ...args: unknown[]): Promise { - if (channel === 'runtime-host:activeIdentity') return owner; - if (channel === 'runtime-host:identities') return [owner]; + if (channel === 'runtime-host:activeIdentity') return OWNER; + if (channel === 'runtime-host:identities') return [OWNER]; if (channel === 'session-local:transcript') { + state.cacheReads += 1; return { cachedAt: 1, batches: [...encodeDesktopTranscriptSnapshot({ beginsAtTurnBoundary: true, sessionId: 'session-1', generation: 'cached:g1', hostEpoch: 'epoch-1', - durableThrough: 20, durable: cachedTail, hasOlder: true, + durableThrough: 20, durable: CACHED_TAIL, hasOlder: true, })], }; } if (channel === 'sessions:transcript:open') { consumerId = args[2] as string; + if (options.liveOpenFails) throw new Error('live transcript unavailable'); setImmediate(() => { for (const batch of encodeDesktopTranscriptSnapshot({ beginsAtTurnBoundary: true, sessionId: 'session-1', generation: 'live-1', hostEpoch: 'epoch-1', - durableThrough: 20, durable: full, hasOlder: false, + durableThrough: 20, durable: FULL, hasOlder: false, })) deliver(batch); }); return { kind: 'ready', value: { @@ -96,29 +121,44 @@ test('the cached preview publishes no earlier history before the live answer rep Uint8Array, crypto: globalThis.crypto, }); assert.ok(bridge); + return { bridge, state }; +} - const store = new DesktopTranscriptRangeStore(sessionId); - const publications: Array<{ ids: string[]; hasOlder: boolean }> = []; +function publications(store: DesktopTranscriptRangeStore) { + const seen: Array<{ ids: string[]; hasOlder: boolean }> = []; store.subscribe(() => { const snapshot = store.snapshot(); if (snapshot.ready) { - publications.push({ - ids: snapshot.messages.map((entry) => entry.id), - hasOlder: snapshot.hasOlder, - }); + seen.push({ ids: snapshot.messages.map((entry) => entry.id), hasOlder: snapshot.hasOlder }); } }); - const handle = await bridge.transcripts.open( - sessionId, - (batch) => store.accept(batch), - () => {}, - 'history', - ); - await waitFor(() => publications.length === 2, { timeoutMs: 5_000 }); + return seen; +} + +// A healthy open publishes the live answer as the first history; the cached +// tail is not a preview the reader ever sees. +test('a healthy transcript open publishes only the live answer and never reads the cache', async () => { + const { bridge, state } = await preloadHarness({ liveOpenFails: false }); + const store = new DesktopTranscriptRangeStore(SESSION_ID); + const seen = publications(store); + const handle = await bridge.transcripts.open(SESSION_ID, (batch) => store.accept(batch), () => {}, 'history'); + await waitFor(() => seen.length === 1, { timeoutMs: 5_000 }); await handle.close(); + assert.deepEqual(seen, [{ ids: FULL.map((entry) => entry.message.id), hasOlder: false }]); + assert.equal(state.cacheReads, 0); +}); - assert.deepEqual(publications, [ - { ids: ['m19', 'm20'], hasOlder: false }, - { ids: full.map((entry) => entry.message.id), hasOlder: false }, - ]); +// The cache stands in only when the live read never answered; even then it +// advertises no earlier history because nothing can serve the read. +test('a failed live open falls back to the cached transcript without earlier history', async () => { + const { bridge, state } = await preloadHarness({ liveOpenFails: true }); + const store = new DesktopTranscriptRangeStore(SESSION_ID); + const seen = publications(store); + const handle = await bridge.transcripts.open(SESSION_ID, (batch) => store.accept(batch), () => {}, 'history'); + await waitFor(() => seen.length === 1, { timeoutMs: 5_000 }); + assert.deepEqual(seen, [{ ids: ['m19', 'm20'], hasOlder: false }]); + assert.equal(state.cacheReads, 1); + assert.equal(handle.generation, 'cached:g1'); + await assert.rejects(handle.loadEarlier(), /Reconnect the Host/); + await handle.close(); }); diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index abb4dfb668..2466beb435 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -2669,6 +2669,7 @@ const makaBridge = { const channel = `sessions:transcript:${consumerId}`; let identity: DesktopTranscriptIdentity | undefined; let cachedIdentity: DesktopTranscriptIdentity | undefined; + let session: Awaited> | undefined; const retiredGenerations = new Set(); let closed = false; let requestClose = () => {}; @@ -2712,22 +2713,15 @@ const makaBridge = { } }; ipcRenderer.on(channel, listener); - const openDispatch = runtimeHostSessionRef(sessionId).then(async (session) => { - consumerScope = session.scope; - const cached = await invokeWhenReady( - 'session-local:transcript', session.scope, session.sessionId, - ).catch(() => null) as import('../shared/session-local-contract.js').DesktopCachedTranscript | null; + const openDispatch = runtimeHostSessionRef(sessionId).then(async (ref) => { + consumerScope = ref.scope; + session = ref; if (closed) throw new Error('Desktop transcript open was cancelled'); - // Local frames do not participate in the live consumer identity or ACK window. - for (const [index, batch] of (cached?.batches ?? []).entries()) { - handler({ ...batch, deliverySequence: index + 1 }); - if (batch.ready) cachedIdentity = { generation: batch.generation, hostEpoch: batch.hostEpoch }; - } return { completion: invokeWhenReady( 'sessions:transcript:open', - session.scope, - session.sessionId, + ref.scope, + ref.sessionId, consumerId, mode, resumeFrom ?? null, @@ -2752,14 +2746,26 @@ const makaBridge = { const cancelled = closed; closed = true; ipcRenderer.off(channel, listener); - if (!cancelled && cachedIdentity && !identity) { - const unavailable = async () => { throw new Error('Reconnect the Host to load uncached history'); }; - return { - ...cachedIdentity, sessionId, readThroughMessageId: null, - acknowledgeTail: unavailable, - loadEarlier: unavailable, - close: async () => {}, - }; + if (!cancelled && !identity && session) { + // The cache stands in only when the live read never answered; on a + // healthy open the first published history is the live answer itself. + const cached = await invokeWhenReady( + 'session-local:transcript', session.scope, session.sessionId, + ).catch(() => null) as import('../shared/session-local-contract.js').DesktopCachedTranscript | null; + // Local frames do not participate in the live consumer identity or ACK window. + for (const [index, batch] of (cached?.batches ?? []).entries()) { + handler({ ...batch, deliverySequence: index + 1 }); + if (batch.ready) cachedIdentity = { generation: batch.generation, hostEpoch: batch.hostEpoch }; + } + if (cachedIdentity) { + const unavailable = async () => { throw new Error('Reconnect the Host to load uncached history'); }; + return { + ...cachedIdentity, sessionId, readThroughMessageId: null, + acknowledgeTail: unavailable, + loadEarlier: unavailable, + close: async () => {}, + }; + } } throw error; } diff --git a/apps/desktop/src/renderer/styles/chat-message.css b/apps/desktop/src/renderer/styles/chat-message.css index 45132f41f2..e4ae008588 100644 --- a/apps/desktop/src/renderer/styles/chat-message.css +++ b/apps/desktop/src/renderer/styles/chat-message.css @@ -42,6 +42,20 @@ width: 100%; } +/* Like the startup reveal (#5494), the previous transcript stays put until the + live answer mounts the new one, which fades in once — no cached tail, no + intermediate partial state. */ +.maka-chat-session-swap { + animation-name: maka-stream-fade-in; + animation-duration: var(--duration-emphasized); + animation-timing-function: var(--ease-out-strong); +} +@media (prefers-reduced-motion: reduce) { + .maka-chat-session-swap { + animation: none; + } +} + /* Native anchoring handles ordinary content growth. The scroll authority disables it during following and atomic range replacement, when it owns the position write itself. */ diff --git a/packages/ui/src/chat-view.tsx b/packages/ui/src/chat-view.tsx index 33a1351ad6..fc28dcf1aa 100644 --- a/packages/ui/src/chat-view.tsx +++ b/packages/ui/src/chat-view.tsx @@ -806,7 +806,7 @@ export function ChatView(props: { ? emptyContent : null} {loadEarlierHistoryControl} -
+