fix(desktop): avoid stale actions in memoized surfaces

This commit is contained in:
eagle 2026-07-19 14:18:14 +08:00 committed by Teknium
parent 5baf174781
commit 8f53429651
3 changed files with 104 additions and 23 deletions

View file

@ -0,0 +1,46 @@
import { describe, expect, it, vi } from 'vitest'
import { latestChatActions } from './latest-actions'
import type { ChatActions } from './types'
function makeActions(onSubmit: ChatActions['onSubmit']): ChatActions {
return {
onAddContextRef: vi.fn(),
onAddUrl: vi.fn(),
onAttachDroppedItems: vi.fn(),
onAttachImageBlob: vi.fn(),
onBranchInNewChat: vi.fn(),
onCancel: vi.fn(),
onDeleteSelectedSession: vi.fn(),
onDismissError: vi.fn(),
onEdit: vi.fn(),
onPasteClipboardImage: vi.fn(),
onPickFiles: vi.fn(),
onPickFolders: vi.fn(),
onPickImages: vi.fn(),
onReload: vi.fn(),
onRemoveAttachment: vi.fn(),
onRestoreToMessage: vi.fn(),
onRetryResume: vi.fn(),
onSteer: vi.fn(),
onSubmit,
onThreadMessagesChange: vi.fn(),
onToggleSelectedPin: vi.fn(),
onTranscribeAudio: vi.fn()
}
}
describe('latestActions adapters', () => {
it('dereferences the latest chat handler from a stable actions object', async () => {
const staleSubmit = vi.fn(async () => false)
const latestSubmit = vi.fn(async () => true)
const actions = makeActions(staleSubmit)
const adapted = latestChatActions(actions)
actions.onSubmit = latestSubmit
await expect(adapted.onSubmit('continue in selected session')).resolves.toBe(true)
expect(staleSubmit).not.toHaveBeenCalled()
expect(latestSubmit).toHaveBeenCalledWith('continue in selected session')
})
})

View file

@ -0,0 +1,52 @@
import type { ChatActions, SidebarActions } from './types'
/**
* Surfaces receive one stable `actions` object whose fields are mutated by the
* wiring controller each render. If a memoized surface passes `actions.foo`
* directly, the child keeps the function from the surface's last render and can
* submit/click against a stale session closure. These adapters keep a stable
* wrapper but dereference the latest field at call time.
*/
export function latestChatActions(actions: ChatActions): ChatActions {
return {
onAddContextRef: (...args) => actions.onAddContextRef(...args),
onAddUrl: (...args) => actions.onAddUrl(...args),
onAttachDroppedItems: (...args) => actions.onAttachDroppedItems(...args),
onAttachImageBlob: (...args) => actions.onAttachImageBlob(...args),
onBranchInNewChat: (...args) => actions.onBranchInNewChat(...args),
onCancel: (...args) => actions.onCancel(...args),
onDeleteSelectedSession: (...args) => actions.onDeleteSelectedSession(...args),
onDismissError: (...args) => actions.onDismissError?.(...args),
onEdit: (...args) => actions.onEdit(...args),
onPasteClipboardImage: (...args) => actions.onPasteClipboardImage(...args),
onPickFiles: (...args) => actions.onPickFiles(...args),
onPickFolders: (...args) => actions.onPickFolders(...args),
onPickImages: (...args) => actions.onPickImages(...args),
onReload: (...args) => actions.onReload(...args),
onRemoveAttachment: (...args) => actions.onRemoveAttachment(...args),
onRestoreToMessage: (...args) => actions.onRestoreToMessage?.(...args) ?? Promise.resolve(),
onRetryResume: (...args) => actions.onRetryResume(...args),
onSteer: (...args) => actions.onSteer(...args),
onSubmit: (...args) => actions.onSubmit(...args),
onThreadMessagesChange: (...args) => actions.onThreadMessagesChange(...args),
onToggleSelectedPin: (...args) => actions.onToggleSelectedPin(...args),
onTranscribeAudio: (...args) => actions.onTranscribeAudio?.(...args) ?? Promise.resolve('')
}
}
export function latestSidebarActions(actions: SidebarActions): SidebarActions {
return {
onArchiveSession: (...args) => actions.onArchiveSession(...args),
onBranchSession: (...args) => actions.onBranchSession(...args),
onDeleteSession: (...args) => actions.onDeleteSession(...args),
onLoadMoreMessaging: (...args) => actions.onLoadMoreMessaging?.(...args),
onLoadMoreProfileSessions: (...args) => actions.onLoadMoreProfileSessions?.(...args),
onLoadMoreSessions: (...args) => actions.onLoadMoreSessions(...args),
onManageCronJob: (...args) => actions.onManageCronJob(...args),
onNavigate: (...args) => actions.onNavigate(...args),
onNewSessionInWorkspace: (...args) => actions.onNewSessionInWorkspace(...args),
onNewSessionSplit: (...args) => actions.onNewSessionSplit(...args),
onResumeSession: (...args) => actions.onResumeSession(...args),
onTriggerCronJob: (...args) => actions.onTriggerCronJob(...args)
}
}

View file

@ -25,6 +25,7 @@ import { useStatusbarItems } from '../shell/hooks/use-statusbar-items'
import { ModelMenuPanel } from '../shell/model-menu-panel'
import { StatusbarControls } from '../shell/statusbar-controls'
import { latestChatActions, latestSidebarActions } from './latest-actions'
import { setStatusbarItemGroup, useStatusbarContributions } from './panes'
import type { SidebarActions, WiringActions } from './types'
@ -48,7 +49,9 @@ export const SidebarSurface = memo(function SidebarSurface({
actions: SidebarActions
currentView: ComponentProps<typeof ChatSidebar>['currentView']
}) {
return <ChatSidebar currentView={currentView} {...actions} />
const latestActions = useMemo(() => latestSidebarActions(actions), [actions])
return <ChatSidebar currentView={currentView} {...latestActions} />
})
export const TerminalSurface = memo(function TerminalSurface() {
@ -137,33 +140,13 @@ export const ChatRoutesSurface = memo(function ChatRoutesSurface({
[actions, activeGatewayProfile, gateway, gatewayState]
)
const chatActions = useMemo(() => latestChatActions(actions), [actions])
const chatView = (
<ChatView
gateway={gateway}
maxVoiceRecordingSeconds={maxVoiceRecordingSeconds}
modelMenuContent={modelMenuContent}
onAddContextRef={actions.onAddContextRef}
onAddUrl={actions.onAddUrl}
onAttachDroppedItems={actions.onAttachDroppedItems}
onAttachImageBlob={actions.onAttachImageBlob}
onBranchInNewChat={actions.onBranchInNewChat}
onCancel={actions.onCancel}
onDeleteSelectedSession={actions.onDeleteSelectedSession}
onDismissError={actions.onDismissError}
onEdit={actions.onEdit}
onPasteClipboardImage={actions.onPasteClipboardImage}
onPickFiles={actions.onPickFiles}
onPickFolders={actions.onPickFolders}
onPickImages={actions.onPickImages}
onReload={actions.onReload}
onRemoveAttachment={actions.onRemoveAttachment}
onRestoreToMessage={actions.onRestoreToMessage}
onRetryResume={actions.onRetryResume}
onSteer={actions.onSteer}
onSubmit={actions.onSubmit}
onThreadMessagesChange={actions.onThreadMessagesChange}
onToggleSelectedPin={actions.onToggleSelectedPin}
onTranscribeAudio={actions.onTranscribeAudio}
{...chatActions}
/>
)