From 8f53429651b1e47eee52aa50dc19bdf175caec3a Mon Sep 17 00:00:00 2001 From: eagle Date: Sun, 19 Jul 2026 14:18:14 +0800 Subject: [PATCH] fix(desktop): avoid stale actions in memoized surfaces --- .../src/app/contrib/latest-actions.test.ts | 46 ++++++++++++++++ .../desktop/src/app/contrib/latest-actions.ts | 52 +++++++++++++++++++ apps/desktop/src/app/contrib/surfaces.tsx | 29 +++-------- 3 files changed, 104 insertions(+), 23 deletions(-) create mode 100644 apps/desktop/src/app/contrib/latest-actions.test.ts create mode 100644 apps/desktop/src/app/contrib/latest-actions.ts diff --git a/apps/desktop/src/app/contrib/latest-actions.test.ts b/apps/desktop/src/app/contrib/latest-actions.test.ts new file mode 100644 index 000000000000..963275265dac --- /dev/null +++ b/apps/desktop/src/app/contrib/latest-actions.test.ts @@ -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') + }) +}) diff --git a/apps/desktop/src/app/contrib/latest-actions.ts b/apps/desktop/src/app/contrib/latest-actions.ts new file mode 100644 index 000000000000..91750243a127 --- /dev/null +++ b/apps/desktop/src/app/contrib/latest-actions.ts @@ -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) + } +} diff --git a/apps/desktop/src/app/contrib/surfaces.tsx b/apps/desktop/src/app/contrib/surfaces.tsx index 80e6460c09e5..0d95bdf6e78f 100644 --- a/apps/desktop/src/app/contrib/surfaces.tsx +++ b/apps/desktop/src/app/contrib/surfaces.tsx @@ -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['currentView'] }) { - return + const latestActions = useMemo(() => latestSidebarActions(actions), [actions]) + + return }) 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 = ( )