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..41f99c157e --- /dev/null +++ b/apps/desktop/src/main/__tests__/app-shell-session-ui-state.test.ts @@ -0,0 +1,118 @@ +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 { + clearAppShellSessionUiStateForSession, + createAppShellSessionUiStateController, + 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 controller', () => { + it('clears one session from every per-session UI map without touching other sessions', () => { + const next = clearAppShellSessionUiStateForSession(seededState(), '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 controller = createAppShellSessionUiStateController(); + const state = controller.getState(); + controller.setMessageLoadErrorBySession((current) => current); + assert.equal(controller.getState(), state); + + 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/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-session-ui-state.ts b/apps/desktop/src/renderer/app-shell-session-ui-state.ts new file mode 100644 index 0000000000..f46ba0e084 --- /dev/null +++ b/apps/desktop/src/renderer/app-shell-session-ui-state.ts @@ -0,0 +1,166 @@ +import { 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; + +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 Object.fromEntries(SESSION_UI_MAP_KEYS.map((key) => [key, {}])) as unknown as AppShellSessionUiState; +} + +function omitSessionKey( + current: AppShellSessionUiState[K], + sessionId: string, +): AppShellSessionUiState[K] { + if (!(sessionId in current)) return current; + const next = { ...current }; + delete (next as Record)[sessionId]; + return next as AppShellSessionUiState[K]; +} + +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 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; + 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 { + getState: () => currentState, + streamingBySessionRef, + sessionEventHealthBySessionRef, + 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, + }; +} 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 {