From 71b1357624068a4724870187de0db58dd65d1b45 Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Wed, 2 Sep 2026 00:03:46 +0800 Subject: [PATCH 1/3] fix(ui): restore session transcript reading position Generated-by: Codex --- apps/desktop/e2e/transcript-scroll.spec.ts | 99 ++++++- .../app-shell-session-ui-state.test.ts | 24 ++ .../renderer/app-shell-session-ui-state.ts | 39 +++ apps/desktop/src/renderer/app-shell.tsx | 68 ++++- .../ui/src/__tests__/use-chat-scroll.test.tsx | 259 ++++++++++++++++++ packages/ui/src/chat-view.tsx | 5 + packages/ui/src/use-chat-scroll.ts | 124 +++++++-- 7 files changed, 590 insertions(+), 28 deletions(-) create mode 100644 packages/ui/src/__tests__/use-chat-scroll.test.tsx diff --git a/apps/desktop/e2e/transcript-scroll.spec.ts b/apps/desktop/e2e/transcript-scroll.spec.ts index acc885b2d7..1d419da3d2 100644 --- a/apps/desktop/e2e/transcript-scroll.spec.ts +++ b/apps/desktop/e2e/transcript-scroll.spec.ts @@ -17,7 +17,7 @@ * under the License. */ -import { expect, test, COMPOSER_INPUT } from './fixtures'; +import { expect, test, COMPOSER_INPUT, ensureSidebarExpanded } from './fixtures'; import type { Page } from '@playwright/test'; /** @@ -99,6 +99,15 @@ function turnTop(page: Page, turnId: string): Promise { }, turnId); } +function turnOffsetFromScroller(page: Page, turnId: string): Promise { + return page.evaluate(([selector, id]) => { + const root = document.querySelector(selector); + const turn = document.querySelector(`[data-turn-id="${CSS.escape(id as string)}"]`); + if (!root || !turn) return Number.POSITIVE_INFINITY; + return Math.round(turn.getBoundingClientRect().top - root.getBoundingClientRect().top); + }, [SCROLLER, turnId] as const); +} + /** * Sample the tail through the frames a growing transcript produces. * @@ -206,6 +215,94 @@ test('a streaming answer keeps the viewport at the tail', async ({ window: page expect(await scrollButtonOffered(page)).toBe(false); }); +test('switching Sessions restores a Turn anchor while a tail Session follows background growth', async ({ + promptRailWindow: page, +}) => { + test.slow(); + await page.setViewportSize({ width: 900, height: 700 }); + await ensureSidebarExpanded(page); + const rows = page.locator('.maka-session-row'); + const selected = rows.locator('button.astryx-side-nav-item.selected'); + const readingSessionId = await selected.evaluate( + (button) => button.closest('.maka-session-row')?.getAttribute('data-session-id'), + ); + if (!readingSessionId) throw new Error('the prompt-rail Session is not selected'); + const tailSessionId = await rows.evaluateAll( + (items, active) => items + .map((row) => row.getAttribute('data-session-id')) + .find((sessionId) => sessionId !== active) ?? null, + readingSessionId, + ); + if (!tailSessionId) throw new Error('the fixture has no tail-intent Session'); + const rowButton = (sessionId: string) => page.locator( + `.maka-session-row[data-session-id=${JSON.stringify(sessionId)}] button`, + ).first(); + + // Move the active bounded range away from the latest window, then establish + // a reading position through the real scroll event path. Reopening the + // Session now has to use the saved sequence before the Turn can exist. + await page.locator('.maka-prompt-rail-tick').first().click({ force: true }); + const readingTurnId = 'turn-prompt-rail-1'; + await expect(page.locator(`[data-turn-id="${readingTurnId}"]`)).toHaveCount(1, { + timeout: 20_000, + }); + await expect.poll( + async () => Math.abs(await turnOffsetFromScroller(page, readingTurnId)), + { message: 'the unloaded prompt reaches the scroller start' }, + ).toBeLessThanOrEqual(24); + // The prompt rail holds its target through late content measurement. Let it + // hand the viewport back before switching, exactly as a reader who paused on + // the selected prompt would. + await page.waitForTimeout(1_200); + expect(await scrollButtonOffered(page)).toBe(true); + + await rowButton(tailSessionId).click(); + await expect(rowButton(tailSessionId)).toHaveClass(/selected/); + await waitForPaintedFrames(page, 6); + expect(await distanceToTail(page)).toBeLessThanOrEqual(4); + // The fixture Session predates connection identities. Choosing any current + // model upgrades it onto the E2E Runtime Host before this test starts a Turn. + const modelSwitcher = page.getByRole('button', { name: '切换当前任务模型' }); + await modelSwitcher.click(); + await page.getByRole('menuitem', { name: 'glm-4.5', exact: true }).click(); + await expect(modelSwitcher).toContainText('glm-4.5'); + + await page.evaluate((sessionId) => { + const state = { complete: false, unsubscribe: () => undefined }; + state.unsubscribe = window.maka.sessions.subscribeEvents(sessionId, (event) => { + if (event.type !== 'complete') return; + state.complete = true; + state.unsubscribe(); + }); + (window as typeof window & { __makaBackgroundTailProbe?: typeof state }) + .__makaBackgroundTailProbe = state; + }, tailSessionId); + await sendPrompt(page, LONG_PROMPT); + await expect(page.locator('.maka-user-message', { hasText: '第 1 行' })).toBeVisible(); + + // The transcript collapses before each async replacement. This round trip + // therefore exercises the production ordering that made a saved scrollTop + // become zero, not merely a pre-filled test tree. + await rowButton(readingSessionId).click(); + await expect.poll( + async () => Math.abs(await turnOffsetFromScroller(page, readingTurnId)), + { timeout: 20_000, message: 'the saved reading Turn returns to the scroller start' }, + ).toBeLessThanOrEqual(4); + expect(await scrollButtonOffered(page)).toBe(true); + + await expect.poll(() => page.evaluate(() => ( + window as typeof window & { + __makaBackgroundTailProbe?: { complete: boolean }; + } + ).__makaBackgroundTailProbe?.complete === true), { timeout: 30_000 }).toBe(true); + await rowButton(tailSessionId).click(); + await expect(rowButton(tailSessionId)).toHaveClass(/selected/); + await waitForPaintedFrames(page, 6); + const tail = await scrollMetrics(page); + expect(tail.distance, JSON.stringify(tail)).toBeLessThanOrEqual(4); + expect(await scrollButtonOffered(page)).toBe(false); +}); + /** * The turn wrappers are not the transcript. It also renders the optimistic user * message, the no-tail live fallback and orphaned conversation items outside diff --git a/apps/desktop/src/main/__tests__/app-shell-session-ui-state.test.ts b/apps/desktop/src/main/__tests__/app-shell-session-ui-state.test.ts index 696071e638..7bd053662e 100644 --- a/apps/desktop/src/main/__tests__/app-shell-session-ui-state.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-session-ui-state.test.ts @@ -234,6 +234,30 @@ describe('app shell session UI state controller', () => { assert.deepEqual(Object.keys(controller.sessionEventHealthBySessionRef.current), ['keep']); }); + it('owns per-session transcript reading anchors without notifying render subscribers', () => { + let notifications = 0; + const controller = createAppShellSessionUiStateController(); + controller.subscribe(() => { + notifications += 1; + }); + + controller.setTranscriptReadingAnchor('drop', { turnId: 'turn-drop', sequence: 7 }); + controller.setTranscriptReadingAnchor('keep', { turnId: 'turn-keep', sequence: 11 }); + controller.setTranscriptReadingAnchor('drop', { turnId: 'turn-drop' }); + + assert.deepEqual(controller.transcriptReadingAnchorBySessionRef.current, { + drop: { turnId: 'turn-drop', sequence: 7 }, + keep: { turnId: 'turn-keep', sequence: 11 }, + }); + assert.equal(notifications, 0, 'reading anchors have no live render subscriber'); + + controller.setTranscriptReadingAnchor('keep', undefined); + controller.clearSessionUiState('drop'); + + assert.deepEqual(controller.transcriptReadingAnchorBySessionRef.current, {}); + assert.equal(notifications, 0); + }); + it('keeps the synchronous live-turn ref aligned with reducer updates', () => { const controller = createAppShellSessionUiStateController(); const projection = armLiveTurn('turn-1'); diff --git a/apps/desktop/src/renderer/app-shell-session-ui-state.ts b/apps/desktop/src/renderer/app-shell-session-ui-state.ts index 8c91c09c69..145aa8f6fb 100644 --- a/apps/desktop/src/renderer/app-shell-session-ui-state.ts +++ b/apps/desktop/src/renderer/app-shell-session-ui-state.ts @@ -57,6 +57,11 @@ export interface SessionPendingClaim { release(key: string): void; } +export interface TranscriptReadingAnchor { + readonly turnId: string; + readonly sequence?: number; +} + const SESSION_UI_MAP_KEYS = [ 'messageLoadErrorBySession', 'messageRetryPendingBySession', @@ -145,6 +150,13 @@ export function createAppShellSessionUiStateController( const sessionEventHealthBySessionRef: { current: Record } = { current: {}, }; + // A reading anchor is renderer-only intent. Keeping it outside observed + // state means ordinary scrolling does not render the shell, while the + // controller still owns the same deletion lifetime as every other Session + // UI registry. + const transcriptReadingAnchorBySessionRef: { + current: Record; + } = { current: {} }; // The ref mirrors whatever is about to become current, so it is already // correct when the synchronous notification reaches a listener that reads it. @@ -197,6 +209,7 @@ export function createAppShellSessionUiStateController( subscribe: state.subscribe, liveTurnBySessionRef, sessionEventHealthBySessionRef, + transcriptReadingAnchorBySessionRef, setMessageLoadErrorBySession: createMapSetter('messageLoadErrorBySession'), messageRetryPending: createPendingClaim('messageRetryPendingBySession'), stopPending: createPendingClaim('stopPendingBySession'), @@ -207,6 +220,28 @@ export function createAppShellSessionUiStateController( setSessionEventHealthBySession: ((updater) => { sessionEventHealthBySessionRef.current = updater(sessionEventHealthBySessionRef.current); }) satisfies StateUpdater>, + setTranscriptReadingAnchor: ( + sessionId: string, + anchor: TranscriptReadingAnchor | undefined, + ) => { + const current = transcriptReadingAnchorBySessionRef.current; + if (!anchor) { + transcriptReadingAnchorBySessionRef.current = omitSessionKey(current, sessionId); + return; + } + const previous = current[sessionId]; + // A temporarily unavailable bounded range cannot make a known sequence + // less true. The Turn identity is the semantic anchor; sequence is + // monotonic metadata for reopening it later. + const next = + previous?.turnId === anchor.turnId && + previous.sequence !== undefined && + anchor.sequence === undefined + ? previous + : anchor; + if (previous === next) return; + transcriptReadingAnchorBySessionRef.current = { ...current, [sessionId]: next }; + }, /** * The authority said something about `turnId` — it started, failed to * start, or ended. Drop that arm's `unconfirmed` claim so a session list @@ -226,6 +261,10 @@ export function createAppShellSessionUiStateController( sessionEventHealthBySessionRef.current, sessionId, ); + transcriptReadingAnchorBySessionRef.current = omitSessionKey( + transcriptReadingAnchorBySessionRef.current, + sessionId, + ); replaceState(clearAppShellSessionUiStateForSession(state.getState(), sessionId)); }, clearTurnTransientStateIfCurrent: ( diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 9c30c227a2..b7669e7693 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -2419,16 +2419,36 @@ function AppShellContent({ }; }, [activeId, activeIdRef, newestDurablePromptSequence, transcriptTurnIndex]); useEffect(() => { - const target = searchScrollTarget; - if (!target || target.sessionId !== activeId || target.sequence === undefined) return; - const sequence = target.sequence; + if (!activeId) return; + const searchTarget = searchScrollTarget?.sessionId === activeId + ? searchScrollTarget + : undefined; + const readingAnchor = sessionUiController.transcriptReadingAnchorBySessionRef.current[activeId]; + const target = searchTarget + ? searchTarget.sequence === undefined + ? undefined + : { sessionId: activeId, turnId: searchTarget.turnId, sequence: searchTarget.sequence } + : readingAnchor?.sequence !== undefined + ? { sessionId: activeId, turnId: readingAnchor.turnId, sequence: readingAnchor.sequence } + : undefined; + if (!target) return; const controller = transcriptRangeRef.current; if (!controller) return; let disposed = false; void controller.ready() - .then(() => controller.loadAround(sequence)) - .then(() => { + .then(async () => { + if ( + disposed || + transcriptRangeRef.current !== controller || + activeIdRef.current !== target.sessionId || + controller.store.sequenceForTurn(target.turnId) !== null + ) return false; + await controller.loadAround(target.sequence); + return true; + }) + .then((loaded) => { if ( + !loaded || disposed || transcriptRangeRef.current !== controller || activeIdRef.current !== target.sessionId @@ -2436,7 +2456,11 @@ function AppShellContent({ setMessages([...controller.store.snapshot().messages]); }) .catch((error) => { - if (disposed || activeIdRef.current !== target.sessionId) return; + if ( + disposed || + transcriptRangeRef.current !== controller || + activeIdRef.current !== target.sessionId + ) return; sessionUiController.setMessageLoadErrorBySession((current) => ({ ...current, [target.sessionId]: localizedShellErrorMessage( @@ -2449,7 +2473,7 @@ function AppShellContent({ return () => { disposed = true; }; - }, [activeId, searchScrollTarget?.nonce]); + }, [activeId, activeSession?.profileId, searchScrollTarget?.nonce]); useShellRunUpdates({ activeId, setShellRunUpdatesBySession: sessionUiController.setShellRunUpdatesBySession, @@ -2603,6 +2627,9 @@ function AppShellContent({ activeId !== undefined || taskEntry.selectors.target !== undefined; const activeMessageLoadError = activeId ? messageLoadErrorBySession[activeId] : undefined; + const activeTranscriptReadingAnchor = activeId + ? sessionUiController.transcriptReadingAnchorBySessionRef.current[activeId] + : undefined; let activeTranscriptRange; try { const controller = transcriptRangeRef.current; @@ -2611,6 +2638,27 @@ function AppShellContent({ } catch { activeTranscriptRange = undefined; } + function handleTranscriptReadingAnchorChange(turnId?: string) { + const sessionId = activeId; + if (!sessionId || activeIdRef.current !== sessionId) return; + if (!turnId) { + sessionUiController.setTranscriptReadingAnchor(sessionId, undefined); + return; + } + const controller = transcriptRangeRef.current; + if (!controller) return; + let sequence: number | undefined; + try { + if (controller.store.range().sessionId !== sessionId) return; + sequence = controller.store.sequenceForTurn(turnId) ?? undefined; + } catch { + return; + } + sessionUiController.setTranscriptReadingAnchor( + sessionId, + sequence === undefined ? { turnId } : { turnId, sequence }, + ); + } async function loadTranscriptHistory(target: 'earlier' | 'latest', anchorTurnId?: string) { const controller = transcriptRangeRef.current; const sessionId = activeId; @@ -3155,6 +3203,12 @@ function AppShellContent({ } : undefined } + restoreTargetTurn={activeTranscriptReadingAnchor + ? { turnId: activeTranscriptReadingAnchor.turnId } + : undefined} + onReadingAnchorChange={activeId + ? handleTranscriptReadingAnchorChange + : undefined} transcriptTurnIndex={ transcriptTurnIndex && transcriptTurnIndex.sessionId === activeId ? transcriptTurnIndex.turns diff --git a/packages/ui/src/__tests__/use-chat-scroll.test.tsx b/packages/ui/src/__tests__/use-chat-scroll.test.tsx new file mode 100644 index 0000000000..a425855012 --- /dev/null +++ b/packages/ui/src/__tests__/use-chat-scroll.test.tsx @@ -0,0 +1,259 @@ +/* + * 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, test } from 'node:test'; +import { act, useRef } from 'react'; +import { createRoot } from 'react-dom/client'; +import { parseHTML } from 'linkedom'; +import type { StoredMessage } from '@maka/core/session'; +import { + TranscriptScrollAuthorityProvider, + useTranscriptScrollAuthority, + type TranscriptScrollAuthority, +} from '../transcript-scroll-authority.js'; +import { useChatScroll } from '../use-chat-scroll.js'; + +const originalGlobals = { + CSS: globalThis.CSS, + document: globalThis.document, + Element: globalThis.Element, + HTMLElement: globalThis.HTMLElement, + MutationObserver: globalThis.MutationObserver, + Node: globalThis.Node, + ResizeObserver: globalThis.ResizeObserver, + window: globalThis.window, +}; +const originalActEnvironment = (globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; +}).IS_REACT_ACT_ENVIRONMENT; + +let mountedRoot: ReturnType | undefined; + +afterEach(async () => { + if (mountedRoot) await act(() => mountedRoot?.unmount()); + mountedRoot = undefined; + Object.assign(globalThis, { + ...originalGlobals, + IS_REACT_ACT_ENVIRONMENT: originalActEnvironment, + }); +}); + +test('a session switch restores a Turn anchor after async fill and preserves tail intent', async () => { + const { document, window } = parseHTML( + '
', + ); + const mount = document.querySelector('#mount'); + const scroller = document.querySelector('#scroller'); + assert.ok(mount); + assert.ok(scroller); + + let scrollHeight = 600; + let scrollTop = 0; + let dispatchCommandScroll = true; + let frameId = 0; + const frames = new Map(); + const resizeCallbacks: ResizeObserverCallback[] = []; + Object.defineProperties(scroller, { + clientHeight: { value: 600 }, + scrollHeight: { get: () => scrollHeight }, + scrollTop: { + get: () => scrollTop, + set: (value: number) => { + scrollTop = Math.max(0, Math.min(value, scrollHeight - 600)); + }, + }, + }); + scroller.getBoundingClientRect = () => ({ + bottom: 600, + height: 600, + left: 0, + right: 800, + top: 0, + width: 800, + x: 0, + y: 0, + toJSON: () => undefined, + }); + + class TestResizeObserver { + constructor(callback: ResizeObserverCallback) { + resizeCallbacks.push(callback); + } + disconnect() {} + observe() {} + unobserve() {} + } + class TestMutationObserver { + disconnect() {} + observe() {} + takeRecords(): MutationRecord[] { return []; } + } + Object.assign(window, { + cancelAnimationFrame: (id: number) => frames.delete(id), + requestAnimationFrame: (callback: FrameRequestCallback) => { + const id = ++frameId; + frames.set(id, callback); + return id; + }, + }); + Object.assign(globalThis, { + CSS: { escape: (value: string) => value }, + document, + Element: window.Element, + HTMLElement: window.HTMLElement, + MutationObserver: TestMutationObserver, + Node: window.Node, + ResizeObserver: TestResizeObserver, + window, + IS_REACT_ACT_ENVIRONMENT: true, + }); + + const installTranscript = ( + height: number, + turns: ReadonlyArray<{ id: string; start: number; height: number }>, + ): void => { + scrollHeight = height; + scroller.replaceChildren(); + for (const turn of turns) { + const element = document.createElement('article'); + element.dataset.turnId = turn.id; + element.getBoundingClientRect = () => ({ + bottom: turn.start + turn.height - scrollTop, + height: turn.height, + left: 0, + right: 800, + top: turn.start - scrollTop, + width: 800, + x: 0, + y: turn.start - scrollTop, + toJSON: () => undefined, + }); + element.scrollIntoView = (options?: boolean | ScrollIntoViewOptions) => { + const block = typeof options === 'object' ? options.block : undefined; + scroller.scrollTop = block === 'center' + ? turn.start - 300 + turn.height / 2 + : turn.start; + if (dispatchCommandScroll) scroller.dispatchEvent(new window.Event('scroll')); + }; + scroller.append(element); + } + }; + const collapseTranscript = (): void => { + scrollHeight = 600; + scrollTop = 0; + scroller.replaceChildren(); + }; + const deliverResize = (): void => { + for (const callback of resizeCallbacks) callback([], {} as ResizeObserver); + }; + const flushFrames = async (): Promise => { + await act(() => { + const pending = [...frames.values()]; + frames.clear(); + for (const callback of pending) callback(0); + }); + }; + + const anchors = new Map(); + let authority: TranscriptScrollAuthority | undefined; + let messageRevision = 0; + let target: { turnId: string; nonce: number } | undefined; + function Harness({ sessionId }: { sessionId: string }) { + const scrollRef = useRef(scroller); + authority = useTranscriptScrollAuthority(); + useChatScroll({ + scrollRef, + sessionId, + messages: [{ id: `message-${messageRevision}` }] as StoredMessage[], + target, + restoreTarget: anchors.has(sessionId) ? { turnId: anchors.get(sessionId)! } : undefined, + onReadingAnchorChange: (turnId) => { + if (turnId) anchors.set(sessionId, turnId); + else anchors.delete(sessionId); + }, + behavior: 'auto', + }); + return null; + } + + const renderSession = async (sessionId: string): Promise => { + messageRevision += 1; + await act(() => mountedRoot?.render( + + + , + )); + }; + + installTranscript(3_000, [ + { id: 'turn-a-1', start: 0, height: 800 }, + { id: 'turn-a-2', start: 800, height: 600 }, + { id: 'turn-a-3', start: 1_400, height: 1_600 }, + ]); + mountedRoot = createRoot(mount); + await renderSession('session-a'); + assert.equal(scroller.scrollTop, 2_400); + + scroller.scrollTop = 900; + scroller.dispatchEvent(new window.Event('scroll')); + assert.equal(anchors.get('session-a'), 'turn-a-2'); + + collapseTranscript(); + await renderSession('session-b'); + installTranscript(2_000, [{ id: 'turn-b-1', start: 0, height: 2_000 }]); + deliverResize(); + assert.equal(scroller.scrollTop, 1_400); + assert.equal(anchors.has('session-b'), false); + + collapseTranscript(); + await renderSession('session-a'); + assert.equal(authority?.getSnapshot().pinned, false); + installTranscript(2_000, [{ id: 'turn-a-latest', start: 0, height: 2_000 }]); + await renderSession('session-a'); + deliverResize(); + assert.equal(anchors.get('session-a'), 'turn-a-2'); + installTranscript(3_000, [ + { id: 'turn-a-1', start: 0, height: 800 }, + { id: 'turn-a-2', start: 800, height: 600 }, + { id: 'turn-a-3', start: 1_400, height: 1_600 }, + ]); + await renderSession('session-a'); + await flushFrames(); + assert.equal(scroller.scrollTop, 800); + assert.equal(scroller.querySelector('[data-turn-id="turn-a-2"]') + ?.getBoundingClientRect().top, 0); + assert.equal(authority?.getSnapshot().pinned, false); + + collapseTranscript(); + await renderSession('session-b'); + installTranscript(2_400, [{ id: 'turn-b-1', start: 0, height: 2_400 }]); + deliverResize(); + assert.equal(scroller.scrollTop, 1_800); + assert.equal(authority?.getSnapshot().pinned, true); + + // A command can land without producing a scroll event when layout or native + // anchoring already put the Turn at the requested offset. Its semantic + // reading position must still be reported before the user switches away. + dispatchCommandScroll = false; + target = { turnId: 'turn-b-1', nonce: 1 }; + await renderSession('session-b'); + await flushFrames(); + assert.equal(anchors.get('session-b'), 'turn-b-1'); +}); diff --git a/packages/ui/src/chat-view.tsx b/packages/ui/src/chat-view.tsx index ba0fb9c72b..9d214651f9 100644 --- a/packages/ui/src/chat-view.tsx +++ b/packages/ui/src/chat-view.tsx @@ -264,6 +264,9 @@ export function ChatView(props: { * chat view only scrolls/highlights the already-rendered turn. */ scrollTargetTurn?: { turnId: string; nonce: number }; + /** Runtime-only reading position restored without search focus or highlight. */ + restoreTargetTurn?: { turnId: string }; + onReadingAnchorChange?(turnId?: string): void; scrollBehavior: ScrollBehavior; hasOlderHistory?: boolean; onLoadEarlierHistory?(anchorTurnId?: string): Promise | void; @@ -538,6 +541,8 @@ export function ChatView(props: { sessionId: props.activeSession?.id, messages: props.messages, target: props.scrollTargetTurn, + restoreTarget: props.restoreTargetTurn, + onReadingAnchorChange: props.onReadingAnchorChange, behavior: props.scrollBehavior, hasOlderHistory: props.hasOlderHistory, onLoadEarlierHistory: props.onLoadEarlierHistory, diff --git a/packages/ui/src/use-chat-scroll.ts b/packages/ui/src/use-chat-scroll.ts index 5e90444e2a..5b7f120d38 100644 --- a/packages/ui/src/use-chat-scroll.ts +++ b/packages/ui/src/use-chat-scroll.ts @@ -41,6 +41,8 @@ export function useChatScroll(input: { sessionId?: string; messages: readonly StoredMessage[]; target?: { turnId: string; nonce: number }; + restoreTarget?: { turnId: string }; + onReadingAnchorChange?(turnId?: string): void; behavior: ScrollBehavior; hasOlderHistory?: boolean; onLoadEarlierHistory?(anchorTurnId?: string): Promise | void; @@ -51,6 +53,23 @@ export function useChatScroll(input: { loadEarlierRef.current = input.onLoadEarlierHistory; const canLoadEarlier = input.onLoadEarlierHistory !== undefined; const handledTarget = useRef(null); + const anchorChangeRef = useRef(input.onReadingAnchorChange); + anchorChangeRef.current = input.onReadingAnchorChange; + const reportReadingAnchor = useRef<(() => void) | undefined>(undefined); + const reportedAnchor = useRef<{ sessionId?: string; turnId?: string } | undefined>(undefined); + const activation = useRef<{ sessionId?: string; restoreTurnId?: string } | undefined>(undefined); + if (activation.current?.sessionId !== input.sessionId) { + activation.current = { + sessionId: input.sessionId, + restoreTurnId: input.restoreTarget?.turnId, + }; + } + const commandTarget = useRef(null); + commandTarget.current = input.target?.turnId + ? `search:${input.sessionId ?? ''}:${input.target.turnId}:${input.target.nonce}` + : activation.current?.restoreTurnId + ? `restore:${input.sessionId ?? ''}:${activation.current.restoreTurnId}` + : null; // A passive effect, not a layout one: the scroller is Astryx's layout root, // an ancestor, and React attaches a parent's ref after its children's layout @@ -58,13 +77,49 @@ export function useChatScroll(input: { // which lands after passive effects, so this is still installed in time. useEffect(() => authority.attach(input.scrollRef.current), [authority, input.scrollRef]); - // A new conversation arrives at its tail. Nothing special positions it: the - // pin is set here and the first fill is growth like any other, so it takes - // the one path instead of a first-fill path of its own. + // A new conversation either resumes a semantic reading position or arrives + // at its tail. Releasing before an async fill is essential: an empty + // transcript clamps every pixel offset to zero, but it cannot erase a Turn + // identity. useEffect(() => { - authority.pinToTail(); + if (activation.current?.restoreTurnId) authority.releasePin(); + else authority.pinToTail(); }, [input.sessionId]); + useEffect(() => { + const report = (): void => { + const snapshot = authority.getSnapshot(); + // A release is part of both navigation commands. Until the command has + // actually landed, neither an intermediate bounded range nor an empty + // one says anything new about where the reader intended to be. + if (commandTarget.current && handledTarget.current !== commandTarget.current) return; + const turnId = snapshot.pinned + ? undefined + : firstVisibleTurnId(input.scrollRef.current); + // An empty bounded range has no new reading position. In particular, + // releasing the pin before a remembered range loads must not erase the + // Turn that caused that range to be requested. + if (!snapshot.pinned && !turnId) return; + const previous = reportedAnchor.current; + if ( + previous !== undefined && + previous.sessionId === input.sessionId && + previous.turnId === turnId + ) return; + reportedAnchor.current = { sessionId: input.sessionId, turnId }; + anchorChangeRef.current?.(turnId); + }; + reportReadingAnchor.current = report; + report(); + const stopWatchingPolicy = authority.subscribe(report); + const stopWatchingReader = authority.subscribeToReaderScroll(report); + return () => { + if (reportReadingAnchor.current === report) reportReadingAnchor.current = undefined; + stopWatchingPolicy(); + stopWatchingReader(); + }; + }, [authority, input.scrollRef, input.sessionId]); + useEffect(() => { const root = input.scrollRef.current; if (!root || !input.hasOlderHistory || !canLoadEarlier) return; @@ -72,17 +127,14 @@ export function useChatScroll(input: { // request while one is in flight, and asking for history the reader // already has is idempotent anyway. const requestEarlier = (): void => { - const rootTop = root.getBoundingClientRect().top; - const anchor = [...root.querySelectorAll('[data-turn-id]')].find( - (turn) => turn.getBoundingClientRect().bottom > rootTop, - ); + const anchorTurnId = firstVisibleTurnId(root); // The browser anchors the reader against everything that lands above // them, with one exception: it declines while the scroller sits at zero, // which is exactly where a wheel asks for history. One pixel is the whole // fix — measured in Chromium, an insert of 501px above the reader moves // `scrollTop` by 501 at an offset of 1 and by 0 at an offset of 0. if (root.scrollTop < 1) root.scrollTop = 1; - void Promise.resolve(loadEarlierRef.current?.(anchor?.dataset.turnId)).catch(() => undefined); + void Promise.resolve(loadEarlierRef.current?.(anchorTurnId)).catch(() => undefined); }; /** Close enough to the start that the reader is about to reach it. */ const nearStart = (): boolean => @@ -121,13 +173,22 @@ export function useChatScroll(input: { }, [authority, input.hasOlderHistory, canLoadEarlier, input.scrollRef, input.sessionId]); useEffect(() => { - const target = input.target; - if (!target?.turnId) return; + const explicitTarget = input.target?.turnId + ? { kind: 'search' as const, turnId: input.target.turnId, nonce: input.target.nonce } + : undefined; + const restoreTurnId = activation.current?.restoreTurnId; + const target = explicitTarget ?? (restoreTurnId + ? { kind: 'restore' as const, turnId: restoreTurnId } + : undefined); + if (!target) return; + if (explicitTarget) activation.current = { sessionId: input.sessionId }; // This effect re-runs on every transcript update so a target that arrives // before its turn still lands. It stops for good once the turn is on // screen — repeating the release afterwards would take the tail away from // a reader who had already scrolled back to it. - const chosen = `${input.sessionId ?? ''}:${target.turnId}:${target.nonce}`; + const chosen = target.kind === 'search' + ? `search:${input.sessionId ?? ''}:${target.turnId}:${target.nonce}` + : `restore:${input.sessionId ?? ''}:${target.turnId}`; if (handledTarget.current === chosen) return; authority.releasePin(); const frame = window.requestAnimationFrame(() => { @@ -137,24 +198,47 @@ export function useChatScroll(input: { if (!element || !('scrollIntoView' in element)) return; handledTarget.current = chosen; const targetElement = element as HTMLElement; - targetElement.setAttribute('tabindex', '-1'); targetElement.scrollIntoView({ - behavior: input.behavior, - block: 'center', + behavior: target.kind === 'search' ? input.behavior : 'auto', + block: target.kind === 'search' ? 'center' : 'start', }); + // A command can land at the browser's existing offset and therefore + // produce no scroll event. Reuse the authority-backed reporter so that + // switching away still retains the position the command established. + reportReadingAnchor.current?.(); + if (target.kind === 'restore') return; + targetElement.setAttribute('tabindex', '-1'); targetElement.focus({ preventScroll: true }); setHighlightedTurnId(target.turnId); }); - const clear = window.setTimeout(() => { - setHighlightedTurnId((current) => (current === target.turnId ? null : current)); - }, 2200); + const clear = target.kind === 'search' + ? window.setTimeout(() => { + setHighlightedTurnId((current) => (current === target.turnId ? null : current)); + }, 2200) + : undefined; return () => { window.cancelAnimationFrame(frame); - window.clearTimeout(clear); + if (clear !== undefined) window.clearTimeout(clear); }; - }, [input.target?.turnId, input.target?.nonce, input.behavior, input.sessionId, input.messages, input.scrollRef]); + }, [ + input.target?.turnId, + input.target?.nonce, + input.restoreTarget?.turnId, + input.behavior, + input.sessionId, + input.messages, + input.scrollRef, + ]); return { highlightedTurnId, }; } + +function firstVisibleTurnId(root: HTMLElement | null): string | undefined { + if (!root) return undefined; + const rootTop = root.getBoundingClientRect().top; + return [...root.querySelectorAll('[data-turn-id]')] + .find((turn) => turn.getBoundingClientRect().bottom > rootTop) + ?.dataset.turnId; +} From c1311a9cd0cae089c6bc2370f29fbee07d2e1dd4 Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Wed, 2 Sep 2026 01:06:41 +0800 Subject: [PATCH 2/3] refactor(desktop): own transcript reading in conversation Generated-by: Codex --- apps/desktop/e2e/transcript-scroll.spec.ts | 57 +++- apps/desktop/renderer-architecture.json | 29 +- .../app-shell-session-ui-state.test.ts | 27 ++ .../renderer/app-shell-session-ui-state.ts | 280 +--------------- apps/desktop/src/renderer/app-shell.tsx | 180 +++-------- .../renderer/features/conversation/README.md | 30 ++ .../controller/transcript-reading-position.ts | 173 ++++++++++ .../renderer/features/conversation/index.ts | 42 +++ .../conversation/model/observable-state.ts | 35 ++ .../conversation/model/session-ui-state.ts | 302 ++++++++++++++++++ .../model/task-readiness-notice.ts | 121 +++++++ .../src/renderer/features/task-entry/index.ts | 3 - .../src/renderer/task-readiness-notice.ts | 110 +------ 13 files changed, 844 insertions(+), 545 deletions(-) create mode 100644 apps/desktop/src/renderer/features/conversation/README.md create mode 100644 apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position.ts create mode 100644 apps/desktop/src/renderer/features/conversation/index.ts create mode 100644 apps/desktop/src/renderer/features/conversation/model/observable-state.ts create mode 100644 apps/desktop/src/renderer/features/conversation/model/session-ui-state.ts create mode 100644 apps/desktop/src/renderer/features/conversation/model/task-readiness-notice.ts diff --git a/apps/desktop/e2e/transcript-scroll.spec.ts b/apps/desktop/e2e/transcript-scroll.spec.ts index 1d419da3d2..14d9300606 100644 --- a/apps/desktop/e2e/transcript-scroll.spec.ts +++ b/apps/desktop/e2e/transcript-scroll.spec.ts @@ -80,15 +80,15 @@ function scrollMetrics(page: Page): Promise<{ * `toBeVisible` passes on the transparent one. */ function scrollButtonOffered(page: Page): Promise { - return page.evaluate((name) => { + return page.evaluate((names) => { const button = [...document.querySelectorAll('button')].find( - (candidate) => candidate.getAttribute('aria-label') === name - || candidate.textContent?.trim() === name, + (candidate) => names.includes(candidate.getAttribute('aria-label') ?? '') + || names.includes(candidate.textContent?.trim() ?? ''), ); - if (!button) throw new Error(`the "${name}" affordance is missing`); + if (!button) throw new Error(`the "${names.join('" / "')}" affordance is missing`); const style = getComputedStyle(button); return style.pointerEvents !== 'none' && Number(style.opacity) > 0.5; - }, '滚动主对话到底部'); + }, ['滚动主对话到底部', 'Scroll main conversation to bottom']); } function turnTop(page: Page, turnId: string): Promise { @@ -108,6 +108,39 @@ function turnOffsetFromScroller(page: Page, turnId: string): Promise { }, [SCROLLER, turnId] as const); } +function waitForStableTurnAtScrollerStart(page: Page, turnId: string): Promise { + return page.evaluate(([selector, id]) => new Promise((resolve, reject) => { + let previousTop = Number.NaN; + let previousHeight = Number.NaN; + let stableFrames = 0; + const timeout = window.setTimeout( + () => reject(new Error(`Turn ${id} did not settle at the scroller start`)), + 10_000, + ); + const measure = (): void => { + const root = document.querySelector(selector); + const turn = root?.querySelector(`[data-turn-id="${CSS.escape(id)}"]`); + if (root && turn) { + const offset = turn.getBoundingClientRect().top - root.getBoundingClientRect().top; + stableFrames = Math.abs(offset) <= 4 + && root.scrollTop === previousTop + && root.scrollHeight === previousHeight + ? stableFrames + 1 + : 0; + previousTop = root.scrollTop; + previousHeight = root.scrollHeight; + if (stableFrames >= 6) { + window.clearTimeout(timeout); + resolve(); + return; + } + } + window.requestAnimationFrame(measure); + }; + measure(); + }), [SCROLLER, turnId]); +} + /** * Sample the tail through the frames a growing transcript produces. * @@ -250,11 +283,13 @@ test('switching Sessions restores a Turn anchor while a tail Session follows bac async () => Math.abs(await turnOffsetFromScroller(page, readingTurnId)), { message: 'the unloaded prompt reaches the scroller start' }, ).toBeLessThanOrEqual(24); - // The prompt rail holds its target through late content measurement. Let it - // hand the viewport back before switching, exactly as a reader who paused on - // the selected prompt would. - await page.waitForTimeout(1_200); - expect(await scrollButtonOffered(page)).toBe(true); + // The prompt rail holds its target through late content measurement. Six + // unchanged painted frames exceed its own quiet-frame handoff, so switching + // after this point cannot carry that release into the next Session. Observe + // transcript geometry directly: the Astryx dock can be briefly unmounted on + // a loaded Xvfb worker even though the reading position is already stable. + await waitForStableTurnAtScrollerStart(page, readingTurnId); + expect(await distanceToTail(page)).toBeGreaterThan(100); await rowButton(tailSessionId).click(); await expect(rowButton(tailSessionId)).toHaveClass(/selected/); @@ -262,7 +297,7 @@ test('switching Sessions restores a Turn anchor while a tail Session follows bac expect(await distanceToTail(page)).toBeLessThanOrEqual(4); // The fixture Session predates connection identities. Choosing any current // model upgrades it onto the E2E Runtime Host before this test starts a Turn. - const modelSwitcher = page.getByRole('button', { name: '切换当前任务模型' }); + const modelSwitcher = page.locator('.maka-model-switcher-trigger'); await modelSwitcher.click(); await page.getByRole('menuitem', { name: 'glm-4.5', exact: true }).click(); await expect(modelSwitcher).toContainText('glm-4.5'); diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index e5b1745fea..254499816e 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -733,27 +733,18 @@ "nonTriviaTokens": 650 }, "src/renderer/app-shell-session-ui-state.ts": { - "importDeclarations": 6, + "importDeclarations": 0, "bridgePaths": {}, "environmentCapabilities": {}, - "hookCalls": { - "useRef": 1 - }, + "hookCalls": {}, "lifecycleMethods": {}, "unresolvedDependencies": 0, - "actionFactories": [ - "createAppShellSessionUiStateController" - ], + "actionFactories": [], "dependencyPaths": { - "./observable-state.js": 1, - "./shell-run-update-state.js": 1, - "@maka/core/events": 1, - "@maka/core/session-event-health": 1, - "@maka/ui": 1, - "react": 1 + "./features/conversation/index.js": 1 }, - "importSpecifiers": 8, - "nonTriviaTokens": 1183 + "importSpecifiers": 0, + "nonTriviaTokens": 7 }, "src/renderer/app-shell-stop-action.ts": { "importDeclarations": 4, @@ -949,6 +940,7 @@ "./desktop-execution-boundary-surface": 1, "./desktop-slash-command": 1, "./error-boundary": 1, + "./features/conversation": 1, "./features/goals": 1, "./features/module-hub": 1, "./features/session-collaboration": 1, @@ -978,7 +970,6 @@ "./settings/runtime-host-ssh-terminal-dialog.js": 1, "./settings/tasks-settings-page": 1, "./stale-sessions": 1, - "./task-readiness-notice": 1, "./use-active-execution-boundary": 1, "./use-app-shell-composer-quotes": 1, "./use-app-shell-session-ui-reads": 1, @@ -1023,7 +1014,7 @@ "react": 1 }, "importSpecifiers": 186, - "nonTriviaTokens": 15779 + "nonTriviaTokens": 15653 }, "src/renderer/use-app-shell-composer-quotes.ts": { "importDeclarations": 3, @@ -4612,9 +4603,7 @@ "unresolvedDependencies": 0, "actionFactories": [], "dependencyPaths": { - "@maka/core/session-send-projection": 1, - "@maka/core/task-submission-readiness": 1, - "@maka/core/ui-locale": 1 + "./features/conversation/index.js": 1 } }, "src/renderer/theme.ts": { diff --git a/apps/desktop/src/main/__tests__/app-shell-session-ui-state.test.ts b/apps/desktop/src/main/__tests__/app-shell-session-ui-state.test.ts index 7bd053662e..d13cbd22f7 100644 --- a/apps/desktop/src/main/__tests__/app-shell-session-ui-state.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-session-ui-state.test.ts @@ -31,6 +31,7 @@ import { createInitialAppShellSessionUiState, type AppShellSessionUiState, } from '../../renderer/app-shell-session-ui-state.js'; +import { transcriptReadingPosition } from '../../renderer/features/conversation/index.js'; function boundaryRequest(requestId: string): SandboxBoundaryRequestEvent { return { @@ -258,6 +259,32 @@ describe('app shell session UI state controller', () => { assert.equal(notifications, 0); }); + it('enriches a Turn-only reading anchor when its range sequence arrives later', () => { + let anchor: { turnId: string; sequence?: number } | undefined; + transcriptReadingPosition.restoreRange({ + sessionId: 'session', + readingAnchor: { turnId: 'turn' }, + controller: { + store: { + range: () => ({ sessionId: 'session' }), + sequenceForTurn: () => 17, + newestDurableUserSequence: () => 17, + snapshot: () => ({ messages: [] }), + }, + ready: async () => undefined, + loadAround: async () => assert.fail('the resident Turn must not load another range'), + }, + isCurrent: () => true, + setMessages: () => assert.fail('the resident range must not replace messages'), + setReadingAnchor: (_sessionId, next) => { + anchor = next; + }, + onError: (error) => assert.fail(String(error)), + }); + + assert.deepEqual(anchor, { turnId: 'turn', sequence: 17 }); + }); + it('keeps the synchronous live-turn ref aligned with reducer updates', () => { const controller = createAppShellSessionUiStateController(); const projection = armLiveTurn('turn-1'); diff --git a/apps/desktop/src/renderer/app-shell-session-ui-state.ts b/apps/desktop/src/renderer/app-shell-session-ui-state.ts index 145aa8f6fb..ab45782eb6 100644 --- a/apps/desktop/src/renderer/app-shell-session-ui-state.ts +++ b/apps/desktop/src/renderer/app-shell-session-ui-state.ts @@ -17,282 +17,4 @@ * under the License. */ -import { useRef } from 'react'; -import type { MessageQueueEntryProjection } from '@maka/core/events'; -import type { SessionEventStreamSnapshot } from '@maka/core/session-event-health'; -import { confirmLiveTurn, type InteractionQueues, type LiveTurnProjection } from '@maka/ui'; -import { createObservableState } from './observable-state.js'; -import type { ShellRunUpdatesBySession } from './shell-run-update-state.js'; - -type StateUpdater = (updater: (current: T) => T) => void; - -export interface AppShellSessionUiState { - messageLoadErrorBySession: Record; - messageRetryPendingBySession: Record; - stopPendingBySession: Record; - liveTurnBySession: Record; - shellRunUpdatesBySession: ShellRunUpdatesBySession; - interactionBySession: InteractionQueues; - messageQueueBySession: Record; -} - -// The pending plate keeps the Host revision beside its entries so edits can -// reject stale multi-client projections instead of silently overwriting them. -export interface MessageQueueUiState { - readonly queueRevision?: number; - readonly entries: readonly MessageQueueEntryProjection[]; -} - -type AppShellSessionUiStateMapKey = keyof AppShellSessionUiState; - -/** The maps that record nothing but "an action is in flight for this key". */ -type BooleanMapKey = - | 'messageRetryPendingBySession' - | 'stopPendingBySession'; - -export interface SessionPendingClaim { - /** Marks `key` in flight. Returns false — a no-op — if it already was. */ - claim(key: string): boolean; - /** Gives the claim back. Safe to call for a key that never held one. */ - release(key: string): void; -} - -export interface TranscriptReadingAnchor { - readonly turnId: string; - readonly sequence?: number; -} - -const SESSION_UI_MAP_KEYS = [ - 'messageLoadErrorBySession', - 'messageRetryPendingBySession', - 'stopPendingBySession', - 'liveTurnBySession', - 'shellRunUpdatesBySession', - 'interactionBySession', - 'messageQueueBySession', -] as const satisfies readonly AppShellSessionUiStateMapKey[]; - -type MissingSessionUiMapKey = Exclude; -const allSessionUiMapsAreListed: Record = {}; -void allSessionUiMapsAreListed; - -// An authoritative session-list refresh heals a session whose turn ended while -// its SessionEvent stream wasn't being followed, and must drop only the live -// projection. The independently-scoped maps (message load error / retry, the -// permission queue, stop-pending) each have -// their own lifecycle and must survive a mere turn settle — a full -// `clearAppShellSessionUiStateForSession` (session deletion) would wipe them too. -// Event-stream health is scoped the same way but lives outside this state; see -// `sessionEventHealthBySessionRef`. -const TURN_TRANSIENT_MAP_KEYS = [ - 'liveTurnBySession', -] as const satisfies readonly AppShellSessionUiStateMapKey[]; - -export function createInitialAppShellSessionUiState(): AppShellSessionUiState { - return Object.fromEntries(SESSION_UI_MAP_KEYS.map((key) => [key, {}])) as unknown as AppShellSessionUiState; -} - -function omitSessionKey>(current: T, sessionId: string): T { - if (!(sessionId in current)) return current; - const next = { ...current }; - delete (next as Record)[sessionId]; - return next; -} - -function updateAppShellSessionUiStateMap( - state: AppShellSessionUiState, - key: K, - updater: (current: AppShellSessionUiState[K]) => AppShellSessionUiState[K], -): AppShellSessionUiState { - const current = state[key]; - const next = updater(current); - if (next === current) return state; - return { ...state, [key]: next }; -} - -function clearSessionUiStateMap( - state: AppShellSessionUiState, - key: K, - sessionId: string, -): AppShellSessionUiState { - return updateAppShellSessionUiStateMap(state, key, (current) => omitSessionKey(current, sessionId)); -} - -export function clearAppShellSessionUiStateForSession( - state: AppShellSessionUiState, - sessionId: string, -): AppShellSessionUiState { - let nextState = state; - for (const key of SESSION_UI_MAP_KEYS) { - nextState = clearSessionUiStateMap(nextState, key, sessionId); - } - return nextState; -} - -export function clearAppShellTurnTransientForSession( - state: AppShellSessionUiState, - sessionId: string, -): AppShellSessionUiState { - let nextState = state; - for (const key of TURN_TRANSIENT_MAP_KEYS) { - nextState = clearSessionUiStateMap(nextState, key, sessionId); - } - return nextState; -} - -export function createAppShellSessionUiStateController( - initialState: AppShellSessionUiState = createInitialAppShellSessionUiState(), -) { - const state = createObservableState(initialState); - const liveTurnBySessionRef = { current: initialState.liveTurnBySession }; - // Written by the event-health probes and read back by them alone. Kept off - // the observed state so a probe never notifies a subscriber. - const sessionEventHealthBySessionRef: { current: Record } = { - current: {}, - }; - // A reading anchor is renderer-only intent. Keeping it outside observed - // state means ordinary scrolling does not render the shell, while the - // controller still owns the same deletion lifetime as every other Session - // UI registry. - const transcriptReadingAnchorBySessionRef: { - current: Record; - } = { current: {} }; - - // The ref mirrors whatever is about to become current, so it is already - // correct when the synchronous notification reaches a listener that reads it. - function replaceState(next: AppShellSessionUiState): void { - liveTurnBySessionRef.current = next.liveTurnBySession; - state.replaceState(next); - } - - function updateMap( - key: K, - updater: (current: AppShellSessionUiState[K]) => AppShellSessionUiState[K], - ): void { - const latestState = state.getState(); - const nextMap = updater(latestState[key]); - if (nextMap === latestState[key]) return; - replaceState({ ...latestState, [key]: nextMap }); - } - - function createMapSetter(key: K): StateUpdater { - return (updater) => updateMap(key, updater); - } - - /** - * The in-flight claim on one of the pending maps: `claim` marks a key and - * reports whether it won, `release` gives it back. - * - * Both read and write the same map, which is what makes this the only - * representation of "an action is in flight". Each of these maps used to be a - * `Set` ref for the duplicate guard beside a map for the rendered flag, - * synchronized by hand at every add and every `finally`, and cleared by two - * separate teardown paths that stayed aligned only by ordering. Nothing - * needed the ref: state replacement is synchronous, so a claim is visible to - * the next `getState()` in the same task. - */ - function createPendingClaim(key: BooleanMapKey): SessionPendingClaim { - return { - claim(claimKey: string): boolean { - if (state.getState()[key][claimKey] === true) return false; - updateMap(key, (current) => ({ ...current, [claimKey]: true })); - return true; - }, - release(claimKey: string): void { - updateMap(key, (current) => omitSessionKey(current, claimKey)); - }, - }; - } - - return { - getState: state.getState, - subscribe: state.subscribe, - liveTurnBySessionRef, - sessionEventHealthBySessionRef, - transcriptReadingAnchorBySessionRef, - setMessageLoadErrorBySession: createMapSetter('messageLoadErrorBySession'), - messageRetryPending: createPendingClaim('messageRetryPendingBySession'), - stopPending: createPendingClaim('stopPendingBySession'), - setLiveTurnBySession: createMapSetter('liveTurnBySession'), - setShellRunUpdatesBySession: createMapSetter('shellRunUpdatesBySession'), - setInteractionBySession: createMapSetter('interactionBySession'), - setMessageQueueBySession: createMapSetter('messageQueueBySession'), - setSessionEventHealthBySession: ((updater) => { - sessionEventHealthBySessionRef.current = updater(sessionEventHealthBySessionRef.current); - }) satisfies StateUpdater>, - setTranscriptReadingAnchor: ( - sessionId: string, - anchor: TranscriptReadingAnchor | undefined, - ) => { - const current = transcriptReadingAnchorBySessionRef.current; - if (!anchor) { - transcriptReadingAnchorBySessionRef.current = omitSessionKey(current, sessionId); - return; - } - const previous = current[sessionId]; - // A temporarily unavailable bounded range cannot make a known sequence - // less true. The Turn identity is the semantic anchor; sequence is - // monotonic metadata for reopening it later. - const next = - previous?.turnId === anchor.turnId && - previous.sequence !== undefined && - anchor.sequence === undefined - ? previous - : anchor; - if (previous === next) return; - transcriptReadingAnchorBySessionRef.current = { ...current, [sessionId]: next }; - }, - /** - * The authority said something about `turnId` — it started, failed to - * start, or ended. Drop that arm's `unconfirmed` claim so a session list - * may settle it again. An answer about a turn this session is not on says - * nothing, and leaves the state untouched. - */ - confirmLiveTurn: (sessionId: string, turnId: string) => { - updateMap('liveTurnBySession', (current) => { - const armed = current[sessionId]; - if (!armed) return current; - const confirmed = confirmLiveTurn(armed, turnId); - return confirmed === armed ? current : { ...current, [sessionId]: confirmed! }; - }); - }, - clearSessionUiState: (sessionId: string) => { - sessionEventHealthBySessionRef.current = omitSessionKey( - sessionEventHealthBySessionRef.current, - sessionId, - ); - transcriptReadingAnchorBySessionRef.current = omitSessionKey( - transcriptReadingAnchorBySessionRef.current, - sessionId, - ); - replaceState(clearAppShellSessionUiStateForSession(state.getState(), sessionId)); - }, - clearTurnTransientStateIfCurrent: ( - sessionId: string, - expected: LiveTurnProjection | undefined, - ) => { - const current = state.getState(); - if (current.liveTurnBySession[sessionId] !== expected) return; - replaceState(clearAppShellTurnTransientForSession(current, sessionId)); - }, - }; -} - -export type AppShellSessionUiStateController = ReturnType; - -/** - * Owns the controller for the component's lifetime. Deliberately does NOT - * subscribe: readers select what they need through - * `useExternalStoreSelector`, so no single component re-renders for every - * write to the store (#1985). - * - * Returns the controller itself rather than a bag of its members. The bag had - * to name every setter, so did the workspace hook above it, and so did - * AppShell's destructure — three places to edit for one new map, and three - * chances for them to disagree about what the store offers. - */ -export function useAppShellSessionUiState(): AppShellSessionUiStateController { - const controllerRef = useRef(null); - controllerRef.current ??= createAppShellSessionUiStateController(); - return controllerRef.current; -} +export * from './features/conversation/index.js'; diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index b7669e7693..f4292e7b3c 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -80,7 +80,8 @@ import { deriveTaskReadinessNotice, isTaskSubmissionHardBlocked, resolveTaskReadinessModelTarget, -} from './task-readiness-notice'; + transcriptReadingPosition, +} from './features/conversation'; import { deriveWorkspaceReadinessRecovery } from './workspace-readiness-recovery'; import { LiveTurnReconciler } from './live-turn-reconciler'; import { useAppShellSessionUiReads } from './use-app-shell-session-ui-reads'; @@ -101,11 +102,7 @@ import { type SessionNavigationPorts, type SessionNavigationRowActions, } from './features/session-navigation'; -import { - TaskEntryHost, - useTaskEntryController, - type TaskEntryError, -} from './features/task-entry'; +import { TaskEntryHost, useTaskEntryController } from './features/task-entry'; import { useNewTaskChoice } from './use-new-task-choice'; import { SessionCollaborationDialog } from './session-collaboration-dialog'; import { SessionTurnRequestComposer } from './session-turn-request-composer.js'; @@ -393,8 +390,10 @@ function AppShellContent({ } = useSettingsModal(); const onboarding = useOnboardingSnapshot(initialOnboardingSnapshot); - const reportTaskEntryError = useCallback( - ({ title, description, profileId }: TaskEntryError) => { + const reportTaskEntryError = useCallback< + Parameters[0]['reportError'] + >( + ({ title, description, profileId }) => { toastApi.error(title, description, undefined, { profileId }); }, [toastApi], @@ -2381,99 +2380,40 @@ function AppShellContent({ setSessionEventHealthBySession: sessionUiController.setSessionEventHealthBySession, toastApi, }); - let newestDurablePromptSequence: number | null = null; - try { - const controller = transcriptRangeRef.current; - if (controller && controller.store.range().sessionId === activeId) { - newestDurablePromptSequence = controller.store.newestDurableUserSequence(); - } - } catch { - newestDurablePromptSequence = null; - } - useEffect(() => { - const sessionId = activeId; - if (!sessionId) { - setTranscriptTurnIndex(undefined); - return; - } - let disposed = false; - if ( - transcriptTurnIndex?.sessionId === sessionId && - (newestDurablePromptSequence === null || - (transcriptTurnIndex.throughSequence !== null && - newestDurablePromptSequence <= transcriptTurnIndex.throughSequence)) - ) return; - void window.maka.sessions.listTurnLandmarks(sessionId).then( - (snapshot) => { - if (disposed || activeIdRef.current !== sessionId) return; - setTranscriptTurnIndex({ - sessionId, - throughSequence: snapshot.throughSequence, - turns: snapshot.landmarks, - }); - }, - () => undefined, - ); - return () => { - disposed = true; - }; - }, [activeId, activeIdRef, newestDurablePromptSequence, transcriptTurnIndex]); - useEffect(() => { - if (!activeId) return; - const searchTarget = searchScrollTarget?.sessionId === activeId - ? searchScrollTarget - : undefined; - const readingAnchor = sessionUiController.transcriptReadingAnchorBySessionRef.current[activeId]; - const target = searchTarget - ? searchTarget.sequence === undefined - ? undefined - : { sessionId: activeId, turnId: searchTarget.turnId, sequence: searchTarget.sequence } - : readingAnchor?.sequence !== undefined - ? { sessionId: activeId, turnId: readingAnchor.turnId, sequence: readingAnchor.sequence } - : undefined; - if (!target) return; - const controller = transcriptRangeRef.current; - if (!controller) return; - let disposed = false; - void controller.ready() - .then(async () => { - if ( - disposed || - transcriptRangeRef.current !== controller || - activeIdRef.current !== target.sessionId || - controller.store.sequenceForTurn(target.turnId) !== null - ) return false; - await controller.loadAround(target.sequence); - return true; - }) - .then((loaded) => { - if ( - !loaded || - disposed || - transcriptRangeRef.current !== controller || - activeIdRef.current !== target.sessionId - ) return; - setMessages([...controller.store.snapshot().messages]); - }) - .catch((error) => { - if ( - disposed || - transcriptRangeRef.current !== controller || - activeIdRef.current !== target.sessionId - ) return; - sessionUiController.setMessageLoadErrorBySession((current) => ({ - ...current, - [target.sessionId]: localizedShellErrorMessage( - error, - desktopConversationCopy.actions.operationFailedFallback, - uiLocale, - ), - })); - }); - return () => { - disposed = true; - }; - }, [activeId, activeSession?.profileId, searchScrollTarget?.nonce]); + const newestDurablePromptSequence = transcriptReadingPosition.newestDurablePromptSequence( + transcriptRangeRef.current, + activeId, + ); + useEffect(() => transcriptReadingPosition.refreshLandmarks({ + sessionId: activeId, + newestDurablePromptSequence, + current: transcriptTurnIndex, + list: (sessionId) => window.maka.sessions.listTurnLandmarks(sessionId), + isCurrent: (sessionId) => activeIdRef.current === sessionId, + setIndex: setTranscriptTurnIndex, + }), [activeId, activeIdRef, newestDurablePromptSequence, transcriptTurnIndex]); + useEffect(() => transcriptReadingPosition.restoreRange({ + sessionId: activeId, + searchTarget: searchScrollTarget, + readingAnchor: activeId + ? sessionUiController.transcriptReadingAnchorBySessionRef.current[activeId] + : undefined, + controller: transcriptRangeRef.current, + isCurrent: (sessionId, controller) => + activeIdRef.current === sessionId && transcriptRangeRef.current === controller, + setMessages, + setReadingAnchor: sessionUiController.setTranscriptReadingAnchor, + onError: (error, sessionId) => { + sessionUiController.setMessageLoadErrorBySession((current) => ({ + ...current, + [sessionId]: localizedShellErrorMessage( + error, + desktopConversationCopy.actions.operationFailedFallback, + uiLocale, + ), + })); + }, + }), [activeId, activeSession?.profileId, messages, searchScrollTarget?.nonce]); useShellRunUpdates({ activeId, setShellRunUpdatesBySession: sessionUiController.setShellRunUpdatesBySession, @@ -2630,34 +2570,18 @@ function AppShellContent({ const activeTranscriptReadingAnchor = activeId ? sessionUiController.transcriptReadingAnchorBySessionRef.current[activeId] : undefined; - let activeTranscriptRange; - try { - const controller = transcriptRangeRef.current; - const range = controller?.store.range(); - if (range?.sessionId === activeId) activeTranscriptRange = range; - } catch { - activeTranscriptRange = undefined; - } + const activeTranscriptRange = transcriptReadingPosition.currentRange( + transcriptRangeRef.current, + activeId, + ); function handleTranscriptReadingAnchorChange(turnId?: string) { - const sessionId = activeId; - if (!sessionId || activeIdRef.current !== sessionId) return; - if (!turnId) { - sessionUiController.setTranscriptReadingAnchor(sessionId, undefined); - return; - } - const controller = transcriptRangeRef.current; - if (!controller) return; - let sequence: number | undefined; - try { - if (controller.store.range().sessionId !== sessionId) return; - sequence = controller.store.sequenceForTurn(turnId) ?? undefined; - } catch { - return; - } - sessionUiController.setTranscriptReadingAnchor( - sessionId, - sequence === undefined ? { turnId } : { turnId, sequence }, - ); + transcriptReadingPosition.captureAnchor({ + sessionId: activeId, + currentSessionId: activeIdRef.current, + turnId, + controller: transcriptRangeRef.current, + setAnchor: sessionUiController.setTranscriptReadingAnchor, + }); } async function loadTranscriptHistory(target: 'earlier' | 'latest', anchorTurnId?: string) { const controller = transcriptRangeRef.current; diff --git a/apps/desktop/src/renderer/features/conversation/README.md b/apps/desktop/src/renderer/features/conversation/README.md new file mode 100644 index 0000000000..334b948410 --- /dev/null +++ b/apps/desktop/src/renderer/features/conversation/README.md @@ -0,0 +1,30 @@ + + +# Conversation feature + +Conversation owns runtime-only Session presentation state and the policies +that connect transcript identity to the Desktop bounded-range controller. Its +public API includes task-readiness presentation and the semantic reading +position operations used by AppShell. + +The feature does not access the Desktop bridge. AppShell supplies bounded-range +and landmark ports, and remains responsible for rejecting stale Session and +controller instances. Session Navigation supplies explicit navigation intent +only; it does not own transcript state. 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 new file mode 100644 index 0000000000..b0a6208236 --- /dev/null +++ b/apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position.ts @@ -0,0 +1,173 @@ +/* + * 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 type { TranscriptReadingAnchor } from '../model/session-ui-state.js'; + +interface TranscriptRangeStore { + range(): { readonly sessionId: string }; + sequenceForTurn(turnId: string): number | null; + newestDurableUserSequence(): number | null; + snapshot(): { readonly messages: readonly Message[] }; +} + +interface TranscriptRangeController { + readonly store: TranscriptRangeStore; + ready(): Promise; + loadAround(sequence: number): Promise; +} + +interface SearchTarget { + readonly sessionId: string; + readonly turnId: string; + readonly sequence?: number; +} + +export function currentTranscriptRange( + controller: { readonly store: { range(): Range } } | undefined, + sessionId: string | undefined, +): Range | undefined { + try { + const range = controller?.store.range(); + return range?.sessionId === sessionId ? range : undefined; + } catch { + return undefined; + } +} + +export function newestDurablePromptSequence( + controller: TranscriptRangeController | undefined, + sessionId: string | undefined, +): number | null { + try { + return controller && controller.store.range().sessionId === sessionId + ? controller.store.newestDurableUserSequence() + : null; + } catch { + return null; + } +} + +export function refreshTranscriptTurnLandmarks(options: { + readonly sessionId?: string; + readonly newestDurablePromptSequence: number | null; + readonly current?: { readonly sessionId: string; readonly throughSequence: number | null }; + readonly list: (sessionId: string) => Promise<{ readonly throughSequence: number | null; readonly landmarks: readonly T[] }>; + readonly isCurrent: (sessionId: string) => boolean; + readonly setIndex: (index: { sessionId: string; throughSequence: number | null; turns: readonly T[] } | undefined) => void; +}): (() => void) | undefined { + const { sessionId } = options; + if (!sessionId) { + options.setIndex(undefined); + return; + } + if ( + options.current?.sessionId === sessionId && + (options.newestDurablePromptSequence === null || + (options.current.throughSequence !== null && + options.newestDurablePromptSequence <= options.current.throughSequence)) + ) return; + let disposed = false; + void options.list(sessionId).then( + (snapshot) => { + if (disposed || !options.isCurrent(sessionId)) return; + options.setIndex({ + sessionId, + throughSequence: snapshot.throughSequence, + turns: snapshot.landmarks, + }); + }, + () => undefined, + ); + return () => { + disposed = true; + }; +} + +export function restoreSessionTranscriptRange(options: { + readonly sessionId?: string; + readonly searchTarget?: SearchTarget | null; + readonly readingAnchor?: TranscriptReadingAnchor; + readonly controller?: TranscriptRangeController; + readonly isCurrent: (sessionId: string, controller: TranscriptRangeController) => boolean; + readonly setMessages: (messages: Message[]) => void; + readonly setReadingAnchor: ( + sessionId: string, + anchor: TranscriptReadingAnchor | undefined, + ) => void; + readonly onError: (error: unknown, sessionId: string) => void; +}): (() => void) | undefined { + const { controller, sessionId } = options; + if (!controller || !sessionId) return; + let readingAnchor = options.readingAnchor; + if (readingAnchor && readingAnchor.sequence === undefined) { + const { turnId } = readingAnchor; + try { + const sequence = controller.store.sequenceForTurn(turnId); + if (sequence !== null) { + readingAnchor = { turnId, sequence }; + options.setReadingAnchor(sessionId, readingAnchor); + } + } catch { + // A stale range cannot enrich the anchor, but also cannot invalidate it. + } + } + const target = options.searchTarget?.sessionId === sessionId + ? options.searchTarget + : readingAnchor; + if (!target || target.sequence === undefined) return; + let disposed = false; + const current = (): boolean => !disposed && options.isCurrent(sessionId, controller); + void controller.ready() + .then(async () => { + if (!current() || controller.store.sequenceForTurn(target.turnId) !== null) return false; + await controller.loadAround(target.sequence!); + return true; + }) + .then((loaded) => { + if (loaded && current()) options.setMessages([...controller.store.snapshot().messages]); + }) + .catch((error) => { + if (current()) options.onError(error, sessionId); + }); + return () => { + disposed = true; + }; +} + +export function captureTranscriptReadingAnchor(options: { + readonly sessionId?: string; + readonly currentSessionId?: string; + readonly turnId?: string; + readonly controller?: TranscriptRangeController; + readonly setAnchor: (sessionId: string, anchor: TranscriptReadingAnchor | undefined) => void; +}): void { + const { sessionId, turnId } = options; + if (!sessionId || options.currentSessionId !== sessionId) return; + if (!turnId) { + options.setAnchor(sessionId, undefined); + return; + } + try { + if (options.controller?.store.range().sessionId !== sessionId) return; + const sequence = options.controller.store.sequenceForTurn(turnId) ?? undefined; + options.setAnchor(sessionId, sequence === undefined ? { turnId } : { turnId, sequence }); + } catch { + // A stale range says nothing new about the reader's current intent. + } +} diff --git a/apps/desktop/src/renderer/features/conversation/index.ts b/apps/desktop/src/renderer/features/conversation/index.ts new file mode 100644 index 0000000000..e8f6b42289 --- /dev/null +++ b/apps/desktop/src/renderer/features/conversation/index.ts @@ -0,0 +1,42 @@ +/* + * 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 { + captureTranscriptReadingAnchor, + currentTranscriptRange, + newestDurablePromptSequence, + refreshTranscriptTurnLandmarks, + restoreSessionTranscriptRange, +} from './controller/transcript-reading-position.js'; + +export const transcriptReadingPosition = { + captureAnchor: captureTranscriptReadingAnchor, + currentRange: currentTranscriptRange, + newestDurablePromptSequence, + refreshLandmarks: refreshTranscriptTurnLandmarks, + restoreRange: restoreSessionTranscriptRange, +}; + +export { + deriveTaskReadinessNotice, + isTaskSubmissionHardBlocked, + resolveTaskReadinessModelTarget, + type TaskReadinessNotice, +} from './model/task-readiness-notice.js'; +export * from './model/session-ui-state.js'; diff --git a/apps/desktop/src/renderer/features/conversation/model/observable-state.ts b/apps/desktop/src/renderer/features/conversation/model/observable-state.ts new file mode 100644 index 0000000000..78a8d755be --- /dev/null +++ b/apps/desktop/src/renderer/features/conversation/model/observable-state.ts @@ -0,0 +1,35 @@ +/* + * 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. + */ + +export function createObservableState(initial: S) { + let current = initial; + const listeners = new Set<() => void>(); + return { + getState: (): S => current, + subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); + }, + replaceState(next: S): void { + if (next === current) return; + current = next; + for (const listener of [...listeners]) listener(); + }, + }; +} diff --git a/apps/desktop/src/renderer/features/conversation/model/session-ui-state.ts b/apps/desktop/src/renderer/features/conversation/model/session-ui-state.ts new file mode 100644 index 0000000000..a983e468ed --- /dev/null +++ b/apps/desktop/src/renderer/features/conversation/model/session-ui-state.ts @@ -0,0 +1,302 @@ +/* + * 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 { useRef } from 'react'; +import type { MessageQueueEntryProjection, ShellRunUpdate } from '@maka/core/events'; +import type { SessionEventStreamSnapshot } from '@maka/core/session-event-health'; +import { confirmLiveTurn, type InteractionQueues, type LiveTurnProjection } from '@maka/ui'; +import { createObservableState } from './observable-state.js'; + +type StateUpdater = (updater: (current: T) => T) => void; +type ShellRunUpdatesBySession = Record>; + +export interface AppShellSessionUiState { + messageLoadErrorBySession: Record; + messageRetryPendingBySession: Record; + stopPendingBySession: Record; + liveTurnBySession: Record; + shellRunUpdatesBySession: ShellRunUpdatesBySession; + interactionBySession: InteractionQueues; + messageQueueBySession: Record; +} + +// The pending plate keeps the Host revision beside its entries so edits can +// reject stale multi-client projections instead of silently overwriting them. +export interface MessageQueueUiState { + readonly queueRevision?: number; + readonly entries: readonly MessageQueueEntryProjection[]; +} + +type AppShellSessionUiStateMapKey = keyof AppShellSessionUiState; + +/** The maps that record nothing but "an action is in flight for this key". */ +type BooleanMapKey = + | 'messageRetryPendingBySession' + | 'stopPendingBySession'; + +export interface SessionPendingClaim { + /** Marks `key` in flight. Returns false — a no-op — if it already was. */ + claim(key: string): boolean; + /** Gives the claim back. Safe to call for a key that never held one. */ + release(key: string): void; +} + +export interface TranscriptReadingAnchor { + readonly turnId: string; + readonly sequence?: number; +} + +const SESSION_UI_MAP_KEYS = [ + 'messageLoadErrorBySession', + 'messageRetryPendingBySession', + 'stopPendingBySession', + 'liveTurnBySession', + 'shellRunUpdatesBySession', + 'interactionBySession', + 'messageQueueBySession', +] as const satisfies readonly AppShellSessionUiStateMapKey[]; + +type MissingSessionUiMapKey = Exclude; +const allSessionUiMapsAreListed: Record = {}; +void allSessionUiMapsAreListed; + +// An authoritative session-list refresh heals a session whose turn ended while +// its SessionEvent stream wasn't being followed, and must drop only the live +// projection. The independently-scoped maps (message load error / retry, the +// permission queue, stop-pending) each have +// their own lifecycle and must survive a mere turn settle — a full +// `clearAppShellSessionUiStateForSession` (session deletion) would wipe them too. +// Event-stream health is scoped the same way but lives outside this state; see +// `sessionEventHealthBySessionRef`. +const TURN_TRANSIENT_MAP_KEYS = [ + 'liveTurnBySession', +] as const satisfies readonly AppShellSessionUiStateMapKey[]; + +export function createInitialAppShellSessionUiState(): AppShellSessionUiState { + return Object.fromEntries(SESSION_UI_MAP_KEYS.map((key) => [key, {}])) as unknown as AppShellSessionUiState; +} + +function omitSessionKey>(current: T, sessionId: string): T { + if (!(sessionId in current)) return current; + const next = { ...current }; + delete (next as Record)[sessionId]; + return next; +} + +function updateAppShellSessionUiStateMap( + state: AppShellSessionUiState, + key: K, + updater: (current: AppShellSessionUiState[K]) => AppShellSessionUiState[K], +): AppShellSessionUiState { + const current = state[key]; + const next = updater(current); + if (next === current) return state; + return { ...state, [key]: next }; +} + +function clearSessionUiStateMap( + state: AppShellSessionUiState, + key: K, + sessionId: string, +): AppShellSessionUiState { + return updateAppShellSessionUiStateMap(state, key, (current) => omitSessionKey(current, sessionId)); +} + +export function clearAppShellSessionUiStateForSession( + state: AppShellSessionUiState, + sessionId: string, +): AppShellSessionUiState { + let nextState = state; + for (const key of SESSION_UI_MAP_KEYS) { + nextState = clearSessionUiStateMap(nextState, key, sessionId); + } + return nextState; +} + +export function clearAppShellTurnTransientForSession( + state: AppShellSessionUiState, + sessionId: string, +): AppShellSessionUiState { + let nextState = state; + for (const key of TURN_TRANSIENT_MAP_KEYS) { + nextState = clearSessionUiStateMap(nextState, key, sessionId); + } + return nextState; +} + +export function createAppShellSessionUiStateController( + initialState: AppShellSessionUiState = createInitialAppShellSessionUiState(), +) { + const state = createObservableState(initialState); + const liveTurnBySessionRef = { current: initialState.liveTurnBySession }; + // Written by the event-health probes and read back by them alone. Kept off + // the observed state so a probe never notifies a subscriber. + const sessionEventHealthBySession = createRuntimeSessionRegistry(); + // A reading anchor is renderer-only intent. Keeping it outside observed + // state means ordinary scrolling does not render the shell, while the + // controller still owns the same deletion lifetime as every other Session + // UI registry. + const transcriptReadingAnchors = createTranscriptReadingAnchorRegistry(); + + // The ref mirrors whatever is about to become current, so it is already + // correct when the synchronous notification reaches a listener that reads it. + function replaceState(next: AppShellSessionUiState): void { + liveTurnBySessionRef.current = next.liveTurnBySession; + state.replaceState(next); + } + + function updateMap( + key: K, + updater: (current: AppShellSessionUiState[K]) => AppShellSessionUiState[K], + ): void { + const latestState = state.getState(); + const nextMap = updater(latestState[key]); + if (nextMap === latestState[key]) return; + replaceState({ ...latestState, [key]: nextMap }); + } + + function createMapSetter(key: K): StateUpdater { + return (updater) => updateMap(key, updater); + } + + /** + * The in-flight claim on one of the pending maps: `claim` marks a key and + * reports whether it won, `release` gives it back. + * + * Both read and write the same map, which is what makes this the only + * representation of "an action is in flight". Each of these maps used to be a + * `Set` ref for the duplicate guard beside a map for the rendered flag, + * synchronized by hand at every add and every `finally`, and cleared by two + * separate teardown paths that stayed aligned only by ordering. Nothing + * needed the ref: state replacement is synchronous, so a claim is visible to + * the next `getState()` in the same task. + */ + function createPendingClaim(key: BooleanMapKey): SessionPendingClaim { + return { + claim(claimKey: string): boolean { + if (state.getState()[key][claimKey] === true) return false; + updateMap(key, (current) => ({ ...current, [claimKey]: true })); + return true; + }, + release(claimKey: string): void { + updateMap(key, (current) => omitSessionKey(current, claimKey)); + }, + }; + } + + return { + getState: state.getState, + subscribe: state.subscribe, + liveTurnBySessionRef, + sessionEventHealthBySessionRef: sessionEventHealthBySession.ref, + transcriptReadingAnchorBySessionRef: transcriptReadingAnchors.ref, + setMessageLoadErrorBySession: createMapSetter('messageLoadErrorBySession'), + messageRetryPending: createPendingClaim('messageRetryPendingBySession'), + stopPending: createPendingClaim('stopPendingBySession'), + setLiveTurnBySession: createMapSetter('liveTurnBySession'), + setShellRunUpdatesBySession: createMapSetter('shellRunUpdatesBySession'), + setInteractionBySession: createMapSetter('interactionBySession'), + setMessageQueueBySession: createMapSetter('messageQueueBySession'), + setSessionEventHealthBySession: sessionEventHealthBySession.update, + setTranscriptReadingAnchor: transcriptReadingAnchors.set, + /** + * The authority said something about `turnId` — it started, failed to + * start, or ended. Drop that arm's `unconfirmed` claim so a session list + * may settle it again. An answer about a turn this session is not on says + * nothing, and leaves the state untouched. + */ + confirmLiveTurn: (sessionId: string, turnId: string) => { + updateMap('liveTurnBySession', (current) => { + const armed = current[sessionId]; + if (!armed) return current; + const confirmed = confirmLiveTurn(armed, turnId); + return confirmed === armed ? current : { ...current, [sessionId]: confirmed! }; + }); + }, + clearSessionUiState: (sessionId: string) => { + sessionEventHealthBySession.clear(sessionId); + transcriptReadingAnchors.set(sessionId, undefined); + replaceState(clearAppShellSessionUiStateForSession(state.getState(), sessionId)); + }, + clearTurnTransientStateIfCurrent: ( + sessionId: string, + expected: LiveTurnProjection | undefined, + ) => { + const current = state.getState(); + if (current.liveTurnBySession[sessionId] !== expected) return; + replaceState(clearAppShellTurnTransientForSession(current, sessionId)); + }, + }; +} + +export type AppShellSessionUiStateController = ReturnType; + +/** + * Owns the controller for the component's lifetime. Deliberately does NOT + * subscribe: readers select what they need through + * `useExternalStoreSelector`, so no single component re-renders for every + * write to the store (#1985). + * + * Returns the controller itself rather than a bag of its members. The bag had + * to name every setter, so did the workspace hook above it, and so did + * AppShell's destructure — three places to edit for one new map, and three + * chances for them to disagree about what the store offers. + */ +export function useAppShellSessionUiState(): AppShellSessionUiStateController { + const controllerRef = useRef(null); + controllerRef.current ??= createAppShellSessionUiStateController(); + return controllerRef.current; +} + +function createRuntimeSessionRegistry() { + const ref: { current: Record } = { current: {} }; + return { + ref, + update(updater: (current: Record) => Record): void { + ref.current = updater(ref.current); + }, + clear(sessionId: string): void { + if (!(sessionId in ref.current)) return; + const next = { ...ref.current }; + delete next[sessionId]; + ref.current = next; + }, + }; +} + +function createTranscriptReadingAnchorRegistry() { + const registry = createRuntimeSessionRegistry(); + const { ref } = registry; + return { + ref, + set(sessionId: string, anchor: TranscriptReadingAnchor | undefined): void { + const previous = ref.current[sessionId]; + if (!anchor) { + registry.clear(sessionId); + return; + } + const next = previous?.turnId === anchor.turnId && + previous.sequence !== undefined && anchor.sequence === undefined + ? previous + : anchor; + if (next === previous) return; + ref.current = { ...ref.current, [sessionId]: next }; + }, + }; +} diff --git a/apps/desktop/src/renderer/features/conversation/model/task-readiness-notice.ts b/apps/desktop/src/renderer/features/conversation/model/task-readiness-notice.ts new file mode 100644 index 0000000000..6ebe6b11f9 --- /dev/null +++ b/apps/desktop/src/renderer/features/conversation/model/task-readiness-notice.ts @@ -0,0 +1,121 @@ +/* + * 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 type { SessionSendProjection } from '@maka/core/session-send-projection'; + +import type { TaskSubmissionReadinessDimension, TaskSubmissionReadinessSnapshot } from '@maka/core/task-submission-readiness'; + +import type { UiLocale } from '@maka/core/ui-locale'; + +/** Selects the stored model target for the renderer's readiness probe. */ +export function resolveTaskReadinessModelTarget( + session: { llmConnectionSlug: string; model: string } | undefined, + _sendOutcome: SessionSendProjection | undefined, + newTaskTarget: { llmConnectionSlug: string; model: string } | undefined, +): { connectionSlug?: string; model?: string } { + return optionalModelTarget( + session?.llmConnectionSlug ?? newTaskTarget?.llmConnectionSlug, + session?.model ?? newTaskTarget?.model, + ); +} + +function optionalModelTarget( + connectionSlug: string | undefined, + model: string | undefined, +): { connectionSlug?: string; model?: string } { + return { + ...(connectionSlug?.trim() ? { connectionSlug: connectionSlug.trim() } : {}), + ...(model?.trim() ? { model: model.trim() } : {}), + }; +} + +export interface TaskReadinessNotice { + tone: 'warning' | 'destructive'; + title: string; + description: string; + actionLabel: string; + action: 'retry' | 'workspace_picker'; +} + +export function isTaskSubmissionHardBlocked( + snapshot: TaskSubmissionReadinessSnapshot | undefined, + options: { ignoreModelTarget?: boolean } = {}, +): boolean { + return ( + snapshot?.blockers.some( + (blocker) => + blocker.state !== 'unknown' && + !(options.ignoreModelTarget === true && blocker.id === 'model_target'), + ) === true + ); +} + +/** Model blockers already have connection-specific recovery surfaces. */ +export function deriveTaskReadinessNotice( + snapshot: TaskSubmissionReadinessSnapshot | undefined, + locale: UiLocale, +): TaskReadinessNotice | undefined { + if (!snapshot) return undefined; + const blocker = snapshot.blockers.find( + (candidate) => + candidate.state !== 'unknown' && + (candidate.id === 'runtime' || candidate.id === 'workspace'), + ); + if (!blocker) return undefined; + return noticeForBlocker(blocker, locale); +} + +function noticeForBlocker( + blocker: TaskSubmissionReadinessDimension, + locale: UiLocale, +): TaskReadinessNotice { + if (blocker.id === 'runtime') { + return locale === 'zh' + ? { + tone: 'destructive', + title: 'Maka 运行服务暂时不可用。', + description: '任务尚未提交。重新检测运行服务后再试。', + actionLabel: '重新检测', + action: 'retry', + } + : { + tone: 'destructive', + title: 'The Maka runtime is unavailable.', + description: 'The task was not submitted. Check the runtime again before retrying.', + actionLabel: 'Check again', + action: 'retry', + }; + } + const action = blocker.repairTarget?.kind === 'workspace_picker' ? 'workspace_picker' : 'retry'; + return locale === 'zh' + ? { + tone: 'destructive', + title: '当前任务的工作区不可用。', + description: '原目录可能已移动、删除或无法访问。请选择可用工作区。', + actionLabel: action === 'workspace_picker' ? '选择工作区' : '重新检测', + action, + } + : { + tone: 'destructive', + title: 'This task workspace is unavailable.', + description: 'The folder may have moved, been deleted, or become inaccessible. Choose an available workspace.', + actionLabel: action === 'workspace_picker' ? 'Choose workspace' : 'Check again', + action, + }; +} diff --git a/apps/desktop/src/renderer/features/task-entry/index.ts b/apps/desktop/src/renderer/features/task-entry/index.ts index 0b75d7d89a..b26b903df0 100644 --- a/apps/desktop/src/renderer/features/task-entry/index.ts +++ b/apps/desktop/src/renderer/features/task-entry/index.ts @@ -20,7 +20,4 @@ export { TaskEntryHost } from './ui/task-entry-host.js'; export { TaskEntryServicesProvider } from './services-context.js'; export { useTaskEntryController } from './controller/use-task-entry-controller.js'; -export type { - TaskEntryError, -} from './controller/use-task-entry-controller.js'; export type { TaskEntryServices } from './ports.js'; diff --git a/apps/desktop/src/renderer/task-readiness-notice.ts b/apps/desktop/src/renderer/task-readiness-notice.ts index 3d09e170c9..201410fdd3 100644 --- a/apps/desktop/src/renderer/task-readiness-notice.ts +++ b/apps/desktop/src/renderer/task-readiness-notice.ts @@ -17,107 +17,9 @@ * under the License. */ -import type { SessionSendProjection } from '@maka/core/session-send-projection'; - -import type { TaskSubmissionReadinessDimension, TaskSubmissionReadinessSnapshot } from '@maka/core/task-submission-readiness'; - -import type { UiLocale } from '@maka/core/ui-locale'; - -/** - * Selects the stored model target for the renderer's readiness probe. - */ -export function resolveTaskReadinessModelTarget( - session: { llmConnectionSlug: string; model: string } | undefined, - _sendOutcome: SessionSendProjection | undefined, - newTaskTarget: { llmConnectionSlug: string; model: string } | undefined, -): { connectionSlug?: string; model?: string } { - return optionalModelTarget( - session?.llmConnectionSlug ?? newTaskTarget?.llmConnectionSlug, - session?.model ?? newTaskTarget?.model, - ); -} - -function optionalModelTarget( - connectionSlug: string | undefined, - model: string | undefined, -): { connectionSlug?: string; model?: string } { - return { - ...(connectionSlug?.trim() ? { connectionSlug: connectionSlug.trim() } : {}), - ...(model?.trim() ? { model: model.trim() } : {}), - }; -} - -export interface TaskReadinessNotice { - tone: 'warning' | 'destructive'; - title: string; - description: string; - actionLabel: string; - action: 'retry' | 'workspace_picker'; -} - -export function isTaskSubmissionHardBlocked( - snapshot: TaskSubmissionReadinessSnapshot | undefined, - options: { ignoreModelTarget?: boolean } = {}, -): boolean { - return ( - snapshot?.blockers.some( - (blocker) => - blocker.state !== 'unknown' && - !(options.ignoreModelTarget === true && blocker.id === 'model_target'), - ) === true - ); -} - -/** Model blockers already have connection-specific recovery surfaces. */ -export function deriveTaskReadinessNotice( - snapshot: TaskSubmissionReadinessSnapshot | undefined, - locale: UiLocale, -): TaskReadinessNotice | undefined { - if (!snapshot) return undefined; - const blocker = snapshot.blockers.find( - (candidate) => - candidate.state !== 'unknown' && - (candidate.id === 'runtime' || candidate.id === 'workspace'), - ); - if (!blocker) return undefined; - return noticeForBlocker(blocker, locale); -} - -function noticeForBlocker( - blocker: TaskSubmissionReadinessDimension, - locale: UiLocale, -): TaskReadinessNotice { - if (blocker.id === 'runtime') { - return locale === 'zh' - ? { - tone: 'destructive', - title: 'Maka 运行服务暂时不可用。', - description: '任务尚未提交。重新检测运行服务后再试。', - actionLabel: '重新检测', - action: 'retry', - } - : { - tone: 'destructive', - title: 'The Maka runtime is unavailable.', - description: 'The task was not submitted. Check the runtime again before retrying.', - actionLabel: 'Check again', - action: 'retry', - }; - } - const action = blocker.repairTarget?.kind === 'workspace_picker' ? 'workspace_picker' : 'retry'; - return locale === 'zh' - ? { - tone: 'destructive', - title: '当前任务的工作区不可用。', - description: '原目录可能已移动、删除或无法访问。请选择可用工作区。', - actionLabel: action === 'workspace_picker' ? '选择工作区' : '重新检测', - action, - } - : { - tone: 'destructive', - title: 'This task workspace is unavailable.', - description: 'The folder may have moved, been deleted, or become inaccessible. Choose an available workspace.', - actionLabel: action === 'workspace_picker' ? 'Choose workspace' : 'Check again', - action, - }; -} +export { + deriveTaskReadinessNotice, + isTaskSubmissionHardBlocked, + resolveTaskReadinessModelTarget, + type TaskReadinessNotice, +} from './features/conversation/index.js'; From 13891e13d6e54c8fe039734b1165752d8cdeb506 Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Wed, 2 Sep 2026 16:34:59 +0800 Subject: [PATCH 3/3] fix(ui): bound missing transcript restore targets Generated-by: Codex --- apps/desktop/e2e/transcript-scroll.spec.ts | 2 +- apps/desktop/renderer-architecture.json | 11 +- .../app-shell-session-ui-state.test.ts | 129 ++++++++++++++++++ apps/desktop/src/renderer/app-shell.tsx | 21 ++- .../controller/transcript-reading-position.ts | 47 +++++-- .../conversation/model/session-ui-state.ts | 8 ++ .../use-app-shell-session-ui-reads.ts | 31 +++-- .../ui/src/__tests__/use-chat-scroll.test.tsx | 47 ++++++- packages/ui/src/chat-view.tsx | 2 +- packages/ui/src/use-chat-scroll.ts | 38 +++++- 10 files changed, 297 insertions(+), 39 deletions(-) diff --git a/apps/desktop/e2e/transcript-scroll.spec.ts b/apps/desktop/e2e/transcript-scroll.spec.ts index 14d9300606..8e599c683d 100644 --- a/apps/desktop/e2e/transcript-scroll.spec.ts +++ b/apps/desktop/e2e/transcript-scroll.spec.ts @@ -299,7 +299,7 @@ test('switching Sessions restores a Turn anchor while a tail Session follows bac // model upgrades it onto the E2E Runtime Host before this test starts a Turn. const modelSwitcher = page.locator('.maka-model-switcher-trigger'); await modelSwitcher.click(); - await page.getByRole('menuitem', { name: 'glm-4.5', exact: true }).click(); + await page.getByRole('menuitemradio', { name: 'glm-4.5', exact: true }).click(); await expect(modelSwitcher).toContainText('glm-4.5'); await page.evaluate((sessionId) => { diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 254499816e..220f8f97db 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -1014,7 +1014,7 @@ "react": 1 }, "importSpecifiers": 186, - "nonTriviaTokens": 15653 + "nonTriviaTokens": 15725 }, "src/renderer/use-app-shell-composer-quotes.ts": { "importDeclarations": 3, @@ -1065,7 +1065,7 @@ "nonTriviaTokens": 581 }, "src/renderer/use-app-shell-session-ui-reads.ts": { - "importDeclarations": 4, + "importDeclarations": 3, "bridgePaths": {}, "environmentCapabilities": {}, "hookCalls": { @@ -1077,11 +1077,10 @@ "dependencyPaths": { "./app-shell-session-ui-state.js": 1, "./live-turn-snapshot.js": 1, - "./use-external-store-selector.js": 1, - "@maka/ui": 1 + "./use-external-store-selector.js": 1 }, - "importSpecifiers": 10, - "nonTriviaTokens": 329 + "importSpecifiers": 7, + "nonTriviaTokens": 322 }, "src/renderer/use-app-shell-session-workspace.ts": { "importDeclarations": 11, diff --git a/apps/desktop/src/main/__tests__/app-shell-session-ui-state.test.ts b/apps/desktop/src/main/__tests__/app-shell-session-ui-state.test.ts index d13cbd22f7..4e2dde6c2e 100644 --- a/apps/desktop/src/main/__tests__/app-shell-session-ui-state.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-session-ui-state.test.ts @@ -71,6 +71,7 @@ function seededState(): AppShellSessionUiState { drop: [boundaryRequest('drop')], keep: [boundaryRequest('keep')], }, + transcriptRestoreUnavailableBySession: { drop: 'turn-drop', keep: 'turn-keep' }, }; } @@ -188,6 +189,7 @@ describe('app shell session UI state controller', () => { assert.deepEqual(Object.keys(next.stopPendingBySession), ['keep']); assert.deepEqual(Object.keys(next.liveTurnBySession), ['keep']); assert.deepEqual(Object.keys(next.interactionBySession), ['keep']); + assert.deepEqual(Object.keys(next.transcriptRestoreUnavailableBySession), ['keep']); }); it('keeps state identity for no-op map updates and only replaces the selected map', () => { @@ -259,6 +261,26 @@ describe('app shell session UI state controller', () => { assert.equal(notifications, 0); }); + it('publishes unavailable transcript restores only until they are consumed', () => { + let notifications = 0; + const controller = createAppShellSessionUiStateController(); + controller.subscribe(() => { + notifications += 1; + }); + + controller.setTranscriptRestoreUnavailable('session', 'turn-missing'); + + assert.deepEqual(controller.getState().transcriptRestoreUnavailableBySession, { + session: 'turn-missing', + }); + assert.equal(notifications, 1); + + controller.setTranscriptRestoreUnavailable('session', undefined); + + assert.deepEqual(controller.getState().transcriptRestoreUnavailableBySession, {}); + assert.equal(notifications, 2); + }); + it('enriches a Turn-only reading anchor when its range sequence arrives later', () => { let anchor: { turnId: string; sequence?: number } | undefined; transcriptReadingPosition.restoreRange({ @@ -285,6 +307,113 @@ describe('app shell session UI state controller', () => { assert.deepEqual(anchor, { turnId: 'turn', sequence: 17 }); }); + it('does not enrich a reading anchor from another Session range', () => { + let sequenceReads = 0; + let anchor: { turnId: string; sequence?: number } | undefined; + transcriptReadingPosition.restoreRange({ + sessionId: 'active', + readingAnchor: { turnId: 'turn' }, + controller: { + store: { + range: () => ({ sessionId: 'stale' }), + sequenceForTurn: () => { + sequenceReads += 1; + return 17; + }, + newestDurableUserSequence: () => 17, + snapshot: () => ({ messages: [] }), + }, + ready: async () => undefined, + loadAround: async () => assert.fail('a stale range must not load'), + }, + isCurrent: () => true, + setMessages: () => assert.fail('a stale range must not replace messages'), + setReadingAnchor: (_sessionId, next) => { + anchor = next; + }, + onError: (error) => assert.fail(String(error)), + }); + + assert.equal(sequenceReads, 0); + assert.equal(anchor, undefined); + }); + + it('abandons a Turn-only restore that remains absent after the range is ready', async () => { + const anchorWrites: Array<{ turnId: string; sequence?: number } | undefined> = []; + let unavailable: { sessionId: string; turnId: string } | undefined; + const options = { + sessionId: 'session', + readingAnchor: { turnId: 'missing' }, + controller: { + store: { + range: () => ({ sessionId: 'session' }), + sequenceForTurn: () => null, + newestDurableUserSequence: () => null, + snapshot: () => ({ messages: [] }), + }, + ready: async () => undefined, + loadAround: async () => assert.fail('a Turn-only anchor has no load target'), + }, + isCurrent: () => true, + setMessages: () => assert.fail('an unavailable target must not replace messages'), + setReadingAnchor: (_sessionId: string, next: { turnId: string; sequence?: number } | undefined) => { + anchorWrites.push(next); + }, + onRestoreUnavailable: (sessionId: string, turnId: string) => { + unavailable = { sessionId, turnId }; + }, + onError: (error: unknown) => assert.fail(String(error)), + }; + + transcriptReadingPosition.restoreRange(options); + await new Promise((resolve) => setImmediate(resolve)); + + assert.deepEqual(anchorWrites, [undefined]); + assert.deepEqual(unavailable, { sessionId: 'session', turnId: 'missing' }); + }); + + it('abandons a known-sequence restore when loadAround cannot make the Turn resident', async () => { + let loadedSequence: number | undefined; + let unavailable: { sessionId: string; turnId: string } | undefined; + let messages: Array<{ id: string }> | undefined; + const anchorWrites: Array<{ turnId: string; sequence?: number } | undefined> = []; + const options = { + sessionId: 'session', + readingAnchor: { turnId: 'removed', sequence: 23 }, + controller: { + store: { + range: () => ({ sessionId: 'session' }), + sequenceForTurn: () => null, + newestDurableUserSequence: () => 29, + snapshot: () => ({ messages: [{ id: 'latest' }] }), + }, + ready: async () => undefined, + loadAround: async (sequence: number) => { + loadedSequence = sequence; + }, + }, + isCurrent: () => true, + setMessages: (next: Array<{ id: string }>) => { + messages = next; + }, + setReadingAnchor: (_sessionId: string, next: { turnId: string; sequence?: number } | undefined) => { + anchorWrites.push(next); + }, + onRestoreUnavailable: (sessionId: string, turnId: string) => { + unavailable = { sessionId, turnId }; + }, + onError: (error: unknown) => assert.fail(String(error)), + }; + + transcriptReadingPosition.restoreRange(options); + await new Promise((resolve) => setImmediate(resolve)); + + assert.equal(loadedSequence, 23); + assert.deepEqual(messages, [{ id: 'latest' }]); + assert.deepEqual(anchorWrites, [undefined]); + assert.deepEqual(unavailable, { sessionId: 'session', turnId: 'removed' }); + }); + it('keeps the synchronous live-turn ref aligned with reducer updates', () => { const controller = createAppShellSessionUiStateController(); const projection = armLiveTurn('turn-1'); diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index f4292e7b3c..84a2984fa1 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -554,6 +554,7 @@ function AppShellContent({ stopPendingBySession, interactionBySession, messageQueueBySession, + transcriptRestoreUnavailableBySession, streamingSessionIds, activeLiveTurnSnapshot, } = useAppShellSessionUiReads(sessionUiController, activeId); @@ -2403,6 +2404,9 @@ function AppShellContent({ activeIdRef.current === sessionId && transcriptRangeRef.current === controller, setMessages, setReadingAnchor: sessionUiController.setTranscriptReadingAnchor, + onRestoreUnavailable: (sessionId, turnId) => { + sessionUiController.setTranscriptRestoreUnavailable(sessionId, turnId); + }, onError: (error, sessionId) => { sessionUiController.setMessageLoadErrorBySession((current) => ({ ...current, @@ -2570,11 +2574,17 @@ function AppShellContent({ const activeTranscriptReadingAnchor = activeId ? sessionUiController.transcriptReadingAnchorBySessionRef.current[activeId] : undefined; + const activeUnavailableTranscriptRestore = activeId + ? transcriptRestoreUnavailableBySession[activeId] + : undefined; const activeTranscriptRange = transcriptReadingPosition.currentRange( transcriptRangeRef.current, activeId, ); function handleTranscriptReadingAnchorChange(turnId?: string) { + if (activeId && activeUnavailableTranscriptRestore) { + sessionUiController.setTranscriptRestoreUnavailable(activeId, undefined); + } transcriptReadingPosition.captureAnchor({ sessionId: activeId, currentSessionId: activeIdRef.current, @@ -3128,8 +3138,15 @@ function AppShellContent({ : undefined } restoreTargetTurn={activeTranscriptReadingAnchor - ? { turnId: activeTranscriptReadingAnchor.turnId } - : undefined} + ? { + turnId: activeTranscriptReadingAnchor.turnId, + unavailable: + activeUnavailableTranscriptRestore + === activeTranscriptReadingAnchor.turnId, + } + : activeUnavailableTranscriptRestore + ? { turnId: activeUnavailableTranscriptRestore, unavailable: true } + : undefined} onReadingAnchorChange={activeId ? handleTranscriptReadingAnchorChange : undefined} 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 b0a6208236..afbb9f2873 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 @@ -110,6 +110,7 @@ export function restoreSessionTranscriptRange(options: { sessionId: string, anchor: TranscriptReadingAnchor | undefined, ) => void; + readonly onRestoreUnavailable?: (sessionId: string, turnId: string) => void; readonly onError: (error: unknown, sessionId: string) => void; }): (() => void) | undefined { const { controller, sessionId } = options; @@ -118,7 +119,9 @@ export function restoreSessionTranscriptRange(options: { if (readingAnchor && readingAnchor.sequence === undefined) { const { turnId } = readingAnchor; try { - const sequence = controller.store.sequenceForTurn(turnId); + const sequence = controller.store.range().sessionId === sessionId + ? controller.store.sequenceForTurn(turnId) + : null; if (sequence !== null) { readingAnchor = { turnId, sequence }; options.setReadingAnchor(sessionId, readingAnchor); @@ -127,20 +130,46 @@ export function restoreSessionTranscriptRange(options: { // A stale range cannot enrich the anchor, but also cannot invalidate it. } } - const target = options.searchTarget?.sessionId === sessionId + const searchTarget = options.searchTarget?.sessionId === sessionId ? options.searchTarget - : readingAnchor; - if (!target || target.sequence === undefined) return; + : undefined; + const target = searchTarget ?? readingAnchor; + if (!target || (searchTarget && target.sequence === undefined)) return; + const restoringReadingAnchor = searchTarget === undefined && readingAnchor !== undefined; let disposed = false; const current = (): boolean => !disposed && options.isCurrent(sessionId, controller); void controller.ready() .then(async () => { - if (!current() || controller.store.sequenceForTurn(target.turnId) !== null) return false; - await controller.loadAround(target.sequence!); - return true; + if (!current() || controller.store.range().sessionId !== sessionId) { + return { loaded: false, unavailable: false }; + } + const residentSequence = controller.store.sequenceForTurn(target.turnId); + if (residentSequence !== null) { + if (restoringReadingAnchor && readingAnchor?.sequence === undefined) { + options.setReadingAnchor(sessionId, { turnId: target.turnId, sequence: residentSequence }); + } + return { loaded: false, unavailable: false }; + } + if (target.sequence === undefined) { + return { loaded: false, unavailable: restoringReadingAnchor }; + } + await controller.loadAround(target.sequence); + if (!current() || controller.store.range().sessionId !== sessionId) { + return { loaded: false, unavailable: false }; + } + return { + loaded: true, + unavailable: restoringReadingAnchor + && controller.store.sequenceForTurn(target.turnId) === null, + }; }) - .then((loaded) => { - if (loaded && current()) options.setMessages([...controller.store.snapshot().messages]); + .then(({ loaded, unavailable }) => { + if (!current()) return; + if (loaded) options.setMessages([...controller.store.snapshot().messages]); + if (unavailable) { + options.setReadingAnchor(sessionId, undefined); + options.onRestoreUnavailable?.(sessionId, target.turnId); + } }) .catch((error) => { if (current()) options.onError(error, sessionId); diff --git a/apps/desktop/src/renderer/features/conversation/model/session-ui-state.ts b/apps/desktop/src/renderer/features/conversation/model/session-ui-state.ts index a983e468ed..350330deb6 100644 --- a/apps/desktop/src/renderer/features/conversation/model/session-ui-state.ts +++ b/apps/desktop/src/renderer/features/conversation/model/session-ui-state.ts @@ -34,6 +34,7 @@ export interface AppShellSessionUiState { shellRunUpdatesBySession: ShellRunUpdatesBySession; interactionBySession: InteractionQueues; messageQueueBySession: Record; + transcriptRestoreUnavailableBySession: Record; } // The pending plate keeps the Host revision beside its entries so edits can @@ -70,6 +71,7 @@ const SESSION_UI_MAP_KEYS = [ 'shellRunUpdatesBySession', 'interactionBySession', 'messageQueueBySession', + 'transcriptRestoreUnavailableBySession', ] as const satisfies readonly AppShellSessionUiStateMapKey[]; type MissingSessionUiMapKey = Exclude; @@ -215,6 +217,12 @@ export function createAppShellSessionUiStateController( setMessageQueueBySession: createMapSetter('messageQueueBySession'), setSessionEventHealthBySession: sessionEventHealthBySession.update, setTranscriptReadingAnchor: transcriptReadingAnchors.set, + setTranscriptRestoreUnavailable: (sessionId: string, turnId: string | undefined) => { + updateMap('transcriptRestoreUnavailableBySession', (current) => { + if (!turnId) return omitSessionKey(current, sessionId); + return current[sessionId] === turnId ? current : { ...current, [sessionId]: turnId }; + }); + }, /** * The authority said something about `turnId` — it started, failed to * start, or ended. Drop that arm's `unconfirmed` claim so a session list diff --git a/apps/desktop/src/renderer/use-app-shell-session-ui-reads.ts b/apps/desktop/src/renderer/use-app-shell-session-ui-reads.ts index ae598025ca..d6ef30d7bc 100644 --- a/apps/desktop/src/renderer/use-app-shell-session-ui-reads.ts +++ b/apps/desktop/src/renderer/use-app-shell-session-ui-reads.ts @@ -17,22 +17,27 @@ * under the License. */ -import type { InteractionQueues } from '@maka/ui'; import type { AppShellSessionUiState, AppShellSessionUiStateController, - MessageQueueUiState, } from './app-shell-session-ui-state.js'; import { deriveLiveTurnSnapshot, liveTurnSnapshotsEqual, selectStreamingSessionIds, sessionIdSetsEqual, - type LiveTurnSnapshot, } from './live-turn-snapshot.js'; import { useExternalStoreSelector } from './use-external-store-selector.js'; -const selectMessageLoadError = (state: AppShellSessionUiState) => state.messageLoadErrorBySession; +const selectMessageLoadState = (state: AppShellSessionUiState) => ({ + messageLoadErrorBySession: state.messageLoadErrorBySession, + transcriptRestoreUnavailableBySession: state.transcriptRestoreUnavailableBySession, +}); +const messageLoadStateEqual = ( + left: ReturnType, + right: ReturnType, +) => left.messageLoadErrorBySession === right.messageLoadErrorBySession + && left.transcriptRestoreUnavailableBySession === right.transcriptRestoreUnavailableBySession; const selectMessageRetryPending = (state: AppShellSessionUiState) => state.messageRetryPendingBySession; const selectStopPending = (state: AppShellSessionUiState) => state.stopPendingBySession; const selectInteraction = (state: AppShellSessionUiState) => state.interactionBySession; @@ -69,17 +74,15 @@ const selectActiveSnapshot = (state: AppShellSessionUiState, sessionId: string | export function useAppShellSessionUiReads( controller: AppShellSessionUiStateController, activeId: string | undefined, -): { - messageLoadErrorBySession: Record; - messageRetryPendingBySession: Record; - stopPendingBySession: Record; - interactionBySession: InteractionQueues; - messageQueueBySession: Record; - streamingSessionIds: Set; - activeLiveTurnSnapshot: LiveTurnSnapshot; -} { +) { + const messageLoadState = useExternalStoreSelector( + controller, + selectMessageLoadState, + undefined, + messageLoadStateEqual, + ); return { - messageLoadErrorBySession: useExternalStoreSelector(controller, selectMessageLoadError), + ...messageLoadState, messageRetryPendingBySession: useExternalStoreSelector(controller, selectMessageRetryPending), stopPendingBySession: useExternalStoreSelector(controller, selectStopPending), interactionBySession: useExternalStoreSelector(controller, selectInteraction), diff --git a/packages/ui/src/__tests__/use-chat-scroll.test.tsx b/packages/ui/src/__tests__/use-chat-scroll.test.tsx index a425855012..d871415c96 100644 --- a/packages/ui/src/__tests__/use-chat-scroll.test.tsx +++ b/packages/ui/src/__tests__/use-chat-scroll.test.tsx @@ -172,19 +172,26 @@ test('a session switch restores a Turn anchor after async fill and preserves tai }; const anchors = new Map(); + const unavailableRestores = new Map(); let authority: TranscriptScrollAuthority | undefined; let messageRevision = 0; let target: { turnId: string; nonce: number } | undefined; function Harness({ sessionId }: { sessionId: string }) { const scrollRef = useRef(scroller); authority = useTranscriptScrollAuthority(); + const unavailableTurnId = unavailableRestores.get(sessionId); + const restoreTurnId = unavailableTurnId ?? anchors.get(sessionId); + const restoreTarget = restoreTurnId + ? { turnId: restoreTurnId, unavailable: unavailableTurnId === restoreTurnId } + : undefined; useChatScroll({ scrollRef, sessionId, messages: [{ id: `message-${messageRevision}` }] as StoredMessage[], target, - restoreTarget: anchors.has(sessionId) ? { turnId: anchors.get(sessionId)! } : undefined, + restoreTarget, onReadingAnchorChange: (turnId) => { + unavailableRestores.delete(sessionId); if (turnId) anchors.set(sessionId, turnId); else anchors.delete(sessionId); }, @@ -248,6 +255,29 @@ test('a session switch restores a Turn anchor after async fill and preserves tai assert.equal(scroller.scrollTop, 1_800); assert.equal(authority?.getSnapshot().pinned, true); + // The same restore key can be handled successfully on one activation and + // become unavailable on the next. The earlier success must not swallow the + // later terminal result. + collapseTranscript(); + await renderSession('session-a'); + installTranscript(2_000, [{ id: 'turn-a-visible', start: 0, height: 2_000 }]); + await renderSession('session-a'); + await flushFrames(); + assert.equal(anchors.get('session-a'), 'turn-a-2'); + + unavailableRestores.set('session-a', 'turn-a-2'); + await renderSession('session-a'); + await flushFrames(); + assert.equal(anchors.get('session-a'), 'turn-a-visible'); + assert.equal(unavailableRestores.has('session-a'), false); + + collapseTranscript(); + await renderSession('session-b'); + installTranscript(2_400, [{ id: 'turn-b-1', start: 0, height: 2_400 }]); + deliverResize(); + assert.equal(scroller.scrollTop, 1_800); + assert.equal(authority?.getSnapshot().pinned, true); + // A command can land without producing a scroll event when layout or native // anchoring already put the Turn at the requested offset. Its semantic // reading position must still be reported before the user switches away. @@ -256,4 +286,19 @@ test('a session switch restores a Turn anchor after async fill and preserves tai await renderSession('session-b'); await flushFrames(); assert.equal(anchors.get('session-b'), 'turn-b-1'); + + target = undefined; + // With no resident Turn to re-anchor to, abandoning the restore falls back + // to the default tail intent and clears the stale reading anchor. + anchors.set('session-b', 'turn-b-never-renders'); + collapseTranscript(); + await renderSession('session-c'); + collapseTranscript(); + await renderSession('session-b'); + assert.equal(authority?.getSnapshot().pinned, false); + unavailableRestores.set('session-b', 'turn-b-never-renders'); + await renderSession('session-b'); + await flushFrames(); + assert.equal(authority?.getSnapshot().pinned, true); + assert.equal(anchors.has('session-b'), false); }); diff --git a/packages/ui/src/chat-view.tsx b/packages/ui/src/chat-view.tsx index 9d214651f9..b090851041 100644 --- a/packages/ui/src/chat-view.tsx +++ b/packages/ui/src/chat-view.tsx @@ -265,7 +265,7 @@ export function ChatView(props: { */ scrollTargetTurn?: { turnId: string; nonce: number }; /** Runtime-only reading position restored without search focus or highlight. */ - restoreTargetTurn?: { turnId: string }; + restoreTargetTurn?: { turnId: string; unavailable?: boolean }; onReadingAnchorChange?(turnId?: string): void; scrollBehavior: ScrollBehavior; hasOlderHistory?: boolean; diff --git a/packages/ui/src/use-chat-scroll.ts b/packages/ui/src/use-chat-scroll.ts index 5b7f120d38..d3e12ffb9b 100644 --- a/packages/ui/src/use-chat-scroll.ts +++ b/packages/ui/src/use-chat-scroll.ts @@ -41,7 +41,7 @@ export function useChatScroll(input: { sessionId?: string; messages: readonly StoredMessage[]; target?: { turnId: string; nonce: number }; - restoreTarget?: { turnId: string }; + restoreTarget?: { turnId: string; unavailable?: boolean }; onReadingAnchorChange?(turnId?: string): void; behavior: ScrollBehavior; hasOlderHistory?: boolean; @@ -59,16 +59,24 @@ export function useChatScroll(input: { const reportedAnchor = useRef<{ sessionId?: string; turnId?: string } | undefined>(undefined); const activation = useRef<{ sessionId?: string; restoreTurnId?: string } | undefined>(undefined); if (activation.current?.sessionId !== input.sessionId) { + handledTarget.current = null; activation.current = { sessionId: input.sessionId, restoreTurnId: input.restoreTarget?.turnId, }; } + const restoreUnavailable = + input.restoreTarget?.turnId === activation.current?.restoreTurnId + && input.restoreTarget?.unavailable === true; const commandTarget = useRef(null); commandTarget.current = input.target?.turnId ? `search:${input.sessionId ?? ''}:${input.target.turnId}:${input.target.nonce}` : activation.current?.restoreTurnId - ? `restore:${input.sessionId ?? ''}:${activation.current.restoreTurnId}` + ? restoreCommandKey( + input.sessionId, + activation.current.restoreTurnId, + restoreUnavailable, + ) : null; // A passive effect, not a layout one: the scroller is Astryx's layout root, @@ -178,7 +186,11 @@ export function useChatScroll(input: { : undefined; const restoreTurnId = activation.current?.restoreTurnId; const target = explicitTarget ?? (restoreTurnId - ? { kind: 'restore' as const, turnId: restoreTurnId } + ? { + kind: 'restore' as const, + turnId: restoreTurnId, + unavailable: restoreUnavailable, + } : undefined); if (!target) return; if (explicitTarget) activation.current = { sessionId: input.sessionId }; @@ -188,14 +200,21 @@ export function useChatScroll(input: { // a reader who had already scrolled back to it. const chosen = target.kind === 'search' ? `search:${input.sessionId ?? ''}:${target.turnId}:${target.nonce}` - : `restore:${input.sessionId ?? ''}:${target.turnId}`; + : restoreCommandKey(input.sessionId, target.turnId, target.unavailable); if (handledTarget.current === chosen) return; authority.releasePin(); const frame = window.requestAnimationFrame(() => { const root = input.scrollRef.current; if (!root) return; const element = root.querySelector(`[data-turn-id="${CSS.escape(target.turnId)}"]`); - if (!element || !('scrollIntoView' in element)) return; + if (!element || !('scrollIntoView' in element)) { + if (target.kind !== 'restore' || !target.unavailable) return; + handledTarget.current = chosen; + activation.current = { sessionId: input.sessionId }; + if (!firstVisibleTurnId(root)) authority.pinToTail(); + reportReadingAnchor.current?.(); + return; + } handledTarget.current = chosen; const targetElement = element as HTMLElement; targetElement.scrollIntoView({ @@ -224,6 +243,7 @@ export function useChatScroll(input: { input.target?.turnId, input.target?.nonce, input.restoreTarget?.turnId, + input.restoreTarget?.unavailable, input.behavior, input.sessionId, input.messages, @@ -242,3 +262,11 @@ function firstVisibleTurnId(root: HTMLElement | null): string | undefined { .find((turn) => turn.getBoundingClientRect().bottom > rootTop) ?.dataset.turnId; } + +function restoreCommandKey( + sessionId: string | undefined, + turnId: string, + unavailable: boolean, +): string { + return `restore:${sessionId ?? ''}:${turnId}:${unavailable ? 'unavailable' : 'pending'}`; +}