diff --git a/apps/desktop/e2e/transcript-scroll.spec.ts b/apps/desktop/e2e/transcript-scroll.spec.ts index acc885b2d7..8e599c683d 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'; /** @@ -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 { @@ -99,6 +99,48 @@ 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); +} + +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. * @@ -206,6 +248,96 @@ 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. 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/); + 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.locator('.maka-model-switcher-trigger'); + await modelSwitcher.click(); + await page.getByRole('menuitemradio', { 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/renderer-architecture.json b/apps/desktop/renderer-architecture.json index e5b1745fea..220f8f97db 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": 15725 }, "src/renderer/use-app-shell-composer-quotes.ts": { "importDeclarations": 3, @@ -1074,7 +1065,7 @@ "nonTriviaTokens": 581 }, "src/renderer/use-app-shell-session-ui-reads.ts": { - "importDeclarations": 4, + "importDeclarations": 3, "bridgePaths": {}, "environmentCapabilities": {}, "hookCalls": { @@ -1086,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, @@ -4612,9 +4602,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 696071e638..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 @@ -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 { @@ -70,6 +71,7 @@ function seededState(): AppShellSessionUiState { drop: [boundaryRequest('drop')], keep: [boundaryRequest('keep')], }, + transcriptRestoreUnavailableBySession: { drop: 'turn-drop', keep: 'turn-keep' }, }; } @@ -187,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', () => { @@ -234,6 +237,183 @@ 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('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({ + 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('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-session-ui-state.ts b/apps/desktop/src/renderer/app-shell-session-ui-state.ts index 8c91c09c69..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,243 +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; -} - -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: {}, - }; - - // 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, - 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>, - /** - * 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, - ); - 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 9c30c227a2..84a2984fa1 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], @@ -555,6 +554,7 @@ function AppShellContent({ stopPendingBySession, interactionBySession, messageQueueBySession, + transcriptRestoreUnavailableBySession, streamingSessionIds, activeLiveTurnSnapshot, } = useAppShellSessionUiReads(sessionUiController, activeId); @@ -2381,75 +2381,43 @@ 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(() => { - const target = searchScrollTarget; - if (!target || target.sessionId !== activeId || target.sequence === undefined) return; - const sequence = target.sequence; - const controller = transcriptRangeRef.current; - if (!controller) return; - let disposed = false; - void controller.ready() - .then(() => controller.loadAround(sequence)) - .then(() => { - if ( - disposed || - transcriptRangeRef.current !== controller || - activeIdRef.current !== target.sessionId - ) return; - setMessages([...controller.store.snapshot().messages]); - }) - .catch((error) => { - if (disposed || activeIdRef.current !== target.sessionId) return; - sessionUiController.setMessageLoadErrorBySession((current) => ({ - ...current, - [target.sessionId]: localizedShellErrorMessage( - error, - desktopConversationCopy.actions.operationFailedFallback, - uiLocale, - ), - })); - }); - return () => { - disposed = true; - }; - }, [activeId, 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, + onRestoreUnavailable: (sessionId, turnId) => { + sessionUiController.setTranscriptRestoreUnavailable(sessionId, turnId); + }, + 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, @@ -2603,13 +2571,27 @@ function AppShellContent({ activeId !== undefined || taskEntry.selectors.target !== undefined; const activeMessageLoadError = activeId ? messageLoadErrorBySession[activeId] : undefined; - let activeTranscriptRange; - try { - const controller = transcriptRangeRef.current; - const range = controller?.store.range(); - if (range?.sessionId === activeId) activeTranscriptRange = range; - } catch { - activeTranscriptRange = undefined; + 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, + turnId, + controller: transcriptRangeRef.current, + setAnchor: sessionUiController.setTranscriptReadingAnchor, + }); } async function loadTranscriptHistory(target: 'earlier' | 'latest', anchorTurnId?: string) { const controller = transcriptRangeRef.current; @@ -3155,6 +3137,19 @@ function AppShellContent({ } : undefined } + restoreTargetTurn={activeTranscriptReadingAnchor + ? { + turnId: activeTranscriptReadingAnchor.turnId, + unavailable: + activeUnavailableTranscriptRestore + === activeTranscriptReadingAnchor.turnId, + } + : activeUnavailableTranscriptRestore + ? { turnId: activeUnavailableTranscriptRestore, unavailable: true } + : undefined} + onReadingAnchorChange={activeId + ? handleTranscriptReadingAnchorChange + : undefined} transcriptTurnIndex={ transcriptTurnIndex && transcriptTurnIndex.sessionId === activeId ? transcriptTurnIndex.turns 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..afbb9f2873 --- /dev/null +++ b/apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position.ts @@ -0,0 +1,202 @@ +/* + * 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 onRestoreUnavailable?: (sessionId: string, turnId: string) => 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.range().sessionId === sessionId + ? controller.store.sequenceForTurn(turnId) + : null; + if (sequence !== null) { + readingAnchor = { turnId, sequence }; + options.setReadingAnchor(sessionId, readingAnchor); + } + } catch { + // A stale range cannot enrich the anchor, but also cannot invalidate it. + } + } + const searchTarget = options.searchTarget?.sessionId === sessionId + ? options.searchTarget + : 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.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, 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); + }); + 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..350330deb6 --- /dev/null +++ b/apps/desktop/src/renderer/features/conversation/model/session-ui-state.ts @@ -0,0 +1,310 @@ +/* + * 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; + transcriptRestoreUnavailableBySession: 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', + 'transcriptRestoreUnavailableBySession', +] 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, + 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 + * 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'; 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 new file mode 100644 index 0000000000..d871415c96 --- /dev/null +++ b/packages/ui/src/__tests__/use-chat-scroll.test.tsx @@ -0,0 +1,304 @@ +/* + * 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(); + 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, + onReadingAnchorChange: (turnId) => { + unavailableRestores.delete(sessionId); + 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); + + // 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. + dispatchCommandScroll = false; + target = { turnId: 'turn-b-1', nonce: 1 }; + 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 ba0fb9c72b..b090851041 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; unavailable?: boolean }; + 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..d3e12ffb9b 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; unavailable?: boolean }; + onReadingAnchorChange?(turnId?: string): void; behavior: ScrollBehavior; hasOlderHistory?: boolean; onLoadEarlierHistory?(anchorTurnId?: string): Promise | void; @@ -51,6 +53,31 @@ 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) { + 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 + ? restoreCommandKey( + input.sessionId, + activation.current.restoreTurnId, + restoreUnavailable, + ) + : 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 +85,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 +135,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,40 +181,92 @@ 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, + unavailable: restoreUnavailable, + } + : 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}` + : 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.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.restoreTarget?.unavailable, + 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; +} + +function restoreCommandKey( + sessionId: string | undefined, + turnId: string, + unavailable: boolean, +): string { + return `restore:${sessionId ?? ''}:${turnId}:${unavailable ? 'unavailable' : 'pending'}`; +}