From 051e1505cef67ad0d02bff6ec918a08bb2498de0 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 4 Jul 2026 18:10:53 +0800 Subject: [PATCH 1/3] refactor(ui-react): add app shell session ui reducer --- .../app-shell-session-ui-state.test.ts | 90 +++++++ .../renderer/app-shell-session-ui-state.ts | 226 ++++++++++++++++++ 2 files changed, 316 insertions(+) create mode 100644 apps/desktop/src/main/__tests__/app-shell-session-ui-state.test.ts create mode 100644 apps/desktop/src/renderer/app-shell-session-ui-state.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 new file mode 100644 index 0000000000..3b77fb1113 --- /dev/null +++ b/apps/desktop/src/main/__tests__/app-shell-session-ui-state.test.ts @@ -0,0 +1,90 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import type { PermissionRequestEvent } from '@maka/core'; +import { + appShellSessionUiStateReducer, + createInitialAppShellSessionUiState, + type AppShellSessionUiState, +} from '../../renderer/app-shell-session-ui-state.js'; + +function permissionRequest(requestId: string): PermissionRequestEvent { + return { + type: 'permission_request', + id: `event-${requestId}`, + ts: 1, + requestId, + toolUseId: `tool-${requestId}`, + toolName: 'shell', + } as unknown as PermissionRequestEvent; +} + +function seededState(): AppShellSessionUiState { + return { + ...createInitialAppShellSessionUiState(), + messageLoadErrorBySession: { drop: 'failed', keep: 'still failed' }, + messageRetryPendingBySession: { drop: true, keep: true }, + stopPendingBySession: { drop: true, keep: true }, + streamingBySession: { + drop: { text: 'drop stream', truncated: false, phase: 'streaming' }, + keep: { text: 'keep stream', truncated: true, phase: 'draining', messageId: 'm-keep' }, + }, + thinkingBySession: { drop: 'drop thinking', keep: 'keep thinking' }, + thinkingTruncatedBySession: { drop: true, keep: true }, + liveToolsBySession: { + drop: [{ toolUseId: 'tool-drop', toolName: 'Shell', status: 'running', args: {} }], + keep: [{ toolUseId: 'tool-keep', toolName: 'Shell', status: 'pending', args: {} }], + }, + permissionBySession: { + drop: [permissionRequest('drop')], + keep: [permissionRequest('keep')], + }, + sessionEventHealthBySession: { + drop: { sessionId: 'drop', status: 'connected', subscribedAt: 1, checkedAt: 1 }, + keep: { sessionId: 'keep', status: 'stale', subscribedAt: 1, checkedAt: 2, staleSince: 2 }, + }, + pendingPermissionModeBySession: { drop: true, keep: true }, + pendingSessionModelBySession: { drop: true, keep: true }, + }; +} + +describe('app shell session UI state reducer', () => { + it('clears one session from every per-session UI map without touching other sessions', () => { + const next = appShellSessionUiStateReducer(seededState(), { + type: 'clear-session', + sessionId: 'drop', + }); + + assert.deepEqual(Object.keys(next.messageLoadErrorBySession), ['keep']); + assert.deepEqual(Object.keys(next.messageRetryPendingBySession), ['keep']); + assert.deepEqual(Object.keys(next.stopPendingBySession), ['keep']); + assert.deepEqual(Object.keys(next.streamingBySession), ['keep']); + assert.deepEqual(Object.keys(next.thinkingBySession), ['keep']); + assert.deepEqual(Object.keys(next.thinkingTruncatedBySession), ['keep']); + assert.deepEqual(Object.keys(next.liveToolsBySession), ['keep']); + assert.deepEqual(Object.keys(next.permissionBySession), ['keep']); + assert.deepEqual(Object.keys(next.sessionEventHealthBySession), ['keep']); + assert.deepEqual(Object.keys(next.pendingPermissionModeBySession), ['keep']); + assert.deepEqual(Object.keys(next.pendingSessionModelBySession), ['keep']); + }); + + it('keeps state identity for no-op map updates and only replaces the selected map', () => { + const state = createInitialAppShellSessionUiState(); + const noop = appShellSessionUiStateReducer(state, { + type: 'update-map', + key: 'messageLoadErrorBySession', + updater: (current) => current, + }); + assert.equal(noop, state); + + const next = appShellSessionUiStateReducer(state, { + type: 'update-map', + key: 'messageLoadErrorBySession', + updater: (current) => ({ ...current, session: 'failed' }), + }); + + assert.notEqual(next, state); + assert.deepEqual(next.messageLoadErrorBySession, { session: 'failed' }); + assert.equal(next.stopPendingBySession, state.stopPendingBySession); + assert.equal(next.streamingBySession, state.streamingBySession); + }); +}); diff --git a/apps/desktop/src/renderer/app-shell-session-ui-state.ts b/apps/desktop/src/renderer/app-shell-session-ui-state.ts new file mode 100644 index 0000000000..bf510d630b --- /dev/null +++ b/apps/desktop/src/renderer/app-shell-session-ui-state.ts @@ -0,0 +1,226 @@ +import { useCallback, useReducer, useRef } from 'react'; +import type { SessionEventStreamSnapshot } from '@maka/core'; +import type { AssistantStreamSlot, PermissionQueues, ToolActivityItem } from '@maka/ui'; + +type StateUpdater = (updater: (current: T) => T) => void; + +export interface AppShellSessionUiState { + messageLoadErrorBySession: Record; + messageRetryPendingBySession: Record; + stopPendingBySession: Record; + streamingBySession: Record; + thinkingBySession: Record; + thinkingTruncatedBySession: Record; + liveToolsBySession: Record; + permissionBySession: PermissionQueues; + sessionEventHealthBySession: Record; + pendingPermissionModeBySession: Record; + pendingSessionModelBySession: Record; +} + +type AppShellSessionUiStateMapKey = keyof AppShellSessionUiState; + +type UpdateMapAction = { + [Key in K]: { + type: 'update-map'; + key: Key; + updater: (current: AppShellSessionUiState[Key]) => AppShellSessionUiState[Key]; + }; +}[K]; + +type ReplaceStateAction = { + type: 'replace-state'; + state: AppShellSessionUiState; +}; + +type AppShellSessionUiStateAction = + | ReplaceStateAction + | UpdateMapAction + | { + type: 'clear-session'; + sessionId: string; + }; + +export function createInitialAppShellSessionUiState(): AppShellSessionUiState { + return { + messageLoadErrorBySession: {}, + messageRetryPendingBySession: {}, + stopPendingBySession: {}, + streamingBySession: {}, + thinkingBySession: {}, + thinkingTruncatedBySession: {}, + liveToolsBySession: {}, + permissionBySession: {}, + sessionEventHealthBySession: {}, + pendingPermissionModeBySession: {}, + pendingSessionModelBySession: {}, + }; +} + +function omitSessionKey(current: Record, sessionId: string): Record { + if (!(sessionId in current)) return current; + const next = { ...current }; + delete next[sessionId]; + return next; +} + +function updateMap( + 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 clearAppShellSessionUiStateForSession( + state: AppShellSessionUiState, + sessionId: string, +): AppShellSessionUiState { + let nextState = state; + + nextState = updateMap(nextState, 'messageLoadErrorBySession', (current) => omitSessionKey(current, sessionId)); + nextState = updateMap(nextState, 'messageRetryPendingBySession', (current) => omitSessionKey(current, sessionId)); + nextState = updateMap(nextState, 'stopPendingBySession', (current) => omitSessionKey(current, sessionId)); + nextState = updateMap(nextState, 'streamingBySession', (current) => omitSessionKey(current, sessionId)); + nextState = updateMap(nextState, 'thinkingBySession', (current) => omitSessionKey(current, sessionId)); + nextState = updateMap(nextState, 'thinkingTruncatedBySession', (current) => omitSessionKey(current, sessionId)); + nextState = updateMap(nextState, 'liveToolsBySession', (current) => omitSessionKey(current, sessionId)); + nextState = updateMap(nextState, 'permissionBySession', (current) => omitSessionKey(current, sessionId)); + nextState = updateMap(nextState, 'sessionEventHealthBySession', (current) => omitSessionKey(current, sessionId)); + nextState = updateMap(nextState, 'pendingPermissionModeBySession', (current) => omitSessionKey(current, sessionId)); + nextState = updateMap(nextState, 'pendingSessionModelBySession', (current) => omitSessionKey(current, sessionId)); + + return nextState; +} + +export function appShellSessionUiStateReducer( + state: AppShellSessionUiState, + action: AppShellSessionUiStateAction, +): AppShellSessionUiState { + switch (action.type) { + case 'replace-state': + return action.state; + case 'clear-session': + return clearAppShellSessionUiStateForSession(state, action.sessionId); + case 'update-map': + switch (action.key) { + case 'messageLoadErrorBySession': + return updateMap(state, action.key, action.updater); + case 'messageRetryPendingBySession': + return updateMap(state, action.key, action.updater); + case 'stopPendingBySession': + return updateMap(state, action.key, action.updater); + case 'streamingBySession': + return updateMap(state, action.key, action.updater); + case 'thinkingBySession': + return updateMap(state, action.key, action.updater); + case 'thinkingTruncatedBySession': + return updateMap(state, action.key, action.updater); + case 'liveToolsBySession': + return updateMap(state, action.key, action.updater); + case 'permissionBySession': + return updateMap(state, action.key, action.updater); + case 'sessionEventHealthBySession': + return updateMap(state, action.key, action.updater); + case 'pendingPermissionModeBySession': + return updateMap(state, action.key, action.updater); + case 'pendingSessionModelBySession': + return updateMap(state, action.key, action.updater); + } + } +} + +export function useAppShellSessionUiState() { + const initialStateRef = useRef(null); + if (!initialStateRef.current) initialStateRef.current = createInitialAppShellSessionUiState(); + + const stateRef = useRef(initialStateRef.current); + // Event handlers need same-frame reads after state setters run. + const streamingBySessionRef = useRef>(stateRef.current.streamingBySession); + const sessionEventHealthBySessionRef = + useRef>(stateRef.current.sessionEventHealthBySession); + const [state, baseDispatch] = useReducer(appShellSessionUiStateReducer, stateRef.current); + + const replaceState = useCallback((next: AppShellSessionUiState) => { + stateRef.current = next; + streamingBySessionRef.current = next.streamingBySession; + sessionEventHealthBySessionRef.current = next.sessionEventHealthBySession; + baseDispatch({ type: 'replace-state', state: next }); + }, []); + + const dispatch = useCallback((action: Exclude) => { + const next = appShellSessionUiStateReducer(stateRef.current, action); + if (next === stateRef.current) return; + replaceState(next); + }, [replaceState]); + + const setMessageLoadErrorBySession = useCallback>>( + (updater) => dispatch({ type: 'update-map', key: 'messageLoadErrorBySession', updater }), + [dispatch], + ); + const setMessageRetryPendingBySession = useCallback>>( + (updater) => dispatch({ type: 'update-map', key: 'messageRetryPendingBySession', updater }), + [dispatch], + ); + const setStopPendingBySession = useCallback>>( + (updater) => dispatch({ type: 'update-map', key: 'stopPendingBySession', updater }), + [dispatch], + ); + const setStreamingBySession = useCallback>>( + (updater) => dispatch({ type: 'update-map', key: 'streamingBySession', updater }), + [dispatch], + ); + const setThinkingBySession = useCallback>>( + (updater) => dispatch({ type: 'update-map', key: 'thinkingBySession', updater }), + [dispatch], + ); + const setThinkingTruncatedBySession = useCallback>>( + (updater) => dispatch({ type: 'update-map', key: 'thinkingTruncatedBySession', updater }), + [dispatch], + ); + const setLiveToolsBySession = useCallback>>( + (updater) => dispatch({ type: 'update-map', key: 'liveToolsBySession', updater }), + [dispatch], + ); + const setPermissionBySession = useCallback>( + (updater) => dispatch({ type: 'update-map', key: 'permissionBySession', updater }), + [dispatch], + ); + const setSessionEventHealthBySession = + useCallback>>( + (updater) => dispatch({ type: 'update-map', key: 'sessionEventHealthBySession', updater }), + [dispatch], + ); + const setPendingPermissionModeBySession = useCallback>>( + (updater) => dispatch({ type: 'update-map', key: 'pendingPermissionModeBySession', updater }), + [dispatch], + ); + const setPendingSessionModelBySession = useCallback>>( + (updater) => dispatch({ type: 'update-map', key: 'pendingSessionModelBySession', updater }), + [dispatch], + ); + const clearSessionUiState = useCallback((sessionId: string) => { + dispatch({ type: 'clear-session', sessionId }); + }, [dispatch]); + + return { + state, + streamingBySessionRef, + sessionEventHealthBySessionRef, + setMessageLoadErrorBySession, + setMessageRetryPendingBySession, + setStopPendingBySession, + setStreamingBySession, + setThinkingBySession, + setThinkingTruncatedBySession, + setLiveToolsBySession, + setPermissionBySession, + setSessionEventHealthBySession, + setPendingPermissionModeBySession, + setPendingSessionModelBySession, + clearSessionUiState, + }; +} From 02754e675d1b3f139672c26a35681541606f0106 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 4 Jul 2026 18:11:19 +0800 Subject: [PATCH 2/3] refactor(ui-react): use reducer for app shell session state --- ...session-message-lifecycle-contract.test.ts | 4 +- ...ion-row-actions-fail-soft-contract.test.ts | 16 +-- .../session-status-presentation.test.ts | 3 +- .../session-sticky-model-contract.test.ts | 3 +- apps/desktop/src/renderer/app-shell.tsx | 111 +++++------------- 5 files changed, 43 insertions(+), 94 deletions(-) diff --git a/apps/desktop/src/main/__tests__/session-message-lifecycle-contract.test.ts b/apps/desktop/src/main/__tests__/session-message-lifecycle-contract.test.ts index b00c3a7a61..3a972a4957 100644 --- a/apps/desktop/src/main/__tests__/session-message-lifecycle-contract.test.ts +++ b/apps/desktop/src/main/__tests__/session-message-lifecycle-contract.test.ts @@ -81,8 +81,8 @@ describe('active session message lifecycle contract', () => { ); assert.match( src, - /const \[messageRetryPendingBySession, setMessageRetryPendingBySession\] = useState>\(\{\}\);[\s\S]*const messageRetryPendingRef = useRef>\(new Set\(\)\)/, - 'desktop shell must track message retry pending state outside React render timing', + /const messageRetryPendingRef = useRef>\(new Set\(\)\);[\s\S]*const \{[\s\S]*setMessageRetryPendingBySession,[\s\S]*\} = useAppShellSessionUiState\(\);[\s\S]*const \{[\s\S]*messageRetryPendingBySession,[\s\S]*\} = sessionUiState;/, + 'desktop shell must keep the ref-backed duplicate guard while exposing per-session retry pending state from the shell UI reducer', ); assert.match( src, diff --git a/apps/desktop/src/main/__tests__/session-row-actions-fail-soft-contract.test.ts b/apps/desktop/src/main/__tests__/session-row-actions-fail-soft-contract.test.ts index c63315386b..db1034e648 100644 --- a/apps/desktop/src/main/__tests__/session-row-actions-fail-soft-contract.test.ts +++ b/apps/desktop/src/main/__tests__/session-row-actions-fail-soft-contract.test.ts @@ -44,17 +44,11 @@ describe('session row actions fail soft', () => { assert.match(cleanupBlock, /clearPendingTurnActionsForSession\(sessionId\);/); assert.match(cleanupBlock, /pendingPermissionModeChangesRef\.current\.delete\(sessionId\);/); assert.match(cleanupBlock, /pendingSessionModelChangesRef\.current\.delete\(sessionId\);/); - assert.match(cleanupBlock, /setMessageRetryPendingBySession\(\(current\) => omitSessionKey\(current, sessionId\)\);/); - assert.match(cleanupBlock, /setStopPendingBySession\(\(current\) => omitSessionKey\(current, sessionId\)\);/); - assert.match(cleanupBlock, /setPendingPermissionModeBySession\(\(current\) => omitSessionKey\(current, sessionId\)\);/); - assert.match(cleanupBlock, /setPendingSessionModelBySession\(\(current\) => omitSessionKey\(current, sessionId\)\);/); - assert.match(cleanupBlock, /setMessageLoadErrorBySession\(\(current\) => omitSessionKey\(current, sessionId\)\);/); - assert.match(cleanupBlock, /setStreamingBySession\(\(current\) => omitSessionKey\(current, sessionId\)\);/); - assert.match(cleanupBlock, /setThinkingBySession\(\(current\) => omitSessionKey\(current, sessionId\)\);/); - assert.match(cleanupBlock, /setThinkingTruncatedBySession\(\(current\) => omitSessionKey\(current, sessionId\)\);/); - assert.match(cleanupBlock, /setLiveToolsBySession\(\(current\) => omitSessionKey\(current, sessionId\)\);/); - assert.match(cleanupBlock, /setPermissionBySession\(\(current\) => omitSessionKey\(current, sessionId\)\);/); - assert.match(cleanupBlock, /setSessionEventHealthBySession\(\(current\) => omitSessionKey\(current, sessionId\)\);/); + assert.match( + cleanupBlock, + /clearSessionUiState\(sessionId\);/, + 'archive/delete cleanup must use the centralized per-session UI state cleanup', + ); assert.match( main, diff --git a/apps/desktop/src/main/__tests__/session-status-presentation.test.ts b/apps/desktop/src/main/__tests__/session-status-presentation.test.ts index c3cfb6ed1b..3f2db3590b 100644 --- a/apps/desktop/src/main/__tests__/session-status-presentation.test.ts +++ b/apps/desktop/src/main/__tests__/session-status-presentation.test.ts @@ -201,7 +201,8 @@ describe('permission mode transition guard copy', () => { const setPermissionModeBlock = renderer.match(/async function setPermissionMode[\s\S]*?async function setSessionModel/)?.[0] ?? ''; assert.match(renderer, /const pendingPermissionModeChangesRef = useRef>\(new Set\(\)\);/); - assert.match(renderer, /const \[pendingPermissionModeBySession, setPendingPermissionModeBySession\] = useState>\(\{\}\);/); + assert.match(renderer, /const \{[\s\S]*setPendingPermissionModeBySession,[\s\S]*\} = useAppShellSessionUiState\(\);/); + assert.match(renderer, /const \{[\s\S]*pendingPermissionModeBySession,[\s\S]*\} = sessionUiState;/); assert.match( setPermissionModeBlock, /const sessionId = activeIdRef\.current;[\s\S]*if \(!sessionId\) \{[\s\S]*setPendingNewChatPermissionMode\(mode\);[\s\S]*return;[\s\S]*\}[\s\S]*pendingPermissionModeChangesRef\.current\.has\(sessionId\)/, diff --git a/apps/desktop/src/main/__tests__/session-sticky-model-contract.test.ts b/apps/desktop/src/main/__tests__/session-sticky-model-contract.test.ts index 4e6fd0b297..eb10a7e310 100644 --- a/apps/desktop/src/main/__tests__/session-sticky-model-contract.test.ts +++ b/apps/desktop/src/main/__tests__/session-sticky-model-contract.test.ts @@ -85,7 +85,8 @@ describe('PR-SESSION-STICKY-MODEL-0 contract', () => { assert.match(globalTypes, /setModel\(sessionId: string, input: \{ llmConnectionSlug: string; model: string \}\): Promise/); assert.match(renderer, /modelChoices=\{chatModelChoices\}/); assert.match(renderer, /const pendingSessionModelChangesRef = useRef>\(new Set\(\)\);/); - assert.match(renderer, /const \[pendingSessionModelBySession, setPendingSessionModelBySession\] = useState>\(\{\}\);/); + assert.match(renderer, /const \{[\s\S]*setPendingSessionModelBySession,[\s\S]*\} = useAppShellSessionUiState\(\);/); + assert.match(renderer, /const \{[\s\S]*pendingSessionModelBySession,[\s\S]*\} = sessionUiState;/); assert.match(renderer, /const sessionId = activeIdRef\.current;[\s\S]*pendingSessionModelChangesRef\.current\.has\(sessionId\)[\s\S]*window\.maka\.sessions\.setModel\(sessionId, input\)[\s\S]*finally \{[\s\S]*pendingSessionModelChangesRef\.current\.delete\(sessionId\);/); assert.match( renderer, diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index cf58e29d73..91b5ba85d2 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -4,7 +4,6 @@ import type { LlmConnection, PermissionMode, PlanReminder, - SessionEventStreamSnapshot, SessionSummary, SettingsSection, StoredMessage, @@ -13,7 +12,6 @@ import type { } from '@maka/core'; import { generalizedErrorMessageChinese, hasSettledInitialOnboarding } from '@maka/core'; import { - type AssistantStreamSlot, type ChatHeaderAlert, type ChatModelChoice, ChatView, @@ -26,9 +24,7 @@ import { type SkillEntry, type TurnFooterActionMeta, useToast, - type ToolActivityItem, activePermissionFor, - type PermissionQueues, } from '@maka/ui'; import { useKeyboardHelp } from './keyboard-help'; import { useCommandPalette } from './command-palette'; @@ -98,6 +94,7 @@ import { createAppShellImportActions } from './app-shell-import-actions'; import { createAppShellSessionRowActions } from './app-shell-session-row-actions'; import { createAppShellSessionSettingsActions } from './app-shell-session-settings-actions'; import { createAppShellStopAction } from './app-shell-stop-action'; +import { useAppShellSessionUiState } from './app-shell-session-ui-state'; import { useActiveSessionEvents, useAppShellBootstrapSubscriptions, @@ -143,55 +140,38 @@ export function AppShell() { const [navSelection, setNavSelection] = useState(() => readNavSelection()); const navSelectionRef = useRef(navSelection); const [messages, setMessages] = useState([]); - const [messageLoadErrorBySession, setMessageLoadErrorBySession] = useState>({}); - const [messageRetryPendingBySession, setMessageRetryPendingBySession] = useState>({}); const messageRetryPendingRef = useRef>(new Set()); - const [stopPendingBySession, setStopPendingBySession] = useState>({}); const stopPendingRef = useRef>(new Set()); - // PR-UI-Cx fixup v2 (@kenji msg 3c01e901 Blocker 2): combined - // per-session assistant streaming state. The `text` + `truncated` - // pair lives in a SINGLE useState so the `text_delta` handler can - // produce both fields from one functional updater — no - // cross-mutation between updaters, no closure-variable hack. - // `truncated` is monotonic while deltas are streaming; `text_complete` - // replaces the slot with the final payload, so the flag then reflects the - // final visible text until `clearStreaming` resets the slot. - const [streamingBySession, setStreamingBySessionState] = useState>({}); - // Session event handlers are subscribed per activeId; read live stream slots from this ref to avoid stale render closures. - const streamingBySessionRef = useRef>({}); - function setStreamingBySession( - updater: (current: Record) => Record, - ) { - const current = streamingBySessionRef.current; - const next = updater(current); - if (next === current) return; - streamingBySessionRef.current = next; - setStreamingBySessionState(next); - } - /** - * PR-UI-LAYOUT-42 (@kenji reference renderer audit, external docs/12-renderer.md §15.3): - * The reference design displays Anthropic-style `reasoning_content` - * (extended thinking) in a collapsible "Reasoning" panel above the - * assistant answer. Maka already emits `ThinkingDeltaEvent` / `ThinkingCompleteEvent` - * from `@ai-sdk/anthropic` (events.ts:76-88) but the renderer drops - * them on the floor — users with thinking models see nothing while - * the model is reasoning. This map accumulates thinking text per - * session so the chat surface can render the panel below the - * existing streaming text. - */ - const [thinkingBySession, setThinkingBySession] = useState>({}); - // PR-UI-C0 review fixup (@kenji msg 7885a347): per-session monotonic - // truncated flag for the thinking buffer. Flipped to `true` when - // `applyThinkingDelta` / `applyThinkingComplete` drops content - // (per-delta cap or per-session total cap). Stays true until the - // panel collapses via `clearStreaming(sessionId)` — same lifecycle - // as `thinkingBySession[sessionId]`. The `` reads - // it via the `truncated` prop to render the "已截断" pill. - const [thinkingTruncatedBySession, setThinkingTruncatedBySession] = useState>({}); - // PR-UI-Cx (@kenji msg 94b0063d → fixup v2 msg 3c01e901): - // `streamingTruncatedBySession` is now inlined into the combined - // `streamingBySession[sessionId].truncated` slot above. See the - // type definition near `useState>`. + const { + state: sessionUiState, + streamingBySessionRef, + sessionEventHealthBySessionRef, + setMessageLoadErrorBySession, + setMessageRetryPendingBySession, + setStopPendingBySession, + setStreamingBySession, + setThinkingBySession, + setThinkingTruncatedBySession, + setLiveToolsBySession, + setPermissionBySession, + setSessionEventHealthBySession, + setPendingPermissionModeBySession, + setPendingSessionModelBySession, + clearSessionUiState, + } = useAppShellSessionUiState(); + const { + messageLoadErrorBySession, + messageRetryPendingBySession, + stopPendingBySession, + streamingBySession, + thinkingBySession, + thinkingTruncatedBySession, + liveToolsBySession, + permissionBySession, + sessionEventHealthBySession, + pendingPermissionModeBySession, + pendingSessionModelBySession, + } = sessionUiState; // PR-MEMORY-VISIBILITY-INDICATOR-0: surface a small pill in the // chat header when xuan's MEMORY.md is being injected into the // agent's system prompt (PR-MEMORY-PROMPT-INJECT-0). Refreshed @@ -199,21 +179,6 @@ export function AppShell() { // whenever the Settings modal closes (the user may have toggled // the agentReadEnabled switch). const [memoryActive, setMemoryActive] = useState(false); - const [liveToolsBySession, setLiveToolsBySession] = useState>({}); - const [permissionBySession, setPermissionBySession] = useState({}); - const [sessionEventHealthBySessionState, setSessionEventHealthBySessionState] = - useState>({}); - const sessionEventHealthBySessionRef = useRef>({}); - const sessionEventHealthBySession = sessionEventHealthBySessionState; - function setSessionEventHealthBySession( - updater: (current: Record) => Record, - ): void { - setSessionEventHealthBySessionState((current) => { - const next = updater(current); - sessionEventHealthBySessionRef.current = next; - return next; - }); - } const [connections, setConnections] = useState([]); const [defaultConnection, setDefaultConnection] = useState(null); const [settingsOpen, setSettingsOpen] = useState(false); @@ -410,9 +375,7 @@ export function AppShell() { const pendingTurnActionTimersRef = useRef>>(new Map()); const pendingSessionRowActionsRef = useRef>(new Set()); const pendingPermissionModeChangesRef = useRef>(new Set()); - const [pendingPermissionModeBySession, setPendingPermissionModeBySession] = useState>({}); const pendingSessionModelChangesRef = useRef>(new Set()); - const [pendingSessionModelBySession, setPendingSessionModelBySession] = useState>({}); const pendingKeyOf = (sessionId: string, turnId: string, actionId: TurnFooterActionMeta['id']) => `${sessionId}:${turnId}:${actionId}`; function addPendingTurnAction(key: string): boolean { @@ -471,17 +434,7 @@ export function AppShell() { clearPendingTurnActionsForSession(sessionId); pendingPermissionModeChangesRef.current.delete(sessionId); pendingSessionModelChangesRef.current.delete(sessionId); - setMessageRetryPendingBySession((current) => omitSessionKey(current, sessionId)); - setStopPendingBySession((current) => omitSessionKey(current, sessionId)); - setPendingPermissionModeBySession((current) => omitSessionKey(current, sessionId)); - setPendingSessionModelBySession((current) => omitSessionKey(current, sessionId)); - setMessageLoadErrorBySession((current) => omitSessionKey(current, sessionId)); - setStreamingBySession((current) => omitSessionKey(current, sessionId)); - setThinkingBySession((current) => omitSessionKey(current, sessionId)); - setThinkingTruncatedBySession((current) => omitSessionKey(current, sessionId)); - setLiveToolsBySession((current) => omitSessionKey(current, sessionId)); - setPermissionBySession((current) => omitSessionKey(current, sessionId)); - setSessionEventHealthBySession((current) => omitSessionKey(current, sessionId)); + clearSessionUiState(sessionId); } const { From b7d3a99225aeb7e413adcc1b6070ef173651861f Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 4 Jul 2026 19:38:17 +0800 Subject: [PATCH 3/3] fix(ui-react): preserve nested session ui updates --- .../app-shell-session-ui-state.test.ts | 64 ++-- .../renderer/app-shell-session-ui-state.ts | 278 +++++++----------- 2 files changed, 155 insertions(+), 187 deletions(-) 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 3b77fb1113..41f99c157e 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 @@ -1,8 +1,10 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import type { PermissionRequestEvent } from '@maka/core'; +import { applyThinkingComplete, applyThinkingDelta } from '@maka/ui'; import { - appShellSessionUiStateReducer, + clearAppShellSessionUiStateForSession, + createAppShellSessionUiStateController, createInitialAppShellSessionUiState, type AppShellSessionUiState, } from '../../renderer/app-shell-session-ui-state.js'; @@ -47,12 +49,9 @@ function seededState(): AppShellSessionUiState { }; } -describe('app shell session UI state reducer', () => { +describe('app shell session UI state controller', () => { it('clears one session from every per-session UI map without touching other sessions', () => { - const next = appShellSessionUiStateReducer(seededState(), { - type: 'clear-session', - sessionId: 'drop', - }); + const next = clearAppShellSessionUiStateForSession(seededState(), 'drop'); assert.deepEqual(Object.keys(next.messageLoadErrorBySession), ['keep']); assert.deepEqual(Object.keys(next.messageRetryPendingBySession), ['keep']); @@ -68,23 +67,52 @@ describe('app shell session UI state reducer', () => { }); it('keeps state identity for no-op map updates and only replaces the selected map', () => { - const state = createInitialAppShellSessionUiState(); - const noop = appShellSessionUiStateReducer(state, { - type: 'update-map', - key: 'messageLoadErrorBySession', - updater: (current) => current, - }); - assert.equal(noop, state); + const controller = createAppShellSessionUiStateController(); + const state = controller.getState(); + controller.setMessageLoadErrorBySession((current) => current); + assert.equal(controller.getState(), state); - const next = appShellSessionUiStateReducer(state, { - type: 'update-map', - key: 'messageLoadErrorBySession', - updater: (current) => ({ ...current, session: 'failed' }), - }); + controller.setMessageLoadErrorBySession((current) => ({ ...current, session: 'failed' })); + const next = controller.getState(); assert.notEqual(next, state); assert.deepEqual(next.messageLoadErrorBySession, { session: 'failed' }); assert.equal(next.stopPendingBySession, state.stopPendingBySession); assert.equal(next.streamingBySession, state.streamingBySession); }); + + it('preserves nested thinking flag updates from thinking delta and complete events', () => { + const sessionId = 'thinking-session'; + const controller = createAppShellSessionUiStateController(); + + controller.setThinkingBySession((current) => { + const applied = applyThinkingDelta(current[sessionId] ?? '', 'x'.repeat(5 * 1024)); + if (applied.truncated) { + controller.setThinkingTruncatedBySession((flags) => + flags[sessionId] ? flags : { ...flags, [sessionId]: true }, + ); + } + return { ...current, [sessionId]: applied.text }; + }); + + const afterDelta = controller.getState(); + assert.match(afterDelta.thinkingBySession[sessionId], /单条 delta 已截断/); + assert.equal(afterDelta.thinkingTruncatedBySession[sessionId], true); + + controller.setThinkingBySession((current) => { + const applied = applyThinkingComplete('final thinking'); + controller.setThinkingTruncatedBySession((flags) => { + if ((flags[sessionId] === true) === applied.truncated) return flags; + if (applied.truncated) return { ...flags, [sessionId]: true }; + const next = { ...flags }; + delete next[sessionId]; + return next; + }); + return { ...current, [sessionId]: applied.text }; + }); + + const afterComplete = controller.getState(); + assert.equal(afterComplete.thinkingBySession[sessionId], 'final thinking'); + assert.equal(afterComplete.thinkingTruncatedBySession[sessionId], undefined); + }); }); 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 bf510d630b..f46ba0e084 100644 --- a/apps/desktop/src/renderer/app-shell-session-ui-state.ts +++ b/apps/desktop/src/renderer/app-shell-session-ui-state.ts @@ -1,4 +1,4 @@ -import { useCallback, useReducer, useRef } from 'react'; +import { useReducer, useRef } from 'react'; import type { SessionEventStreamSnapshot } from '@maka/core'; import type { AssistantStreamSlot, PermissionQueues, ToolActivityItem } from '@maka/ui'; @@ -20,51 +20,39 @@ export interface AppShellSessionUiState { type AppShellSessionUiStateMapKey = keyof AppShellSessionUiState; -type UpdateMapAction = { - [Key in K]: { - type: 'update-map'; - key: Key; - updater: (current: AppShellSessionUiState[Key]) => AppShellSessionUiState[Key]; - }; -}[K]; - -type ReplaceStateAction = { - type: 'replace-state'; - state: AppShellSessionUiState; -}; - -type AppShellSessionUiStateAction = - | ReplaceStateAction - | UpdateMapAction - | { - type: 'clear-session'; - sessionId: string; - }; +const SESSION_UI_MAP_KEYS = [ + 'messageLoadErrorBySession', + 'messageRetryPendingBySession', + 'stopPendingBySession', + 'streamingBySession', + 'thinkingBySession', + 'thinkingTruncatedBySession', + 'liveToolsBySession', + 'permissionBySession', + 'sessionEventHealthBySession', + 'pendingPermissionModeBySession', + 'pendingSessionModelBySession', +] as const satisfies readonly AppShellSessionUiStateMapKey[]; + +type MissingSessionUiMapKey = Exclude; +const allSessionUiMapsAreListed: Record = {}; +void allSessionUiMapsAreListed; export function createInitialAppShellSessionUiState(): AppShellSessionUiState { - return { - messageLoadErrorBySession: {}, - messageRetryPendingBySession: {}, - stopPendingBySession: {}, - streamingBySession: {}, - thinkingBySession: {}, - thinkingTruncatedBySession: {}, - liveToolsBySession: {}, - permissionBySession: {}, - sessionEventHealthBySession: {}, - pendingPermissionModeBySession: {}, - pendingSessionModelBySession: {}, - }; + return Object.fromEntries(SESSION_UI_MAP_KEYS.map((key) => [key, {}])) as unknown as AppShellSessionUiState; } -function omitSessionKey(current: Record, sessionId: string): Record { +function omitSessionKey( + current: AppShellSessionUiState[K], + sessionId: string, +): AppShellSessionUiState[K] { if (!(sessionId in current)) return current; const next = { ...current }; - delete next[sessionId]; - return next; + delete (next as Record)[sessionId]; + return next as AppShellSessionUiState[K]; } -function updateMap( +function updateAppShellSessionUiStateMap( state: AppShellSessionUiState, key: K, updater: (current: AppShellSessionUiState[K]) => AppShellSessionUiState[K], @@ -75,152 +63,104 @@ function updateMap( return { ...state, [key]: next }; } -function clearAppShellSessionUiStateForSession( +function clearSessionUiStateMap( state: AppShellSessionUiState, + key: K, sessionId: string, ): AppShellSessionUiState { - let nextState = state; - - nextState = updateMap(nextState, 'messageLoadErrorBySession', (current) => omitSessionKey(current, sessionId)); - nextState = updateMap(nextState, 'messageRetryPendingBySession', (current) => omitSessionKey(current, sessionId)); - nextState = updateMap(nextState, 'stopPendingBySession', (current) => omitSessionKey(current, sessionId)); - nextState = updateMap(nextState, 'streamingBySession', (current) => omitSessionKey(current, sessionId)); - nextState = updateMap(nextState, 'thinkingBySession', (current) => omitSessionKey(current, sessionId)); - nextState = updateMap(nextState, 'thinkingTruncatedBySession', (current) => omitSessionKey(current, sessionId)); - nextState = updateMap(nextState, 'liveToolsBySession', (current) => omitSessionKey(current, sessionId)); - nextState = updateMap(nextState, 'permissionBySession', (current) => omitSessionKey(current, sessionId)); - nextState = updateMap(nextState, 'sessionEventHealthBySession', (current) => omitSessionKey(current, sessionId)); - nextState = updateMap(nextState, 'pendingPermissionModeBySession', (current) => omitSessionKey(current, sessionId)); - nextState = updateMap(nextState, 'pendingSessionModelBySession', (current) => omitSessionKey(current, sessionId)); - - return nextState; + return updateAppShellSessionUiStateMap(state, key, (current) => omitSessionKey(current, sessionId)); } -export function appShellSessionUiStateReducer( +export function clearAppShellSessionUiStateForSession( state: AppShellSessionUiState, - action: AppShellSessionUiStateAction, + sessionId: string, ): AppShellSessionUiState { - switch (action.type) { - case 'replace-state': - return action.state; - case 'clear-session': - return clearAppShellSessionUiStateForSession(state, action.sessionId); - case 'update-map': - switch (action.key) { - case 'messageLoadErrorBySession': - return updateMap(state, action.key, action.updater); - case 'messageRetryPendingBySession': - return updateMap(state, action.key, action.updater); - case 'stopPendingBySession': - return updateMap(state, action.key, action.updater); - case 'streamingBySession': - return updateMap(state, action.key, action.updater); - case 'thinkingBySession': - return updateMap(state, action.key, action.updater); - case 'thinkingTruncatedBySession': - return updateMap(state, action.key, action.updater); - case 'liveToolsBySession': - return updateMap(state, action.key, action.updater); - case 'permissionBySession': - return updateMap(state, action.key, action.updater); - case 'sessionEventHealthBySession': - return updateMap(state, action.key, action.updater); - case 'pendingPermissionModeBySession': - return updateMap(state, action.key, action.updater); - case 'pendingSessionModelBySession': - return updateMap(state, action.key, action.updater); - } + let nextState = state; + for (const key of SESSION_UI_MAP_KEYS) { + nextState = clearSessionUiStateMap(nextState, key, sessionId); } + return nextState; } -export function useAppShellSessionUiState() { - const initialStateRef = useRef(null); - if (!initialStateRef.current) initialStateRef.current = createInitialAppShellSessionUiState(); - - const stateRef = useRef(initialStateRef.current); - // Event handlers need same-frame reads after state setters run. - const streamingBySessionRef = useRef>(stateRef.current.streamingBySession); - const sessionEventHealthBySessionRef = - useRef>(stateRef.current.sessionEventHealthBySession); - const [state, baseDispatch] = useReducer(appShellSessionUiStateReducer, stateRef.current); - - const replaceState = useCallback((next: AppShellSessionUiState) => { - stateRef.current = next; +export function createAppShellSessionUiStateController( + initialState: AppShellSessionUiState = createInitialAppShellSessionUiState(), + onChange: (state: AppShellSessionUiState) => void = () => {}, +) { + let currentState = initialState; + const streamingBySessionRef = { current: currentState.streamingBySession }; + const sessionEventHealthBySessionRef = { current: currentState.sessionEventHealthBySession }; + + function replaceState(next: AppShellSessionUiState): void { + if (next === currentState) return; + currentState = next; streamingBySessionRef.current = next.streamingBySession; sessionEventHealthBySessionRef.current = next.sessionEventHealthBySession; - baseDispatch({ type: 'replace-state', state: next }); - }, []); - - const dispatch = useCallback((action: Exclude) => { - const next = appShellSessionUiStateReducer(stateRef.current, action); - if (next === stateRef.current) return; - replaceState(next); - }, [replaceState]); - - const setMessageLoadErrorBySession = useCallback>>( - (updater) => dispatch({ type: 'update-map', key: 'messageLoadErrorBySession', updater }), - [dispatch], - ); - const setMessageRetryPendingBySession = useCallback>>( - (updater) => dispatch({ type: 'update-map', key: 'messageRetryPendingBySession', updater }), - [dispatch], - ); - const setStopPendingBySession = useCallback>>( - (updater) => dispatch({ type: 'update-map', key: 'stopPendingBySession', updater }), - [dispatch], - ); - const setStreamingBySession = useCallback>>( - (updater) => dispatch({ type: 'update-map', key: 'streamingBySession', updater }), - [dispatch], - ); - const setThinkingBySession = useCallback>>( - (updater) => dispatch({ type: 'update-map', key: 'thinkingBySession', updater }), - [dispatch], - ); - const setThinkingTruncatedBySession = useCallback>>( - (updater) => dispatch({ type: 'update-map', key: 'thinkingTruncatedBySession', updater }), - [dispatch], - ); - const setLiveToolsBySession = useCallback>>( - (updater) => dispatch({ type: 'update-map', key: 'liveToolsBySession', updater }), - [dispatch], - ); - const setPermissionBySession = useCallback>( - (updater) => dispatch({ type: 'update-map', key: 'permissionBySession', updater }), - [dispatch], - ); - const setSessionEventHealthBySession = - useCallback>>( - (updater) => dispatch({ type: 'update-map', key: 'sessionEventHealthBySession', updater }), - [dispatch], - ); - const setPendingPermissionModeBySession = useCallback>>( - (updater) => dispatch({ type: 'update-map', key: 'pendingPermissionModeBySession', updater }), - [dispatch], - ); - const setPendingSessionModelBySession = useCallback>>( - (updater) => dispatch({ type: 'update-map', key: 'pendingSessionModelBySession', updater }), - [dispatch], - ); - const clearSessionUiState = useCallback((sessionId: string) => { - dispatch({ type: 'clear-session', sessionId }); - }, [dispatch]); + onChange(next); + } + + function updateMap( + key: K, + updater: (current: AppShellSessionUiState[K]) => AppShellSessionUiState[K], + ): void { + const nextMap = updater(currentState[key]); + const latestState = currentState; + if (nextMap === latestState[key]) return; + replaceState({ ...latestState, [key]: nextMap }); + } + + function createMapSetter(key: K): StateUpdater { + return (updater) => updateMap(key, updater); + } return { - state, + getState: () => currentState, streamingBySessionRef, sessionEventHealthBySessionRef, - setMessageLoadErrorBySession, - setMessageRetryPendingBySession, - setStopPendingBySession, - setStreamingBySession, - setThinkingBySession, - setThinkingTruncatedBySession, - setLiveToolsBySession, - setPermissionBySession, - setSessionEventHealthBySession, - setPendingPermissionModeBySession, - setPendingSessionModelBySession, - clearSessionUiState, + setMessageLoadErrorBySession: createMapSetter('messageLoadErrorBySession'), + setMessageRetryPendingBySession: createMapSetter('messageRetryPendingBySession'), + setStopPendingBySession: createMapSetter('stopPendingBySession'), + setStreamingBySession: createMapSetter('streamingBySession'), + setThinkingBySession: createMapSetter('thinkingBySession'), + setThinkingTruncatedBySession: createMapSetter('thinkingTruncatedBySession'), + setLiveToolsBySession: createMapSetter('liveToolsBySession'), + setPermissionBySession: createMapSetter('permissionBySession'), + setSessionEventHealthBySession: createMapSetter('sessionEventHealthBySession'), + setPendingPermissionModeBySession: createMapSetter('pendingPermissionModeBySession'), + setPendingSessionModelBySession: createMapSetter('pendingSessionModelBySession'), + clearSessionUiState: (sessionId: string) => { + replaceState(clearAppShellSessionUiStateForSession(currentState, sessionId)); + }, + }; +} + +export function useAppShellSessionUiState() { + const [, forceRender] = useReducer((version: number) => version + 1, 0); + const controllerRef = useRef | null>(null); + + if (!controllerRef.current) { + controllerRef.current = createAppShellSessionUiStateController( + createInitialAppShellSessionUiState(), + () => forceRender(), + ); + } + + const controller = controllerRef.current; + + return { + state: controller.getState(), + streamingBySessionRef: controller.streamingBySessionRef, + sessionEventHealthBySessionRef: controller.sessionEventHealthBySessionRef, + setMessageLoadErrorBySession: controller.setMessageLoadErrorBySession, + setMessageRetryPendingBySession: controller.setMessageRetryPendingBySession, + setStopPendingBySession: controller.setStopPendingBySession, + setStreamingBySession: controller.setStreamingBySession, + setThinkingBySession: controller.setThinkingBySession, + setThinkingTruncatedBySession: controller.setThinkingTruncatedBySession, + setLiveToolsBySession: controller.setLiveToolsBySession, + setPermissionBySession: controller.setPermissionBySession, + setSessionEventHealthBySession: controller.setSessionEventHealthBySession, + setPendingPermissionModeBySession: controller.setPendingPermissionModeBySession, + setPendingSessionModelBySession: controller.setPendingSessionModelBySession, + clearSessionUiState: controller.clearSessionUiState, }; }