From 44d882944e31f5b891fe781c8dc66d6246c66918 Mon Sep 17 00:00:00 2001 From: tmoonlight Date: Mon, 19 Oct 2026 15:21:48 +0800 Subject: [PATCH] Stabilize ChatView and App callbacks to stop closure chains Session switches and status updates rebuilt ChatView/App callbacks with different dependencies on alternating renders, so V8 kept every render's scope alive through the shared closure context. Callbacks that depend on changing state or props now use useStableCallback (ChatView 100, App 34). - Keep useCallback where identity must follow the session: ChatView's async submit path calls draft/history/send/upload/preview helpers after an await, and ChatView does not remount per session (10 kept, reasons recorded in the test). App keeps the two functions handed to hooks. - Effects that relied on callback identity to rerun now list the values they follow (focus, queue drain, tail-follow, change dock, browser tab restore, preview auto-refresh). - Replace the Sidebar-only guard with stableCallbacksArchitecture.test.js covering Sidebar, ChatView and App; update source-anchor tests. Measured with an isolated daemon and headless Edge (160 alternating session switches / unread toggles): ChatView scopes retained 262 -> 17, App 176 -> none. --- CLAUDE.md | 2 +- web/src/App.jsx | 144 +++--- web/src/components/ChatView.jsx | 453 ++++++++---------- .../chatFileDropFeedbackArchitecture.test.js | 4 +- .../composerEditabilityArchitecture.test.js | 2 +- ...omposerSessionControlsArchitecture.test.js | 8 +- web/src/lib/expertPrefill.test.js | 3 +- web/src/lib/goalStatusBarArchitecture.test.js | 2 +- web/src/lib/modelReasoningSync.test.js | 2 +- .../lib/newSessionFirstUserMessage.test.js | 4 +- web/src/lib/pastedTextArchitecture.test.js | 14 +- .../lib/previewWorkbenchArchitecture.test.js | 4 +- web/src/lib/runTests.js | 2 +- ...onCreationActionGuardsArchitecture.test.js | 2 +- web/src/lib/sessionHeaderContextMenu.test.js | 2 +- ...sidebarStableCallbacksArchitecture.test.js | 78 --- .../singleWriterOwnershipArchitecture.test.js | 2 +- .../lib/stableCallbacksArchitecture.test.js | 121 +++++ .../lib/turnInterruptionArchitecture.test.js | 2 +- 19 files changed, 425 insertions(+), 426 deletions(-) delete mode 100644 web/src/lib/sidebarStableCallbacksArchitecture.test.js create mode 100644 web/src/lib/stableCallbacksArchitecture.test.js diff --git a/CLAUDE.md b/CLAUDE.md index 2a263b2bf..eecc769e4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -726,7 +726,7 @@ JS↔C++ bridge(同进程,webview `bind`,无 HTTP): 用户反馈:挂机一两天后解锁屏幕,Desktop 停在 WebView2 的「内存不足」错误页,只能手动刷新。本机现场:主 WebView 渲染进程 10 小时涨到 3.3 GB(上限约 4 GB),CPU 几乎为 0。CDP 堆快照比对(无头 Edge 连同一 daemon,隔几分钟两次快照,看新增且 GC 后仍存活的对象到 GC root 的路径)定位到: -- **主因 —— 侧栏空闲渲染 + 闭包链。** 侧栏 5 秒刷新即使数据没变也把新的 Map / Set / 数组写回状态,每轮渲染好几次;`Sidebar` 里几十个依赖各异的 `useCallback` 在不同渲染里交替重建,V8 让同一次渲染的闭包共享一个作用域,旧回调就把旧渲染的整个作用域(含当时的会话列表)留住,一份接一份连成链 —— 快照里 27 分钟存活了 450 份侧栏作用域,空闲首页每次渲染约留 70 KB。修法是刷新路径上的状态一律「内容没变就不调用 setState」(`lib/structuralState.js`:`useStructuralState` 的 setter 自持最新值 ref;带 ref 同步的 setter 经 `commitIfChanged(ref, setState, updater)`,前提是该状态的每个写入方都同步 ref),空闲时不渲染,链条不再增长。**只在 updater 里返回旧值不够**:React 18 对结果与旧值相同的更新会提前放弃渲染,但 update 对象(连同 updater 闭包捕获的数据)仍挂在 hook 队列里,直到该组件下次渲染才清掉 —— 第一版修复就这样把泄漏换成了队列增长(打开会话的页面约 0.24 MB/分钟,三方 A/B 才看出来)。**别把这些状态改回普通 `useState`、在 setter 里无条件 `new Map(prev)` 或写成 `changed ? next : prev`**;`sidebarWorkspaceRefresh.test.js` 的空闲刷新零写入用例与声明检查守着。活跃使用时(会话状态、标题真有变化)侧栏照样渲染,所以链条本身也断掉了:依赖会变的状态或 props 的 13 个回调改用 `lib/useStableCallback.js`(包装函数只捕获 ref,身份永不变,ref 在 insertion effect 里更新到最新实现),剩下的 `useCallback` 只依赖 setter / ref / 其它稳定回调。**侧栏里不要再写 `useCallback(fn, [会变的状态或 props])`**,`sidebarStableCallbacksArchitecture.test.js` 会解析源码并列出违规的回调名;`useStableCallback` 不能用于渲染期间调用的函数,也不能用于需要靠身份变化触发 effect 重跑的场合。对照实测(隔离 daemon,交替「改工作区名 + 切换未读」6 分钟 60 次):改造前侧栏作用域存活 29 份并持续接链,改造后不再接链。**同类问题在 `ChatView` 与 `App` 里还在**:交替「切换会话 + 切换未读」8 分钟 160 次,ChatView 作用域存活 257 份、App 178 份,GC 后堆 +3.4 MB(约 21 KB/次),侧栏只有个位数 —— 活跃使用时的剩余增长来自这两处。 +- **主因 —— 侧栏空闲渲染 + 闭包链。** 侧栏 5 秒刷新即使数据没变也把新的 Map / Set / 数组写回状态,每轮渲染好几次;`Sidebar` 里几十个依赖各异的 `useCallback` 在不同渲染里交替重建,V8 让同一次渲染的闭包共享一个作用域,旧回调就把旧渲染的整个作用域(含当时的会话列表)留住,一份接一份连成链 —— 快照里 27 分钟存活了 450 份侧栏作用域,空闲首页每次渲染约留 70 KB。修法是刷新路径上的状态一律「内容没变就不调用 setState」(`lib/structuralState.js`:`useStructuralState` 的 setter 自持最新值 ref;带 ref 同步的 setter 经 `commitIfChanged(ref, setState, updater)`,前提是该状态的每个写入方都同步 ref),空闲时不渲染,链条不再增长。**只在 updater 里返回旧值不够**:React 18 对结果与旧值相同的更新会提前放弃渲染,但 update 对象(连同 updater 闭包捕获的数据)仍挂在 hook 队列里,直到该组件下次渲染才清掉 —— 第一版修复就这样把泄漏换成了队列增长(打开会话的页面约 0.24 MB/分钟,三方 A/B 才看出来)。**别把这些状态改回普通 `useState`、在 setter 里无条件 `new Map(prev)` 或写成 `changed ? next : prev`**;`sidebarWorkspaceRefresh.test.js` 的空闲刷新零写入用例与声明检查守着。活跃使用时(会话状态、标题真有变化)侧栏照样渲染,所以链条本身也断掉了:依赖会变的状态或 props 的 13 个回调改用 `lib/useStableCallback.js`(包装函数只捕获 ref,身份永不变,ref 在 insertion effect 里更新到最新实现),剩下的 `useCallback` 只依赖 setter / ref / 其它稳定回调。对照实测(隔离 daemon,交替「改工作区名 + 切换未读」6 分钟 60 次):改造前侧栏作用域存活 29 份并持续接链,改造后不再接链。`ChatView`(100 个)与 `App`(34 个)按同一规则改造 —— 交替「切换会话 + 切换未读」8 分钟 160 次,改造前 ChatView 作用域存活 262 份、App 176 份;改造后 ChatView 17 份、App 不再接链,操作翻倍到 320 次也只到 21 份(剩下的来自下面保留的 10 个回调)。此时快照比对里的新增存活主要是 V8 JIT 代码与当前界面树,对象总数反而下降。**在 Sidebar / ChatView / App 里不要再写 `useCallback(fn, [会变的状态或 props])`**,`stableCallbacksArchitecture.test.js` 解析三个组件并列出违规回调。`useStableCallback` 的三条边界,改造前用 Babel 逐个核对过:(1) 不能用于渲染期间调用、或交给 React hook 在渲染里调用的函数(`useSyncExternalStore` 的 subscribe / getSnapshot、App 交给 `usePreviewPresentation` / `useRef` 的两个);(2) 依赖数组里列了被改造回调的 effect,原先靠回调身份变化隐式重跑,要把真正要跟随的值显式补进去(ChatView 的聚焦 effect 补 `questionRequest`、排队出队 effect 补 `sessionRuntimeUnavailable` 等,注意补的变量必须声明在 effect 之前,否则 TDZ);(3) **ChatView 不随会话重新挂载**,`submit` 等异步流程在 `await` 之后调用的回调若绑定会话(清草稿、写输入历史、发送、上传、回填输入框、打开预览),改成稳定引用会在用户中途切会话时作用到新会话上,所以这 10 个保留 `useCallback` 并在测试的 `KEEP` 里写明原因 —— 它们只在切会话 / 工作区等用户操作时重建,链长随操作次数而不是时间增长。 - **webview 0.12.0 桥接泄漏。** 库注入的 `_promises[id]` 只写不删,每次 `aceDesktop_*` 调用永久留一条。`web_host.cpp::kBridgeReplyCleanupScript` 在库脚本之后接管同一套 `call` / `onReply`(消息格式不变),回包即删;升级 webview 库时确认上游是否已修。托盘菜单推送(`desktopTrayMenu.js`)相同内容 60 秒内不重复过桥。 - **兜底 —— 渲染进程退出自动重载。** 主 WebView 此前没接 `ProcessFailed`。现在 `web_host_browser_win.hpp::install_renderer_recovery_handler` 对 RENDER_PROCESS_EXITED 投递 `Reload()`,无响应 / 浏览器进程退出只记日志;10 分钟内最多 3 次,超过保留错误页防崩溃循环(`renderer_recovery.{hpp,cpp}` 纯逻辑)。端到端冒烟 `tests/desktop/renderer_recovery_smoke.py`(`WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS=--js-flags=--max-old-space-size=64` 让页面几秒内 OOM,确认自动重载且桥接接管生效);macOS / Linux 尚未接对应事件。 diff --git a/web/src/App.jsx b/web/src/App.jsx index c2fe8a139..c99aea817 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -10,6 +10,7 @@ import { DesktopPetsPromo } from './components/DesktopPetsPromo.jsx'; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { api, apiConnectionScope, ApiError, createApi } from './lib/api.js'; +import { useStableCallback } from './lib/useStableCallback.js'; import { computerUseSettingsStore } from './lib/computerUseSettings.js'; import { observeComputerUsePointerTheme } from './lib/computerUsePointerTheme.js'; import { useTheme } from './theme.jsx'; @@ -368,21 +369,21 @@ export function App() { if (!id) return; setRecentExpertIds((current) => recordRecentExpert(current, id)); }, [setRecentExpertIds]); - const loadHomeComposerDraft = useCallback((workspaceHash, client = api) => ({ + const loadHomeComposerDraft = useStableCallback((workspaceHash, client = api) => ({ draft: homeDraftStore.read(client, workspaceHash), ready: homeDraftStore.load(client, workspaceHash), - }), [homeDraftStore]); - const updateHomeComposerDraft = useCallback((workspaceHash, text, client = api) => { + })); + const updateHomeComposerDraft = useStableCallback((workspaceHash, text, client = api) => { homeDraftStore.update(client, workspaceHash, text); - }, [homeDraftStore]); - const acceptHomeComposerDraft = useCallback((workspaceHash, submittedText, client = api) => { + }); + const acceptHomeComposerDraft = useStableCallback((workspaceHash, submittedText, client = api) => { void homeDraftStore.accept(client, workspaceHash, submittedText); - }, [homeDraftStore]); + }); // 首页粘贴的文件块上传完成时用户已离开首页:在 store 里的最新草稿上回填, // 不能用 ChatView 手里的旧快照覆盖期间的编辑。 - const patchHomeComposerDraft = useCallback((workspaceHash, updater, client = api) => { + const patchHomeComposerDraft = useStableCallback((workspaceHash, updater, client = api) => { homeDraftStore.patch(client, workspaceHash, updater); - }, [homeDraftStore]); + }); useEffect(() => { const flush = () => { void homeDraftStore.flush(); }; const onVisibility = () => { if (document.visibilityState === 'hidden') flush(); }; @@ -1015,7 +1016,7 @@ export function App() { }; }, [authState]); - const acknowledgeConfigRecovery = useCallback(async () => { + const acknowledgeConfigRecovery = useStableCallback(async () => { if (configRecoveryAckBusy) return; setConfigRecoveryAckBusy(true); try { @@ -1028,7 +1029,7 @@ export function App() { setConfigRecoveryDialogOpen(false); setConfigRecoveryNotice(null); } - }, [configRecoveryAckBusy]); + }); const pollUpdateJob = useCallback((jobId) => { if (!jobId) return; @@ -1236,19 +1237,19 @@ export function App() { // Ctrl+` toggle 控制台(终端聚焦时 xterm 的 customKeyEventHandler 放行该 // 组合,事件照常冒泡到 window)。后端 console 不可用时快捷键惰化。 const consoleAvailable = !!health?.console?.available; - const toggleConsoleDock = useCallback(() => { + const toggleConsoleDock = useStableCallback(() => { if (!consoleAvailable) return; setConsoleDock((prev) => ({ ...prev, open: !prev.open })); - }, [consoleAvailable, setConsoleDock]); - const setConsoleDockHeight = useCallback((next) => { + }); + const setConsoleDockHeight = useStableCallback((next) => { setConsoleDock((prev) => ({ ...prev, height: clampDockHeight(next, window.innerHeight), })); - }, [setConsoleDock]); - const setConsoleDockOpen = useCallback((open) => { + }); + const setConsoleDockOpen = useStableCallback((open) => { setConsoleDock((prev) => ({ ...prev, open: !!open })); - }, [setConsoleDock]); + }); const handleSelectSession = useCallback(async (session) => { if (!session?.id) return; @@ -1508,7 +1509,7 @@ export function App() { await probe(); }, [probe]); - const toggleSidePanel = useCallback(() => { + const toggleSidePanel = useStableCallback(() => { if (!responsiveSidebars.canDockRight || responsiveSidebars.popup === 'right') { responsiveSidebars.togglePopup('right'); return; @@ -1518,21 +1519,21 @@ export function App() { return runAfterFileApproval(approval, () => { setUiPrefs((prev) => toggleRightPanel(prev, previewPanelVisible)); }); - }, [previewPanelVisible, requestPreviewLeave, setUiPrefs, uiPrefs, responsiveSidebars]); + }); - const revealPreviewPanel = useCallback(() => { + const revealPreviewPanel = useStableCallback(() => { if (responsiveSidebars.requestRightPopup(true)) return; setUiPrefs(revealRightPanelDetails); - }, [setUiPrefs, responsiveSidebars.requestRightPopup]); + }); - const toggleSidePanelList = useCallback(() => { + const toggleSidePanelList = useStableCallback(() => { setUiPrefs((prev) => ({ ...prev, sidePanelListCollapsed: !effectiveSidePanelListCollapsed(prev), })); - }, [setUiPrefs]); + }); - const revealSidePanelList = useCallback(() => { + const revealSidePanelList = useStableCallback(() => { if (responsiveSidebars.requestRightPopup()) return; setUiPrefs((prev) => { if (!prev.sidePanelCollapsed && !effectiveSidePanelListCollapsed(prev)) return prev; @@ -1542,11 +1543,11 @@ export function App() { sidePanelListCollapsed: false, }; }); - }, [setUiPrefs, responsiveSidebars.requestRightPopup]); + }); // 最大化 / 还原中间预览面板。沿用旧字段名保存偏好,但 UI 控件已迁到预览面板。 // 最大化时强制确保右侧 SidePanel 未折叠,符合"右侧文件栏仍然可用"的行为。 - const toggleSidePanelMaximized = useCallback(() => { + const toggleSidePanelMaximized = useStableCallback(() => { setUiPrefs((prev) => { const nextMax = !prev.sidePanelMaximized; return { @@ -1555,9 +1556,9 @@ export function App() { sidePanelCollapsed: nextMax ? false : prev.sidePanelCollapsed, }; }); - }, [setUiPrefs]); + }); - const toggleProjectSidebar = useCallback(() => { + const toggleProjectSidebar = useStableCallback(() => { if (!responsiveSidebars.canDockLeft || responsiveSidebars.popup === 'left') { responsiveSidebars.togglePopup('left'); return; @@ -1567,9 +1568,9 @@ export function App() { ...prev, sidebarCollapsed: !prev.sidebarCollapsed, })); - }, [setUiPrefs, responsiveSidebars]); + }); - const openUpdateDialog = useCallback(() => { + const openUpdateDialog = useStableCallback(() => { if (!updateJobIsActive(updateJob) && updateJob?.target_version && updateStatus?.latest_version @@ -1577,9 +1578,9 @@ export function App() { setUpdateJob(null); } setUpdateDialogOpen(true); - }, [updateJob, updateStatus]); + }); - const checkForUpdates = useCallback(async () => { + const checkForUpdates = useStableCallback(async () => { if (updateChecking) return; setUpdateChecking(true); try { @@ -1612,7 +1613,7 @@ export function App() { } finally { setUpdateChecking(false); } - }, [updateChecking, updateJob]); + }); const showAboutAceCode = useCallback(async () => { const result = await showDesktopAboutDialog(); @@ -1630,7 +1631,7 @@ export function App() { }); }, []); - const chooseDesktopCloseAction = useCallback(async (behavior) => { + const chooseDesktopCloseAction = useStableCallback(async (behavior) => { if (desktopCloseBusy) return; setDesktopCloseBusy(true); const result = await performDesktopCloseChoice({ @@ -1653,9 +1654,9 @@ export function App() { setDesktopCloseDialogOpen(false); setRememberDesktopCloseChoice(false); setDesktopCloseBusy(false); - }, [desktopCloseBusy, rememberDesktopCloseChoice]); + }); - const startUpdate = useCallback(async () => { + const startUpdate = useStableCallback(async () => { if (!updateStatus?.update_available || updateStarting || updateJobIsActive(updateJob)) return; setUpdateStarting(true); try { @@ -1675,9 +1676,9 @@ export function App() { } finally { setUpdateStarting(false); } - }, [pollUpdateJob, updateJob, updateStarting, updateStatus]); + }); - const cancelUpdate = useCallback(async () => { + const cancelUpdate = useStableCallback(async () => { if (!updateJob?.job_id || !updateJobIsActive(updateJob) || updateJob?.can_cancel === false @@ -1698,9 +1699,9 @@ export function App() { } finally { setUpdateCancelling(false); } - }, [pollUpdateJob, updateCancelling, updateJob]); + }); - const restartAfterUpdate = useCallback(async () => { + const restartAfterUpdate = useStableCallback(async () => { if (updateRestarting || updateJob?.state !== 'succeeded' || !updateJob?.restart_required) return; @@ -1711,9 +1712,9 @@ export function App() { setUpdateRestarting(false); toast({ kind: 'err', text: '自动重启失败:' + (e?.message || '未知错误') }); } - }, [updateJob, updateRestarting]); + }); - const openHomeForWorkspace = useCallback((workspace = null, { composerFeedback = false } = {}) => { + const openHomeForWorkspace = useStableCallback((workspace = null, { composerFeedback = false } = {}) => { const current = activeRefRef.current || {}; if (composerFeedback && view === 'single' && !sessionJumpId(current) && !current?.loop && !current?.expertComponents) { @@ -1723,13 +1724,13 @@ export function App() { const next = homeRefFromWorkspace(target, activeRefRef.current, health); void refreshWorkspaceGitInfo(createApi(next), next).catch(() => {}); navigateToRef(next); - }, [health, navigateToRef, view]); + }); const openLoopPage = useCallback(() => { navigateToRef({ loop: true }); }, [navigateToRef]); - const openExpertComponents = useCallback(() => { + const openExpertComponents = useStableCallback(() => { const current = activeRefRef.current || {}; const base = homeRefFromWorkspace(current, current, health); navigateToRef({ @@ -1737,9 +1738,9 @@ export function App() { home: false, expertComponents: true, }); - }, [health, navigateToRef]); + }); - const startConversationalExpertCreation = useCallback(() => { + const startConversationalExpertCreation = useStableCallback(() => { const current = activeRefRef.current || {}; const base = homeRefFromWorkspace(current, current, health); void refreshWorkspaceGitInfo(createApi(base), base).catch(() => {}); @@ -1748,21 +1749,21 @@ export function App() { initialDraftText: expertManagerCreationDraft(), }); return true; - }, [health, navigateToRef]); + }); - const startAiThemeCreation = useCallback(() => { + const startAiThemeCreation = useStableCallback(() => { const next = aiThemeCreationRef(activeRefRef.current || {}, health); setShowSettings(false); navigateToRef(next); - }, [health, navigateToRef]); + }); - const startScheduledTaskCreation = useCallback(() => { + const startScheduledTaskCreation = useStableCallback(() => { const next = scheduledTaskCreationRef(activeRefRef.current || {}, health); void refreshWorkspaceGitInfo(createApi(next), next).catch(() => {}); navigateToRef(next); - }, [health, navigateToRef]); + }); - const dispatchExpertToNewTask = useCallback((expert, prompt = expert?.quick_prompts?.[0] || '') => { + const dispatchExpertToNewTask = useStableCallback((expert, prompt = expert?.quick_prompts?.[0] || '') => { const expertId = String(expert?.id || ''); if (!expertId) return false; const current = activeRefRef.current || {}; @@ -1777,7 +1778,7 @@ export function App() { }); rememberRecentExpert(expert); return true; - }, [health, navigateToRef, rememberRecentExpert]); + }); const consumeInitialDraftText = useCallback(() => { replaceActiveRef((current) => { @@ -1805,7 +1806,7 @@ export function App() { }); }, [replaceActiveRef]); - const replaceHomeWorkspace = useCallback((workspace) => { + const replaceHomeWorkspace = useStableCallback((workspace) => { replaceActiveRef((current) => { const next = homeRefFromWorkspace(workspace, current, health); if (['ai-theme', 'scheduled-task'].includes(current?.composerDraftScope)) { @@ -1814,7 +1815,7 @@ export function App() { void refreshWorkspaceGitInfo(createApi(next), next).catch(() => {}); return next; }); - }, [health, replaceActiveRef]); + }); const abortGuidedTour = useCallback(() => { setGuidedTourRun(false); @@ -1839,7 +1840,7 @@ export function App() { if (openModels) openSettingsSection('models'); }, [openSettingsSection]); - const replayGuidedTour = useCallback(async () => { + const replayGuidedTour = useStableCallback(async () => { setShowSettings(false); setSearchOpen(false); setGuidedTourRun(false); @@ -1856,7 +1857,7 @@ export function App() { } finally { setGuidedTourPreparing(true); } - }, [health, navigateToRef]); + }); useEffect(() => { if (guidedTourForced) return; @@ -1939,7 +1940,7 @@ export function App() { if (guidedTourPreparing || guidedTourRun) abortGuidedTour(); }, [abortGuidedTour, guidedTourHasActiveSession, guidedTourPreparing, guidedTourRun]); - const createDesktopTraySession = useCallback(async () => { + const createDesktopTraySession = useStableCallback(async () => { if (!await requestPreviewLeave()) return; try { const next = await createNewSessionForActiveWorkspace(api, activeRefRef.current, health); @@ -1963,7 +1964,7 @@ export function App() { } catch (e) { toast({ kind: 'err', text: '新建会话失败:' + (e.message || '') }); } - }, [health, navigateToRef, requestPreviewLeave]); + }); const handleSubagentTasksChange = useCallback((info) => { const parentId = info?.parentId || ''; @@ -2053,7 +2054,7 @@ export function App() { setSingleLayout((prev) => width > 0 && prev.rightPanelPopup !== width ? { ...prev, rightPanelPopup: width } : prev); }, [setSingleLayout]); - const setSidebarWidth = useCallback((nextWidth, shellWidth = 0) => { + const setSidebarWidth = useStableCallback((nextWidth, shellWidth = 0) => { const sidePanelVisible = !sidePanelNavigationCollapsed; setSingleLayout((prev) => { const sidebar = normalizeSidebarWidth(nextWidth, { @@ -2065,13 +2066,9 @@ export function App() { }); return sidebar === prev.sidebar ? prev : { ...prev, sidebar }; }); - }, [ - previewPanelVisible, - setSingleLayout, - sidePanelNavigationCollapsed, - ]); + }); - const setSidePanelWidth = useCallback((nextWidth, contentWidth = 0) => { + const setSidePanelWidth = useStableCallback((nextWidth, contentWidth = 0) => { setSingleLayout((prev) => { const sidePanel = normalizeSidePanelWidth(nextWidth, { contentWidth, @@ -2081,9 +2078,9 @@ export function App() { }); return sidePanel === prev.sidePanel ? prev : { ...prev, sidePanel }; }); - }, [previewPanelVisible, setSingleLayout, sidePanelMaximized]); + }); - const setPreviewPanelWidth = useCallback((nextWidth, contentWidth = 0) => { + const setPreviewPanelWidth = useStableCallback((nextWidth, contentWidth = 0) => { const sidePanelVisible = !sidePanelNavigationCollapsed; setSingleLayout((prev) => { const previewPanel = normalizePreviewPanelWidth(nextWidth, { @@ -2097,10 +2094,7 @@ export function App() { } return { ...prev, previewPanel, previewPanelUserSized: true }; }); - }, [ - setSingleLayout, - sidePanelNavigationCollapsed, - ]); + }); const setSubagentPanelWidth = useCallback((nextWidth, contentWidth = 0) => { setSingleLayout((prev) => { @@ -2109,7 +2103,7 @@ export function App() { }); }, [setSingleLayout]); - const startSidebarResize = useCallback((event) => { + const startSidebarResize = useStableCallback((event) => { if (view !== 'single') return; if (event.button != null && event.button !== 0) return; if (sidebarResizeActiveRef.current) return; @@ -2139,9 +2133,9 @@ export function App() { window.addEventListener('pointercancel', onStop, { once: true }); window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onStop, { once: true }); - }, [setSidebarWidth, singleLayout.sidebar, view]); + }); - const onSidebarHandleKeyDown = useCallback((event) => { + const onSidebarHandleKeyDown = useStableCallback((event) => { if (view !== 'single') return; const step = event.shiftKey ? 32 : 12; if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { @@ -2150,7 +2144,7 @@ export function App() { const shellWidth = singleShellRef.current?.getBoundingClientRect().width || 0; setSidebarWidth(singleLayout.sidebar + delta, shellWidth); } - }, [setSidebarWidth, singleLayout.sidebar, view]); + }); const activeId = activeRef?.sessionId || activeRef?.id || ''; const openLoopRun = useCallback((run) => { diff --git a/web/src/components/ChatView.jsx b/web/src/components/ChatView.jsx index d09e22353..1bf363170 100644 --- a/web/src/components/ChatView.jsx +++ b/web/src/components/ChatView.jsx @@ -295,6 +295,7 @@ import { } from '../lib/editableFileDraft.js'; import { createUnsavedFileGuard, runAfterFileApproval } from '../lib/unsavedFileGuard.js'; import { sessionWorkbench } from '../lib/sessionWorkbench.js'; +import { useStableCallback } from '../lib/useStableCallback.js'; import { useWorkbenchState } from '../lib/useWorkbenchState.js'; import { ResponsiveSidebar } from './ResponsiveSidebar.jsx'; import { clampPopupWidth, popupWidthRange } from '../lib/responsiveSidebars.js'; @@ -899,7 +900,7 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses const [reviewRequest, setReviewRequest] = useWorkbenchState(workbenchOwner, 'reviewRequest', 0); const [fileLocateRequest, setFileLocateRequest] = useWorkbenchState(workbenchOwner, 'fileLocate', () => ({ path: '', token: 0 })); const [previewTabState, setPreviewTabState] = useWorkbenchState(workbenchOwner, 'previews', () => ({})); - const getPreviewTabState = useCallback(() => sessionWorkbench.get(workbenchOwner, 'previews', () => ({})), [workbenchOwner]); + const getPreviewTabState = useStableCallback(() => sessionWorkbench.get(workbenchOwner, 'previews', () => ({}))); const previewContextRef = useRef(null); const previewLeaveRequestRef = useRef(() => true); const [previewCloseConfirm, setPreviewCloseConfirm] = useState(null); @@ -996,9 +997,9 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses const transcriptSwarmMode = normalizeSwarmMode(transcript.swarmMode); const [composerSwarmChoice, setComposerSwarmChoice] = useState(null); const composerSwarmMode = effectiveSwarmMode(composerSwarmChoice, transcriptSwarmMode); - const changeComposerSwarmMode = useCallback((mode) => { + const changeComposerSwarmMode = useStableCallback((mode) => { setComposerSwarmChoice(reconcileSwarmChoice(normalizeSwarmMode(mode), transcriptSwarmMode)); - }, [transcriptSwarmMode]); + }); // 服务端确认(或别处切到同一模式)后,本地选择清空,重新跟随服务端。 useEffect(() => { setComposerSwarmChoice((choice) => reconcileSwarmChoice(choice, transcriptSwarmMode)); @@ -1276,16 +1277,16 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses writeDesktopHomeWorkspaceHash(workspaceHash).catch(() => {}); }, [setHomeWorkspaceSelection]); - const selectHomeWorkspace = useCallback((workspace) => { + const selectHomeWorkspace = useStableCallback((workspace) => { const selected = workspace?.noWorkspace ? noHomeWorkspaceOption() : workspace; const workspaceHash = String(selected?.hash || ''); setHomeWorkspaceHash(workspaceHash); persistHomeWorkspaceHash(workspaceHash); onHomeWorkspaceChange?.(selected); setProjectDropdownOpen(false); - }, [onHomeWorkspaceChange, persistHomeWorkspaceHash]); + }); - const handleProjectCreated = useCallback(async (workspace) => { + const handleProjectCreated = useStableCallback(async (workspace) => { const option = normalizeWorkspaceOption(workspace, homeWorkspaces.length); if (!option?.hash || !option?.cwd) { throw new Error('项目已创建,但返回的工作区信息不完整'); @@ -1306,9 +1307,9 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses ? `已创建项目:${directoryName}(目录名已自动转换)` : `已创建项目:${directoryName}`, }); - }, [homeWorkspaces.length, selectHomeWorkspace]); + }); - const handleOpenExistingDirectory = useCallback(async () => { + const handleOpenExistingDirectory = useStableCallback(async () => { setProjectDropdownOpen(false); try { const workspace = await pickExistingWorkspace({ api }); @@ -1329,7 +1330,7 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses } catch (error) { toast({ kind: 'err', text: `打开现有目录失败:${error?.message || ''}` }); } - }, [api, homeWorkspaces.length, selectHomeWorkspace]); + }); const selectedHomeWorkspace = useMemo(() => { return homeWorkspaceOptionForHash(homeWorkspaces, homeWorkspaceHash); @@ -1498,7 +1499,7 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses setComposerSwarmChoice(null); }, [clearComposerExtras]); - const createHomeComposerSession = useCallback(async (text, { + const createHomeComposerSession = useStableCallback(async (text, { createOptions = null, firstUserMessageText = '', firstUserMessageContent = null, @@ -1589,9 +1590,9 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses } finally { setHomeSubmitting(false); } - }, [api, experts, health, homeExpertId, homeModelName, homeReasoningEffort, homeSubmitting, reasoningSwitching, onSessionPromoted, permissionMode, ref, selectedHomeWorkspace, workbenchOwner]); + }); - const stageMediaFiles = useCallback((reservedFiles) => { + const stageMediaFiles = useStableCallback((reservedFiles) => { const stagedItems = []; for (const reserved of Array.from(reservedFiles || [])) { const { file, identity, localId } = reserved; @@ -1627,7 +1628,7 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses } } return stagedItems; - }, [handleComposerChange]); + }); const persistMediaFilesToSession = useCallback(async (targetSid, reservedFiles) => { const persistOne = async (reserved) => { @@ -1752,7 +1753,7 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses return results.filter(Boolean); }, [api, setComposerValue]); - const handleMediaFiles = useCallback((files) => { + const handleMediaFiles = useStableCallback((files) => { const reservedFiles = reserveUniqueComposerFiles(files); if (reservedFiles.length === 0) return; stageMediaFiles(reservedFiles); @@ -1761,12 +1762,7 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses .catch((e) => { toast({ kind: 'err', text: '附件上传失败:' + (e?.message || '') }); }); - }, [ - persistMediaFilesToSession, - reserveUniqueComposerFiles, - sid, - stageMediaFiles, - ]); + }); // ---- 粘贴的文本块(第 2 条反馈 f300) ---------------------------------------- // 达到折叠阈值的粘贴不进 Slate:小的成为内联块({type:'pasted_text'}),大的 @@ -1776,7 +1772,7 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses // 文件块的暂存资源:与 stageMediaFiles 同形,另带 paste 描述(与 File 上登记的一致)。 // replaceId 非空时替换该块(编辑 / 用剪贴板替换),否则追加在末尾。 - const stagePastedTextFiles = useCallback((reservedFiles, { deferUpload = false, replaceId = '' } = {}) => { + const stagePastedTextFiles = useStableCallback((reservedFiles, { deferUpload = false, replaceId = '' } = {}) => { const stagedItems = []; for (const reserved of Array.from(reservedFiles || [])) { const { file, identity, localId } = reserved || {}; @@ -1807,12 +1803,12 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses : appendPasteFileAttachments(current, stagedItems); handleComposerChange(composerValueRef.current, next); return stagedItems; - }, [handleComposerChange]); + }); // 首页粘贴的文件块上传到工作区草稿附件区(各段顺序上传)。完成时仍在同一个首页 // scope 就直接回填 id + store;已经离开首页则经 App 的草稿 store 回填最新草稿 // (reconcileHomeDraftUpload:块已删除 / 已发送时不写,不会复活)。 - const persistPastedTextToWorkspaceDraft = useCallback(async (hash, scope, reservedFiles) => { + const persistPastedTextToWorkspaceDraft = useStableCallback(async (hash, scope, reservedFiles) => { const uploaded = []; let firstError = null; for (const reserved of Array.from(reservedFiles || [])) { @@ -1874,11 +1870,11 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses } if (firstError) throw firstError; return uploaded; - }, [api, handleComposerChange, onHomeComposerDraftPatch]); + }); // 按当前目标上传粘贴的文件块:会话内 → 会话附件;首页 → 工作区草稿附件区; // AI 主题等临时首页(本来就不落盘)不上传,留给发送时的现有流程。 - const uploadPasteReservations = useCallback((reservedFiles) => { + const uploadPasteReservations = useStableCallback((reservedFiles) => { const targetSid = sidRef.current; let upload; if (targetSid) { @@ -1892,14 +1888,14 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses upload.catch((error) => { toast({ kind: 'err', text: '粘贴的文本上传失败:' + (error?.message || '') }); }); - }, [persistMediaFilesToSession, persistPastedTextToWorkspaceDraft]); + }); // RichComposer 的 onLargeTextPaste:达到折叠阈值的文本到这里分类。返回 false 表示 // 不折叠(照常进编辑器)。deferUpload:来自上箭头翻到的旧历史,文件块只在内存暂存、 // 显示「待上传」,开始编辑或发送时才上传(commitDeferredPastes)。 // legacyDraft:来自 restoreComposerDraft 的旧长文本折叠,落文件时登记 legacyFoldGuardRef, // 在块拿到 id 之前不覆盖服务端草稿里的旧全文。 - const handleLargeTextPaste = useCallback((text, { deferUpload = false, legacyDraft = false } = {}) => { + const handleLargeTextPaste = useStableCallback((text, { deferUpload = false, legacyDraft = false } = {}) => { const normalized = normalizePastedText(text); const current = composerContentRef.current || composerContentFromText(composerValueRef.current); const plan = planPastedTextInsertion(current, normalized); @@ -1924,11 +1920,11 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses } uploadPasteReservations(reservedFiles); return true; - }, [handleComposerChange, releaseDeferredPastes, reserveUniqueComposerFiles, stagePastedTextFiles, uploadPasteReservations]); + }); handleLargeTextPasteRef.current = handleLargeTextPaste; // 用户在翻出来的旧历史条目上开始编辑(或直接发送):上传仍在输入框里的暂存块。 - const commitDeferredPastes = useCallback(() => { + const commitDeferredPastes = useStableCallback(() => { const group = deferredHistoryPasteRef.current; if (!group) return false; deferredHistoryPasteRef.current = null; @@ -1945,11 +1941,11 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses ))); uploadPasteReservations(live); return true; - }, [uploadPasteReservations]); + }); // 对话框保存 / 用剪贴板替换:删掉原块后按同一套分类在原位置放入新内容(换 key; // 落文件则上传新附件、替换引用)。编辑后变短也保持为块 —— 用户是在编辑这个块。 - const replacePasteBlockText = useCallback((id, text) => { + const replacePasteBlockText = useStableCallback((id, text) => { const current = composerContentRef.current; if (!current) return; const normalized = normalizePastedText(text); @@ -1967,20 +1963,20 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses if (reservedFiles.length === 0) return; stagePastedTextFiles(reservedFiles, { replaceId: id }); uploadPasteReservations(reservedFiles); - }, [handleComposerChange, reserveUniqueComposerFiles, stagePastedTextFiles, uploadPasteReservations]); + }); // 卡片「上传失败,点击重试」:取回保留的 File,按当前目标重新上传。 - const retryPasteUpload = useCallback((key) => { + const retryPasteUpload = useStableCallback((key) => { const reservedFiles = composerAttachmentFilesForLocalIds(attachmentReservationsRef.current, [key]); if (reservedFiles.length === 0) { toast({ kind: 'err', text: '粘贴的文本已失效,请删除后重新粘贴' }); return; } uploadPasteReservations(reservedFiles); - }, [uploadPasteReservations]); + }); // 排队消息编辑框里粘贴的大段文本:落文件时上传到当前会话,返回带 id 的附件部件。 - const uploadPastedTextForQueue = useCallback(async (plan) => { + const uploadPastedTextForQueue = useStableCallback(async (plan) => { const targetSid = sidRef.current; if (!targetSid) throw new Error(tr('composerAttachment.uploadFailed')); const parts = []; @@ -2007,7 +2003,7 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses }); } return parts; - }, [api]); + }); // 首页草稿附件 → 会话附件的唯一导入点(sendInputOrBuiltin 开头与 /turn 的 // interruptTurn 之前)。导入用部件上记下的 store_scope,不能靠发送那一刻的首页 @@ -2055,10 +2051,10 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses return payloadWithImportedPastes(payload, imported); }, [api, setComposerValue]); - const removeComposerAttachment = useCallback((key) => { + const removeComposerAttachment = useStableCallback((key) => { const next = removeComposerAttachmentReference(composerContentRef.current, key); handleComposerChange(composerValueRef.current, next); - }, [handleComposerChange]); + }); const removeComposerContext = useCallback((key) => { setComposerContexts((items) => items.filter((item) => (item.local_id || item.id || item.type) !== key)); @@ -2515,7 +2511,7 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses return () => { cancelled = true; }; }, [api, modelProfileRevision, ref?.context_window, ref?.deleted, ref?.model, ref?.modelDeleted, ref?.model_deleted, ref?.model_name, ref?.model_preset, ref?.provider, ref?.workspaceHash, sid]); - const refreshSessionModels = useCallback(async () => { + const refreshSessionModels = useStableCallback(async () => { if (modelRefreshing || reasoningSwitching) return; const targetSid = sid; setModelRefreshing(true); @@ -2559,7 +2555,7 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses } finally { setModelRefreshing(false); } - }, [api, modelOptions, modelRefreshing, sid, reasoningSwitching]); + }); useEffect(() => { if (!sid) { @@ -2615,7 +2611,7 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses }); }, [measureStickyContext]); - const measureConversationTurn = useCallback((rowMetrics = null) => { + const measureConversationTurn = useStableCallback((rowMetrics = null) => { const el = scrollRef.current; const turnsForRail = conversationTurnsRef.current; if (!el || turnsForRail.length === 0) { @@ -2646,9 +2642,9 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses setActiveConversationTurn((previous) => ( previous === nextIndex ? previous : nextIndex )); - }, [sid]); + }); - const scheduleTranscriptMeasures = useCallback(() => { + const scheduleTranscriptMeasures = useStableCallback(() => { if (stickyRafRef.current) { cancelAnimationFrame(stickyRafRef.current); stickyRafRef.current = 0; @@ -2664,7 +2660,7 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses measureStickyContext(rowMetrics); measureConversationTurn(rowMetrics); }); - }, [measureConversationTurn, measureStickyContext]); + }); const setTailFollowFromAction = useCallback((action) => { tailFollowStateRef.current = nextChatTailFollowState(tailFollowStateRef.current, action); @@ -2737,7 +2733,7 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses }); }, [preserveActivityExpansionAnchor]); - const beginActivityExpansionAnchor = useCallback((titleElement) => { + const beginActivityExpansionAnchor = useStableCallback((titleElement) => { historyController.cancelAnchor(); const container = scrollRef.current; cancelActivityExpansionAnchor(); @@ -2765,9 +2761,9 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses secondFrame: 0, }; return true; - }, [cancelActivityExpansionAnchor, cancelTailFollowScroll, historyController, setTailFollowFromAction]); + }); - const scheduleTailFollowScroll = useCallback(() => { + const scheduleTailFollowScroll = useStableCallback(() => { const scrollToBottom = () => { if (historyController.hasAnchor()) return false; if (activityExpansionAnchorRef.current) return false; @@ -2791,23 +2787,23 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses scrollToBottom(); }); }); - }, [cancelTailFollowScroll, historyController]); + }); - const jumpToChatTail = useCallback(() => { + const jumpToChatTail = useStableCallback(() => { historyController.cancelAnchor(); cancelActivityExpansionAnchor(); setTailFollowFromAction({ type: 'jump_to_tail' }); scheduleTailFollowScroll(); scheduleTranscriptMeasures(); - }, [cancelActivityExpansionAnchor, historyController, scheduleTailFollowScroll, scheduleTranscriptMeasures, setTailFollowFromAction]); + }); - const pauseTailFollowForReview = useCallback(() => { + const pauseTailFollowForReview = useStableCallback(() => { cancelTailFollowScroll(); if (!busy && transcriptStatus !== 'running') return; setTailFollowFromAction({ type: 'review_pause' }); - }, [busy, cancelTailFollowScroll, setTailFollowFromAction, transcriptStatus]); + }); - const handleMessagesScroll = useCallback(() => { + const handleMessagesScroll = useStableCallback(() => { historyController.onScroll({ pointerActive: scrollActivityRef.current.pointerActive }); const el = scrollRef.current; if (el) { @@ -2840,29 +2836,29 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses scrollActivityRef.current.prev = metrics; } scheduleTranscriptMeasures(); - }, [cancelActivityExpansionAnchor, historyController, scheduleTranscriptMeasures, setTailFollowFromAction]); + }); // 滚轮上滚是最明确的"用户想往回看"信号,不等 scroll 事件的启发式判定, // 直接暂停跟随(仅回合进行中生效,见 pauseTailFollowForReview 内的门)。 - const handleMessagesWheel = useCallback((event) => { + const handleMessagesWheel = useStableCallback((event) => { historyController.onWheel(event); cancelActivityExpansionAnchor(); if (event.deltaY < 0) pauseTailFollowForReview(); - }, [cancelActivityExpansionAnchor, historyController, pauseTailFollowForReview]); + }); - const handleMessagesPointerDown = useCallback(() => { + const handleMessagesPointerDown = useStableCallback(() => { historyController.onPointerDown(); cancelActivityExpansionAnchor(); scrollActivityRef.current.pointerActive = true; - }, [cancelActivityExpansionAnchor, historyController]); + }); - const handleMessagesKeyDownCapture = useCallback((event) => { + const handleMessagesKeyDownCapture = useStableCallback((event) => { historyController.onKeyDown(event); if (!['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Home', 'End', ' '].includes(event.key)) { return; } cancelActivityExpansionAnchor(); - }, [cancelActivityExpansionAnchor, historyController]); + }); useEffect(() => { const clearPointerActive = () => { @@ -2876,7 +2872,7 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses }; }, []); - const jumpToStickyUserSource = useCallback((context) => { + const jumpToStickyUserSource = useStableCallback((context) => { const el = scrollRef.current; const targetId = String(context?.itemId || ''); if (!el || !targetId) return; @@ -2898,9 +2894,9 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses }); requestAnimationFrame(scheduleStickyMeasure); window.setTimeout(scheduleStickyMeasure, 220); - }, [historyController, scheduleStickyMeasure]); + }); - const jumpToConversationTurn = useCallback((turn, index, retried = false) => { + const jumpToConversationTurn = useStableCallback((turn, index, retried = false) => { historyController.cancelAnchor(); const el = scrollRef.current; const targetId = String(turn?.itemId || ''); @@ -2943,21 +2939,21 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses scrollTop: el.scrollTop, }; window.requestAnimationFrame(scheduleTranscriptMeasures); - }, [expandTranscriptWindow, historyController, pauseTailFollowForReview, scheduleTranscriptMeasures, sid]); + }); - const focusChatInput = useCallback((force = false) => { + const focusChatInput = useStableCallback((force = false) => { if (questionRequest) return; if (!force && isEditableElement(document.activeElement)) return; inputRef.current?.focus(); - }, [questionRequest]); + }); - const restoreChatInputFocusSoon = useCallback((force = false) => { + const restoreChatInputFocusSoon = useStableCallback((force = false) => { requestAnimationFrame(() => { focusChatInput(force); requestAnimationFrame(() => focusChatInput(force)); window.setTimeout(() => focusChatInput(force), 80); }); - }, [focusChatInput]); + }); useEffect(() => { if (sid || !stagedExpertDraft.present) return; @@ -3020,18 +3016,19 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses busy, cancelTailFollowScroll, changeDockBottomPadding, + historyController, permissionRequests, renderedItems, scheduleTailFollowScroll, sid, ]); - const handleTranscriptContentResize = useCallback(() => { + const handleTranscriptContentResize = useStableCallback(() => { scheduleTranscriptMeasures(); if (historyController.preserveAnchor()) return; if (preserveActivityExpansionAnchor()) return; scheduleTailFollowScroll(); - }, [historyController, preserveActivityExpansionAnchor, scheduleTailFollowScroll, scheduleTranscriptMeasures]); + }); useEffect(() => observeChatTailContent( transcriptContentRef.current, @@ -3120,7 +3117,8 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses cancelAnimationFrame(id); if (timer) window.clearTimeout(timer); }; - }, [sid, focusChatInput]); + // focusChatInput 身份稳定;列出 questionRequest 让回答完问题后照旧把焦点还给输入框。 + }, [sid, focusChatInput, questionRequest]); useEffect(() => { return bindDesktopComposerAutoFocus({ @@ -3289,7 +3287,7 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses setHistory((h) => [...h, text]); }, [api]); - const enqueueInput = useCallback((payload) => { + const enqueueInput = useStableCallback((payload) => { if (!sid) return; // cwd 输入历史只记编辑器文本:内联粘贴块的正文不进历史。 const text = typeof payload === 'string' ? payload : inputHistoryTextForPayload(payload); @@ -3297,7 +3295,7 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses if (text.trim()) recordInputHistory(text); // 标题不在本地用消息全文改写:服务端落盘后经 session_updated{summary} 下发 // 截断后的摘要,顶部与侧栏同源。 - }, [recordInputHistory, sid, updateQueueState]); + }); const cancelQueued = useCallback((queuedId) => { updateQueueState((prev) => cancelQueuedInput(prev, queuedId)); @@ -3305,16 +3303,16 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses // 解除「用户中断回合」带来的队列暂停。实际出队仍由下面的 drain effect 在 // queueState 变化后接管(busy=false 且不再 paused 才会发),这里只改标记。 - const resumeQueue = useCallback(() => { + const resumeQueue = useStableCallback(() => { const targetSid = sidRef.current; if (!targetSid) return; const current = transcript.getState(); updateQueueState((prev) => resumeQueuedInput(prev, targetSid, { afterAbortTurnId: current.abortPending ? (current.abortTurnId || 'pending-stop') : '', })); - }, [updateQueueState, transcript.getState]); + }); - const retryQueued = useCallback((queuedId) => { + const retryQueued = useStableCallback((queuedId) => { // 点「重试」是用户明确要发这条消息,暂停态一并解除,否则按钮按了没反应。 updateQueueState((prev) => { const next = retryQueuedInput(prev, queuedId); @@ -3325,7 +3323,7 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses ? (current.abortTurnId || 'pending-stop') : '', }) : next; }); - }, [updateQueueState, transcript.getState]); + }); const saveQueuedEdit = useCallback((queuedId, text, composerContent) => { const queuedItem = queueStore.getState().items.find( @@ -3348,7 +3346,7 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses updateQueueState((prev) => updateQueuedInputContent(prev, queuedId, nextText, { composerContent })); }, [queueStore, updateQueueState]); - const runSideQuestion = useCallback(( + const runSideQuestion = useStableCallback(( rawQuestion, { command = 'btw', recordHistory = false, historyText = null } = {}, ) => { @@ -3373,18 +3371,18 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses if (entry.trim()) recordInputHistory(entry); } return started; - }, [sideChat, recordInputHistory]); + }); - const openSideQuestionComposer = useCallback(() => { + const openSideQuestionComposer = useStableCallback(() => { if (!sidRef.current) { toast({ kind: 'err', text: '请先在已有会话中使用 /btw 或 /side' }); return; } setSideChatAnchor(null); sideChat.open(); - }, [sideChat]); + }); - const guideQueued = useCallback((queuedId) => { + const guideQueued = useStableCallback((queuedId) => { const targetSid = sidRef.current; const expectedTurnId = String(activeTurnId || ''); if (!targetSid || !busy || !expectedTurnId) { @@ -3447,7 +3445,7 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses toast({ kind: 'err', text: '插话提交失败:' + (e?.message || '未知错误') }); return false; }); - }, [activeTurnId, api, busy, onQuestionResolve, questionForView, queueStore, updateQueueState]); + }); const executeBuiltinCommand = useCallback((targetSid, command) => ( api.executeCommand(targetSid, command).then((result) => { @@ -3494,7 +3492,7 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses return api.sendInput(targetSid, materialized); }, [api, executeBuiltinCommand, materializeWorkspaceDraftPastes]); - const submit = useCallback((text) => { + const submit = useStableCallback((text) => { if (sessionRuntimeUnavailable) return; const submittedContent = reconcileComposerContentAttachments(composerContentRef.current, composerAttachments); const activeAttachments = submittedContent ? composerContentAttachments(submittedContent, composerAttachments, { sessionId: sid }) : composerAttachments; @@ -3887,9 +3885,9 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses } }) .finally(() => setComposerSubmitting(false)); - }, [sid, busy, activeTurnId, api, homeSubmitting, recordInputHistory, enqueueInput, updateQueueState, applyEvent, sendInputOrBuiltin, executeBuiltinCommand, composerSubmitting, clearCurrentSessionDraft, composerAttachments, composerContexts, composerSwarmChoice, transcriptSwarmMode, clearComposerExtras, createHomeComposerSession, persistMediaFilesToSession, restoreChatInputFocusSoon, setTailFollowFromAction, runSideQuestion, draftWorkspaceHash, homeDraftWorkspaceHash, homeComposerDrafts, onHomeComposerDraftAccepted, ref?.noWorkspace, ref?.no_workspace, ref?.workspaceHash, ref?.workspace_hash, sessionRuntimeUnavailable, retryUserMessageId, transcript.getState, transcriptLoadState, readOnlyExternalSession, commitDeferredPastes, materializeWorkspaceDraftPastes, uploadPasteReservations]); + }); - const drainQueuedInput = useCallback(() => { + const drainQueuedInput = useStableCallback(() => { const targetSid = sidRef.current; if (!targetSid || busy || drainRef.current || sessionRuntimeUnavailable) return; const current = transcript.getState(); @@ -3935,7 +3933,7 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses .finally(() => { drainRef.current = false; }); - }, [applyEvent, busy, queueStore, sendInputOrBuiltin, setTailFollowFromAction, updateQueueState, sessionRuntimeUnavailable, transcript.getState]); + }); const prevBusyRef = useRef(busy); useEffect(() => { @@ -3966,7 +3964,10 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses if (wasBusy || !hasSendingQueuedInput(queueState, sid)) { drainQueuedInput(); } - }, [busy, drainQueuedInput, lastTurnOutcome, lastTerminalTurnId, queueState, sid, transcriptLoadState, updateQueueState]); + // drainQueuedInput 身份稳定;它读到的这些值仍要列出,保持原来「会话恢复可用等变化时 + // 再试一次出队」的时机(原先靠回调身份变化触发)。 + }, [applyEvent, busy, drainQueuedInput, lastTurnOutcome, lastTerminalTurnId, queueState, queueStore, + sessionRuntimeUnavailable, sid, transcript.getState, transcriptLoadState, updateQueueState]); useEffect(() => { if (!sid || items.length === 0) return; @@ -3987,7 +3988,7 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses }); }, [items, sid, updateQueueState]); - const abort = useCallback(() => { + const abort = useStableCallback(() => { if (!sid) return; const current = transcript.getState(); if (!current.busy || current.abortPending) return; @@ -4002,12 +4003,12 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses payload: { turn_id: current.activeTurnId }, timestamp_ms: Date.now(), }, { emitEffects: false }); - }, [applyEvent, sid, updateQueueState, transcript.getState]); + }); - const stopCurrentWork = useCallback(() => { + const stopCurrentWork = useStableCallback(() => { if (!sid || !busy) return; abort(); - }, [abort, busy, sid]); + }); useEffect(() => { const onDisconnect = () => { @@ -4019,7 +4020,7 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses return () => connection.removeEventListener('disconnect', onDisconnect); }, [applyEvent, transcript.getState]); - const runGoalCommand = useCallback(async (action, objective = '') => { + const runGoalCommand = useStableCallback(async (action, objective = '') => { if (!sid) throw new Error('当前会话不可用'); const commandLabels = { edit: '编辑目标', @@ -4040,22 +4041,19 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses toast({ kind: 'err', text: `${label}失败:${error?.message || '未知错误'}` }); throw error; } - }, [executeBuiltinCommand, sid]); + }); - const editGoal = useCallback( + const editGoal = useStableCallback( (objective) => runGoalCommand('edit', objective), - [runGoalCommand], ); - const changeGoalStatus = useCallback( + const changeGoalStatus = useStableCallback( (action) => runGoalCommand(action), - [runGoalCommand], ); - const clearGoal = useCallback( + const clearGoal = useStableCallback( () => runGoalCommand('clear'), - [runGoalCommand], ); - const selectHomeModel = useCallback(async (name) => { + const selectHomeModel = useStableCallback(async (name) => { const nextName = String(name || ''); const previousName = String(homeModelName || ''); if (!nextName || nextName === previousName || modelRefreshing || modelSwitching || reasoningSwitching) return; @@ -4072,9 +4070,9 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses } finally { setModelSwitching(false); } - }, [api, homeModelName, modelRefreshing, modelSwitching, reasoningSwitching]); + }); - const switchSessionModel = useCallback(async (name) => { + const switchSessionModel = useStableCallback(async (name) => { const nextName = String(name || ''); const currentName = selectedModelName(modelState); if (!sid || !nextName || nextName === currentName || modelSwitching || reasoningSwitching) return; @@ -4090,12 +4088,12 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses setPendingModelName(''); setModelSwitching(false); } - }, [api, modelState, modelSwitching, reasoningSwitching, sid]); + }); - const changeComposerModel = useCallback((name) => { + const changeComposerModel = useStableCallback((name) => { if (sid) void switchSessionModel(name); else void selectHomeModel(name); - }, [selectHomeModel, sid, switchSessionModel]); + }); useEffect(() => { setHomeReasoningEffort(null); @@ -4106,7 +4104,7 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses setHomeReasoningEffort((current) => composerReasoningOptions(selected, current)?.selectedEffort ?? null); }, [homeModelName, modelOptions]); - const changeComposerReasoning = useCallback(async (effort) => { + const changeComposerReasoning = useStableCallback(async (effort) => { if (homeSubmitting || composerSubmitting || reasoningSwitching || modelSwitching || modelRefreshing) return; const selected = sid ? modelState : modelOptions.find((option) => option.name === homeModelName); const choices = composerReasoningOptions(selected, sid ? undefined : homeReasoningEffort); @@ -4130,7 +4128,7 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses } finally { if (sidRef.current === targetSid && reasoningRequestRef.current === request) setReasoningSwitching(false); } - }, [api, composerSubmitting, homeModelName, homeReasoningEffort, homeSubmitting, modelOptions, modelRefreshing, modelState, modelSwitching, reasoningSwitching, sid]); + }); const reasoningShortcutPending = useRef(false); const shortcutComposerVisible = () => !!inputRef.current?.getElement()?.getClientRects().length @@ -4154,7 +4152,7 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses reasoningDown: () => stepReasoning(-1), }); - const switchHomeDefaultPermissionMode = useCallback(async (mode) => { + const switchHomeDefaultPermissionMode = useStableCallback(async (mode) => { const nextMode = normalizePermissionMode(mode); const previousMode = normalizePermissionMode(permissionMode); if (nextMode === previousMode || permissionSwitching) return; @@ -4171,9 +4169,9 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses } finally { setPermissionSwitching(false); } - }, [api, permissionMode, permissionSwitching]); + }); - const switchPermissionMode = useCallback(async (mode) => { + const switchPermissionMode = useStableCallback(async (mode) => { const nextMode = normalizePermissionMode(mode); const previousMode = normalizePermissionMode(permissionMode); if (!sid || nextMode === previousMode || permissionSwitching) return; @@ -4191,12 +4189,12 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses } finally { setPermissionSwitching(false); } - }, [api, onPermissionModeChanged, permissionMode, permissionSwitching, sid]); + }); - const changeComposerPermissionMode = useCallback((mode) => { + const changeComposerPermissionMode = useStableCallback((mode) => { if (sid) void switchPermissionMode(mode); else void switchHomeDefaultPermissionMode(mode); - }, [sid, switchHomeDefaultPermissionMode, switchPermissionMode]); + }); useLayoutEffect(() => { const element = layoutRef.current; @@ -4211,7 +4209,7 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses return () => observer.disconnect(); }, [sid]); - const startSidePanelResize = useCallback((event) => { + const startSidePanelResize = useStableCallback((event) => { if (!showSidePanel || !onSidePanelResize) return; if (event.button != null && event.button !== 0) return; if (sidePanelResizeActiveRef.current) return; @@ -4241,9 +4239,9 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses window.addEventListener('pointercancel', onStop, { once: true }); window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onStop, { once: true }); - }, [onSidePanelResize, showSidePanel, sidePanelWidth]); + }); - const onSidePanelHandleKeyDown = useCallback((event) => { + const onSidePanelHandleKeyDown = useStableCallback((event) => { if (!onSidePanelResize) return; const step = event.shiftKey ? 32 : 12; if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { @@ -4252,9 +4250,9 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses const contentWidth = layoutRef.current?.getBoundingClientRect().width || 0; onSidePanelResize(sidePanelWidth + delta, contentWidth); } - }, [onSidePanelResize, sidePanelWidth]); + }); - const startPreviewPanelResize = useCallback((event) => { + const startPreviewPanelResize = useStableCallback((event) => { if (!onPreviewPanelResize) return; if (event.button != null && event.button !== 0) return; if (previewPanelResizeActiveRef.current) return; @@ -4284,9 +4282,9 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses window.addEventListener('pointercancel', onStop, { once: true }); window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onStop, { once: true }); - }, [onPreviewPanelResize]); + }); - const onPreviewPanelHandleKeyDown = useCallback((event) => { + const onPreviewPanelHandleKeyDown = useStableCallback((event) => { if (!onPreviewPanelResize) return; const step = event.shiftKey ? 32 : 12; if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { @@ -4295,9 +4293,9 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses const contentWidth = layoutRef.current?.getBoundingClientRect().width || 0; onPreviewPanelResize(renderedPreviewPanelWidthRef.current + delta, contentWidth); } - }, [onPreviewPanelResize]); + }); - const startSubagentPanelResize = useCallback((event) => { + const startSubagentPanelResize = useStableCallback((event) => { if (!subagentPanelOpen || !sid || !onSubagentPanelResize) return; if (event.button != null && event.button !== 0) return; if (subagentPanelResizeActiveRef.current) return; @@ -4330,9 +4328,9 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses window.addEventListener('pointercancel', onStop, { once: true }); window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onStop, { once: true }); - }, [onSubagentPanelResize, sid, subagentPanelOpen]); + }); - const onSubagentPanelHandleKeyDown = useCallback((event) => { + const onSubagentPanelHandleKeyDown = useStableCallback((event) => { if (!onSubagentPanelResize) return; const step = event.shiftKey ? 32 : 12; if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { @@ -4344,7 +4342,7 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses contentWidth, ); } - }, [onSubagentPanelResize]); + }); useEffect(() => { if (!subagentPanelOpen) subagentPanelResizeCleanupRef.current?.(); @@ -4357,7 +4355,7 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses // fork: 调后端 POST /api/sessions/:id/fork,成功后切到新 session(同 ref)。 // 失败弹 toast 不打断当前 session。新 session 不会自动启 turn, // 用户在新 session 自己输入消息才开始。 - const forkAndSwitch = useCallback(async (messageId) => { + const forkAndSwitch = useStableCallback(async (messageId) => { const sourceMessageId = messageId == null ? '' : String(messageId); if (!sid || !sourceMessageId) return; if (!forkActionGuardRef.current.acquire(FORK_ACTION_KEY)) return; @@ -4430,7 +4428,7 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses forkActionGuardRef.current.release(FORK_ACTION_KEY); setForkingMessageId((current) => (current === sourceMessageId ? '' : current)); } - }, [sid, api, ref, onSessionPromoted]); + }); useEffect(() => { const handler = (event) => { @@ -4548,12 +4546,12 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses useEffect(() => { setHeaderRenaming(false); }, [sid]); const sessionTitleLabelRef = useRef(null); const sessionMenuButtonRef = useRef(null); - const startHeaderRename = useCallback(() => { + const startHeaderRename = useStableCallback(() => { if (!sid || readOnlyExternalSession) return; setHeaderRenaming(true); - }, [sid, readOnlyExternalSession]); + }); const cancelHeaderRename = useCallback(() => setHeaderRenaming(false), []); - const commitHeaderRename = useCallback(async (draft) => { + const commitHeaderRename = useStableCallback(async (draft) => { setHeaderRenaming(false); const targetSid = sid; const next = resolveSessionTitleRename(draft, title); @@ -4580,10 +4578,10 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses } catch (e) { toast({ kind: 'err', text: '重命名失败:' + (e?.message || '') }); } - }, [api, applyEvent, ref?.noWorkspace, ref?.no_workspace, sessionWorkspaceHash, sid, title]); + }); // 「会话菜单」按钮左键、按钮右键、顶栏其它位置右键共用这一份菜单。 - const openSessionMenu = useCallback(({ x, y, placement, anchorRect }) => { + const openSessionMenu = useStableCallback(({ x, y, placement, anchorRect }) => { if (!sid || typeof window === 'undefined') return; const button = sessionMenuButtonRef.current; const target = readOnlyExternalSession ? button : sidebarSessionContextTarget(sid, sessionWorkspaceHash, button); @@ -4611,13 +4609,13 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses [DESKTOP_CONTEXT_ACTIONS.RENAME_SESSION]: startHeaderRename, }, }); - }, [sessionWorkspaceHash, sid, readOnlyExternalSession, openSideQuestionComposer, onFindInConversation, startHeaderRename]); - const openSessionContextMenu = useCallback((event) => { + }); + const openSessionContextMenu = useStableCallback((event) => { event.preventDefault(); event.stopPropagation(); const rect = event.currentTarget.getBoundingClientRect(); openSessionMenu({ x: rect.right, y: rect.bottom + 4, anchorRect: rect }); - }, [openSessionMenu]); + }); useEffect(() => { if (!sid) return undefined; const handler = (event) => { @@ -4677,7 +4675,7 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses const composerExpertId = sid ? boundExpertId : homeExpertId; const composerExpert = displayedExperts.find((expert) => expert.id === composerExpertId) || null; - const selectComposerExpert = useCallback(async (expert, options = {}) => { + const selectComposerExpert = useStableCallback(async (expert, options = {}) => { const expertId = String(expert?.id || ''); if (!expertId) return false; const hasDraftText = Object.prototype.hasOwnProperty.call(options, 'draftText'); @@ -4828,17 +4826,9 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses setExpertSwitching(false); } } - }, [ - api, - onRememberExpert, - onSessionExpertChanged, - restoreChatInputFocusSoon, - sessionExpertId, - sessionExpertSnapshot, - sid, - ]); + }); - const detachComposerExpert = useCallback(async () => { + const detachComposerExpert = useStableCallback(async () => { if (expertDetaching) return false; const hasBoundExpert = sid ? !!sessionExpertId : !!homeExpertId; if (!hasBoundExpert && !pendingExpertRef.current) return true; @@ -4881,14 +4871,7 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses } finally { if (sidRef.current === targetSessionId) setExpertDetaching(false); } - }, [ - api, - expertDetaching, - homeExpertId, - onSessionExpertChanged, - sessionExpertId, - sid, - ]); + }); useEffect(() => { if (busy || expertSwitching || !pendingExpert?.confirmed || !sid) return undefined; @@ -5029,9 +5012,9 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses sid, ]); - const selectExpertOpeningPrompt = useCallback(async (expert, prompt) => { + const selectExpertOpeningPrompt = useStableCallback(async (expert, prompt) => { return selectComposerExpert(expert, { draftText: String(prompt || '') }); - }, [selectComposerExpert]); + }); const currentContextWindow = Number(modelState?.contextWindow || ref?.context_window || ref?.contextWindow || 0) || 0; const tokenBudget = useMemo(() => normalizeTokenBudget({ usage: tokenUsage, @@ -5209,7 +5192,7 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses setCollapsedMediaKeys(new Set()); }, [sid]); - const toggleActivitySummary = useCallback((key, titleElement) => { + const toggleActivitySummary = useStableCallback((key, titleElement) => { const anchored = beginActivityExpansionAnchor(titleElement); if (!anchored) pauseTailFollowForReview(); flushSync(() => { @@ -5222,14 +5205,9 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses }); preserveActivityExpansionAnchor(); scheduleActivityExpansionAnchorMeasure(); - }, [ - beginActivityExpansionAnchor, - pauseTailFollowForReview, - preserveActivityExpansionAnchor, - scheduleActivityExpansionAnchorMeasure, - ]); + }); - const toggleMediaGroup = useCallback((key, titleElement) => { + const toggleMediaGroup = useStableCallback((key, titleElement) => { const anchored = beginActivityExpansionAnchor(titleElement); if (!anchored) pauseTailFollowForReview(); flushSync(() => { @@ -5242,33 +5220,28 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses }); preserveActivityExpansionAnchor(); scheduleActivityExpansionAnchorMeasure(); - }, [ - beginActivityExpansionAnchor, - pauseTailFollowForReview, - preserveActivityExpansionAnchor, - scheduleActivityExpansionAnchorMeasure, - ]); + }); - const openReviewPanel = useCallback(() => { + const openReviewPanel = useStableCallback(() => { if (!showSidePanel || !sid) return; if (sidePanelCollapsed || sidePanelListCollapsed) onRevealSidePanelList?.(); setReviewRequest((n) => n + 1); - }, [onRevealSidePanelList, showSidePanel, sid, sidePanelCollapsed, sidePanelListCollapsed]); + }); - const locateInFileTree = useCallback((path) => { + const locateInFileTree = useStableCallback((path) => { if (!showSidePanel || !path) return; if (sidePanelCollapsed || sidePanelListCollapsed) onRevealSidePanelList?.(); setFileLocateRequest((prev) => ({ path, token: (prev.token || 0) + 1 })); - }, [onRevealSidePanelList, showSidePanel, sidePanelCollapsed, sidePanelListCollapsed]); + }); - const dismissChangeDock = useCallback(() => { + const dismissChangeDock = useStableCallback(() => { if (!changeDockDismissalKey || !changeSignature) return; setDismissedDockSignatures((prev) => dismissChangeDockSignature( prev, changeDockDismissalKey, changeSignature, )); - }, [changeDockDismissalKey, changeSignature, setDismissedDockSignatures]); + }); // 渲染期刷新 ref(纯缓存):submit 提交新一轮对话时经此收起整个 dock。 dockAutoDismissRef.current = () => { @@ -5282,7 +5255,7 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses if (!completedTurnResultVisible) return; dismissChangeDock(); if (sid) setTodoDockSuppression({ sessionKey: sid, signature: todoSignature }); - }, [completedTurnResultVisible, dismissChangeDock, sid, todoSignature]); + }, [changeDockDismissalKey, changeSignature, completedTurnResultVisible, dismissChangeDock, sid, todoSignature]); const conversationActivity = useMemo(() => selectConversationActivity({ foregroundBusy: busy, @@ -5300,10 +5273,10 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses subagentTasks.tasks, ]); - const resolveQuestion = useCallback(() => { + const resolveQuestion = useStableCallback(() => { onQuestionResolve?.(); requestAnimationFrame(() => inputRef.current?.focus()); - }, [onQuestionResolve]); + }); const sidePanelMounted = showSidePanel; @@ -5331,7 +5304,7 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses () => activePreviewTab(previewTabState, previewContext), [previewContext, previewTabState], ); - const requestPreviewApproval = useCallback((getTabs, kind = 'switch') => ( + const requestPreviewApproval = useStableCallback((getTabs, kind = 'switch') => ( previewFileGuardRef.current.request({ kind, getTabs, @@ -5361,20 +5334,20 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses } }, }) - ), [api, previewContext, setPreviewTabState, sidePanelCwd]); - const requestPreviewLeave = useCallback(() => requestPreviewApproval( + )); + const requestPreviewLeave = useStableCallback(() => requestPreviewApproval( () => visiblePreviewTabs(getPreviewTabState(), previewContext), - ), [getPreviewTabState, previewContext, requestPreviewApproval]); + )); previewLeaveRequestRef.current = requestPreviewLeave; useLayoutEffect(() => onRegisterPreviewLeaveGuard?.(requestPreviewLeave), [ onRegisterPreviewLeaveGuard, requestPreviewLeave, ]); - const requestActiveFileLeave = useCallback(() => { + const requestActiveFileLeave = useStableCallback(() => { const key = activePreviewTab(getPreviewTabState(), previewContext)?.key; return requestPreviewApproval(() => visiblePreviewTabs(getPreviewTabState(), previewContext) .filter((tab) => tab.key === key)); - }, [getPreviewTabState, previewContext, requestPreviewApproval]); - const selectPreview = useCallback((producer, afterSelect) => { + }); + const selectPreview = useStableCallback((producer, afterSelect) => { if (previewFileGuardRef.current.isPending()) return false; const state = getPreviewTabState(); const current = activePreviewTab(state, previewContext); @@ -5385,7 +5358,7 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses afterSelect?.(); return true; }); - }, [getPreviewTabState, previewContext, requestActiveFileLeave, setPreviewTabState]); + }); const previewTabsOpen = previewTabs.length > 0; // 总开关必须连最大化详情一起隐藏;恢复时仍保留最大化偏好与原页签。 const previewPanelVisible = previewTabsOpen && !sidePanelCollapsed && !previewPanelHidden; @@ -5399,10 +5372,10 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses const sidePanelNavigationCollapsed = sidePanelCollapsed || (rightPopup ? (popupNavigationCollapsed ?? (compactRightPopup || sidePanelListCollapsed)) : sidePanelListCollapsed); - const toggleVisibleSidePanelList = useCallback(() => { + const toggleVisibleSidePanelList = useStableCallback(() => { if (rightPopup) setPopupNavigationCollapsed(!sidePanelNavigationCollapsed); else onToggleSidePanelList?.(); - }, [rightPopup, sidePanelNavigationCollapsed, onToggleSidePanelList]); + }); useLayoutEffect(() => { notifyNativeSurfaceOverlayChange(); }, [compactRightPopup, sidePanelNavigationCollapsed]); @@ -5501,7 +5474,7 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses // (ToolBlock 的 task_complete 完成总结、会话摘要),那里的文件链接以前点了 // 没有任何反应。链接本身已经没有 href(markdown.js 里删掉了),不会跳走, // 这里只负责把它们接回预览/文件树。 - const handleTranscriptFileLink = useCallback((event) => { + const handleTranscriptFileLink = useStableCallback((event) => { if (event.type === 'keydown' && event.key !== 'Enter' && event.key !== ' ') return; const anchor = event.target?.closest?.('a[data-file-path]'); if (!anchor) return; @@ -5512,10 +5485,10 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses const lineAttr = anchor.getAttribute('data-file-line'); if (kind === 'directory') locateInFileTree(path); else openFilePreview(path, lineAttr ? Number(lineAttr) : null); - }, [locateInFileTree, openFilePreview]); + }); // 有专用原生文件选择器(Desktop 壳)走原生,否则走 web 路径选择器(add-web-path-picker)。 - const openPreviewFilePicker = useCallback(async () => { + const openPreviewFilePicker = useStableCallback(async () => { if (!sidePanelCwd) return; try { const picked = await pickPreviewFile(sidePanelCwd, { api }); @@ -5523,7 +5496,7 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses } catch (error) { toast({ kind: 'err', text: error?.message || '选择器不可用' }); } - }, [api, openFilePreview, sidePanelCwd]); + }); const showBrowserPage = useCallback((pageId, title, favicon) => { if (!sid || !pageId) return; @@ -5541,7 +5514,7 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses }, [onRevealPreviewPanel, previewScope, selectPreview, sid]); const browserOpenPendingRef = useRef(false); - const openBrowserPreview = useCallback(async (url = '') => { + const openBrowserPreview = useStableCallback(async (url = '') => { if (!sid || !hasNativeAgentBrowser() || browserOpenPendingRef.current) return; browserOpenPendingRef.current = true; try { @@ -5582,7 +5555,7 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses } finally { browserOpenPendingRef.current = false; } - }, [ref, requestActiveFileLeave, showBrowserPage, sid]); + }); // 切会话时向 Desktop 对账一次 native 页面池;事件流已经在 App 级持续镜像。 useEffect(() => { @@ -5634,7 +5607,7 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses revealed.delete(pageId); } } - }, [getPreviewTabState, previewContext, previewScope, sessionBrowserPages, showBrowserPage, sid]); + }, [getPreviewTabState, previewContext, previewScope, sessionBrowserPages, showBrowserPage, sid, workbenchOwner]); // Agent 切换默认目标页(browser_open / 显式选页)且有浏览器工具正在执行时,把 // 那一页的页签激活到前台;目标不属于本会话(显式操作别的会话的页)则不动。 @@ -5653,7 +5626,7 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses // iOS 模拟器页签。聚焦打开走 selectPreview(离开未保存文件需确认);不抢焦点的补开 // 不切换当前页签,直接写状态。 - const showSimulatorTab = useCallback((udid = '', title = '', { focus = true } = {}) => { + const showSimulatorTab = useStableCallback((udid = '', title = '', { focus = true } = {}) => { if (!sid) return false; const options = { scopeKey: previewScope, sessionId: sid, udid, title, focus }; if (!focus) { @@ -5664,22 +5637,22 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses onRevealPreviewPanel?.(); setPreviewPanelHidden(false); }); - }, [onRevealPreviewPanel, previewScope, selectPreview, setPreviewTabState, sid]); + }); // 手动入口:已有模拟器页签就聚焦最近一个;否则打开选择页,由面板按「会话已绑定的 // 设备 → 唯一已启动的设备 → 用户选择」确定设备。 - const openIosSimulatorPreview = useCallback(() => { + const openIosSimulatorPreview = useStableCallback(() => { const existing = simulatorTabsForSession(getPreviewTabState(), sid); const target = existing[existing.length - 1]; return showSimulatorTab(target?.udid || '', target?.title || ''); - }, [getPreviewTabState, showSimulatorTab, sid]); + }); - const retargetSimulatorPreview = useCallback((tabKey, udid, title) => { + const retargetSimulatorPreview = useStableCallback((tabKey, udid, title) => { if (!sid || !udid) return; setPreviewTabState((prev) => retargetSimulatorTab(prev, { scopeKey: previewScope, sessionId: sid, tabKey, udid, title, })); - }, [previewScope, setPreviewTabState, sid]); + }); // transcript 里的 ios_simulator 工具结果驱动页签,与上面 Agent 浏览器「每会话已揭示 // 集合」同一思路:每个会话首次加载完历史时只记下最近一条结果、不动页签(打开旧会话 @@ -5709,7 +5682,7 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses } }, [getPreviewTabState, latestSimulatorEvent, previewScope, setPreviewTabState, showSimulatorTab, sid, transcriptLoadState]); - const openSessionChangePreview = useCallback((filePath, turnUserMessageId = '') => { + const openSessionChangePreview = useStableCallback((filePath, turnUserMessageId = '') => { if (!sid || !filePath) return; const turnChangeSet = turnUserMessageId ? turnChangeSets.find((set) => set.userMessageId === turnUserMessageId) @@ -5727,12 +5700,12 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses setPopupNavigationCollapsed(null); setPreviewPanelHidden(false); }); - }, [changeSummary.fileCount, onToggleSidePanel, previewScope, selectPreview, sid, sidePanelCollapsed, turnChangeSets, responsiveSidebars?.requestRightPopup]); + }); // git 变更点击文件 → 在中间详情栏开/聚焦「变更」页签(复刻会话级变更旧行为)。 // gitBase 只有从 SidePanel 导航列表点击时才带;详情栏内点文件不带,由 // openGitChangesTab 保留页签原 base。 - const openGitChangePreview = useCallback((filePath, gitBase, gitFileCount) => { + const openGitChangePreview = useStableCallback((filePath, gitBase, gitFileCount) => { if (!previewContext.sessionId || !filePath) return; return selectPreview((prev) => openGitChangesTab(prev, { ...previewContext, @@ -5745,15 +5718,15 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses setPopupNavigationCollapsed(null); setPreviewPanelHidden(false); }); - }, [onToggleSidePanel, previewContext, selectPreview, sidePanelCollapsed, sidePanelCwd, responsiveSidebars?.requestRightPopup]); + }); // SidePanel 切基线时,若「变更」页签已打开则同步其 base(详情栏跟着换比较对象)。 - const updateGitChangeBase = useCallback((gitBase) => { + const updateGitChangeBase = useStableCallback((gitBase) => { if (!previewContext.sessionId) return; setPreviewTabState((prev) => updateGitChangesTab(prev, { ...previewContext, base: gitBase || '' })); - }, [previewContext]); + }); - const activatePreview = useCallback((tabKey) => { + const activatePreview = useStableCallback((tabKey) => { const tab = previewTabs.find((candidate) => candidate.key === tabKey); return selectPreview((prev) => activatePreviewTab(prev, { ...previewContext, @@ -5763,22 +5736,22 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses void selectAgentBrowserPage(tab.pageId); } }); - }, [previewContext, previewTabs, selectPreview]); + }); - const refreshPreview = useCallback((tabKey) => { + const refreshPreview = useStableCallback((tabKey) => { setPreviewTabState((prev) => refreshPreviewTab(prev, { ...previewContext, tabKey, })); - }, [previewContext]); + }); - const updateFilePreviewDraft = useCallback((tabKey, patch) => { + const updateFilePreviewDraft = useStableCallback((tabKey, patch) => { setPreviewTabState((prev) => updateFileTabDraft(prev, { ...previewContext, tabKey, patch, })); - }, [previewContext, setPreviewTabState]); + }); useEffect(() => { const transition = nextAutoPreviewRefresh(previewAutoRefreshRef.current, { @@ -5790,9 +5763,9 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses }); previewAutoRefreshRef.current = transition.state; if (transition.tabKey) refreshPreview(transition.tabKey); - }, [activePreview, busy, lastUserTurnKey, latestSuccessfulChangedFiles, refreshPreview, sid]); + }, [activePreview, busy, lastUserTurnKey, latestSuccessfulChangedFiles, previewContext, refreshPreview, sid]); - const previewTabsForCloseAction = useCallback((kind, tabKey = '') => { + const previewTabsForCloseAction = useStableCallback((kind, tabKey = '') => { if (kind === 'all') return previewTabs; const index = previewTabs.findIndex((tab) => tab.key === tabKey); if (index < 0) return []; @@ -5800,9 +5773,9 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses if (kind === 'others') return previewTabs.filter((tab) => tab.key !== tabKey); if (kind === 'right') return previewTabs.slice(index + 1); return []; - }, [previewTabs]); + }); - const performPreviewClose = useCallback((kind, tabKey = '') => { + const performPreviewClose = useStableCallback((kind, tabKey = '') => { const affected = previewTabsForCloseAction(kind, tabKey); if (affected.length === 0) return; affected.forEach((tab) => { @@ -5820,15 +5793,9 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses if (affected.length >= previewTabs.length && sidePanelMaximized) { onToggleSidePanelMaximized?.(); } - }, [ - onToggleSidePanelMaximized, - previewContext, - previewTabs.length, - previewTabsForCloseAction, - sidePanelMaximized, - ]); + }); - const requestPreviewClose = useCallback((kind, tabKey = '') => { + const requestPreviewClose = useStableCallback((kind, tabKey = '') => { const affected = previewTabsForCloseAction(kind, tabKey); if (affected.length === 0) return; const keys = new Set(affected.map((tab) => tab.key)); @@ -5837,42 +5804,36 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses .filter((tab) => keys.has(tab.key)), 'close', ), () => performPreviewClose(kind, tabKey)); - }, [ - getPreviewTabState, - performPreviewClose, - previewContext, - previewTabsForCloseAction, - requestPreviewApproval, - ]); + }); - const closePreview = useCallback((tabKey) => { + const closePreview = useStableCallback((tabKey) => { requestPreviewClose('one', tabKey); - }, [requestPreviewClose]); + }); - const closeAllPreviews = useCallback(() => { + const closeAllPreviews = useStableCallback(() => { requestPreviewClose('all'); - }, [requestPreviewClose]); + }); - const closeOtherPreviews = useCallback((tabKey) => { + const closeOtherPreviews = useStableCallback((tabKey) => { requestPreviewClose('others', tabKey); - }, [requestPreviewClose]); + }); - const closePreviewsToRight = useCallback((tabKey) => { + const closePreviewsToRight = useStableCallback((tabKey) => { requestPreviewClose('right', tabKey); - }, [requestPreviewClose]); + }); - const hidePreviewPanel = useCallback(() => { + const hidePreviewPanel = useStableCallback(() => { return runAfterFileApproval(requestActiveFileLeave(), () => setPreviewPanelHidden(true)); - }, [requestActiveFileLeave]); + }); - const reorderPreview = useCallback((sourceKey, targetKey, placement) => { + const reorderPreview = useStableCallback((sourceKey, targetKey, placement) => { setPreviewTabState((prev) => reorderPreviewTab(prev, { ...previewContext, sourceKey, targetKey, placement, })); - }, [previewContext]); + }); // Home and auxiliary pages share the same workspace navigation and previews. let homeContent = null; diff --git a/web/src/lib/chatFileDropFeedbackArchitecture.test.js b/web/src/lib/chatFileDropFeedbackArchitecture.test.js index ec848c56a..9315516c1 100644 --- a/web/src/lib/chatFileDropFeedbackArchitecture.test.js +++ b/web/src/lib/chatFileDropFeedbackArchitecture.test.js @@ -100,10 +100,10 @@ run('desktop file drag activates on entry and acceptance without async foregroun run('ChatView stages home attachments without creating or navigating a session', () => { const chatView = source('components/ChatView.jsx'); - const handleStart = chatView.indexOf('const handleMediaFiles = useCallback((files) => {'); + const handleStart = chatView.indexOf('const handleMediaFiles = useStableCallback((files) => {'); const handleEnd = chatView.indexOf('\n\n const removeComposerAttachment', handleStart); const handleFlow = chatView.slice(handleStart, handleEnd); - const submitStart = chatView.indexOf('const submit = useCallback((text) => {'); + const submitStart = chatView.indexOf('const submit = useStableCallback((text) => {'); const submitEnd = chatView.indexOf('\n\n const drainQueuedInput', submitStart); const submitFlow = chatView.slice(submitStart, submitEnd); diff --git a/web/src/lib/composerEditabilityArchitecture.test.js b/web/src/lib/composerEditabilityArchitecture.test.js index a45c98cac..97525464d 100644 --- a/web/src/lib/composerEditabilityArchitecture.test.js +++ b/web/src/lib/composerEditabilityArchitecture.test.js @@ -96,7 +96,7 @@ run('提问挂起时 composer 整体让位给提问框,不留插话入口', () = run('排队卡片的插话在提问挂起时仍走提问插话,不打断回合', () => { const chatView = source('components/ChatView.jsx'); const guideFlow = chatView.slice( - chatView.indexOf('const guideQueued = useCallback((queuedId) => {'), + chatView.indexOf('const guideQueued = useStableCallback((queuedId) => {'), chatView.indexOf('const executeBuiltinCommand = useCallback('), ); assert.ok(guideFlow.includes('api.interjectQuestion(pendingQuestion.sid, {')); diff --git a/web/src/lib/composerSessionControlsArchitecture.test.js b/web/src/lib/composerSessionControlsArchitecture.test.js index e369d3fdc..113ca761d 100644 --- a/web/src/lib/composerSessionControlsArchitecture.test.js +++ b/web/src/lib/composerSessionControlsArchitecture.test.js @@ -146,7 +146,7 @@ run('swarm and expert selections survive submission until explicitly changed', ( const cleanupStart = chatView.indexOf('const clearComposerExtras = useCallback'); const resetStart = chatView.indexOf('const resetComposerContextSelections = useCallback'); - const createStart = chatView.indexOf('const createHomeComposerSession = useCallback'); + const createStart = chatView.indexOf('const createHomeComposerSession = useStableCallback'); assert.ok(cleanupStart >= 0 && resetStart > cleanupStart && createStart > resetStart); const submissionCleanup = chatView.slice(cleanupStart, resetStart); assert.doesNotMatch( @@ -159,8 +159,8 @@ run('swarm and expert selections survive submission until explicitly changed', ( assert.match(chatView, /resetComposerContextSelections\(\)/); assert.doesNotMatch(chatView, /clearComposerExtras\(\{[^)]*preserveSwarm/); - const submitStart = chatView.indexOf('const submit = useCallback'); - const drainStart = chatView.indexOf('const drainQueuedInput = useCallback'); + const submitStart = chatView.indexOf('const submit = useStableCallback'); + const drainStart = chatView.indexOf('const drainQueuedInput = useStableCallback'); assert.ok(submitStart >= 0 && drainStart > submitStart); assert.doesNotMatch( chatView.slice(submitStart, drainStart), @@ -300,7 +300,7 @@ run('active refresh forces model reload while home refresh keeps defaults-only b // 触发场景:首页没有 session id;期望仍只刷新模型列表、默认模型和权限, // 不能创建会话或调用 session reload。 - const refreshBody = chatView.match(/const refreshSessionModels = useCallback\(async \(\) => \{([\s\S]*?)\n \}, \[api,/s)?.[1] || ''; + const refreshBody = chatView.match(/const refreshSessionModels = useStableCallback\(async \(\) => \{([\s\S]*?)\n \}, \[api,/s)?.[1] || ''; assert.match(refreshBody, /api\.getDefaultModel\(\)/); assert.match(refreshBody, /api\.getDefaultPermissionMode\(\)/); assert.doesNotMatch(refreshBody, /createSession|switchModel/); diff --git a/web/src/lib/expertPrefill.test.js b/web/src/lib/expertPrefill.test.js index 174963c3c..7f9902d5a 100644 --- a/web/src/lib/expertPrefill.test.js +++ b/web/src/lib/expertPrefill.test.js @@ -31,7 +31,8 @@ function componentSource(path, name) { const node = component.body.body.flatMap((statement) => statement.declarations || []) .find((declaration) => declaration.id.name === variable); assert.ok(node, `Missing production callback ${variable}`); - return text(node.init.callee?.name === 'useCallback' ? node.init.arguments[0] : node.init); + const wrapped = ['useCallback', 'useStableCallback'].includes(node.init.callee?.name); + return text(wrapped ? node.init.arguments[0] : node.init); }, effects: component.body.body.filter((node) => node.expression?.callee?.name === 'useEffect') .map((node) => node.expression.arguments), diff --git a/web/src/lib/goalStatusBarArchitecture.test.js b/web/src/lib/goalStatusBarArchitecture.test.js index 4b10af5d3..1477c1e13 100644 --- a/web/src/lib/goalStatusBarArchitecture.test.js +++ b/web/src/lib/goalStatusBarArchitecture.test.js @@ -63,7 +63,7 @@ run('chat actions use the existing goal command route and retain authoritative e run('running-turn interrupt remains separate from goal pause and resume', () => { const controls = source('lib/goalControl.js'); const chatView = source('components/ChatView.jsx'); - const stopStart = chatView.indexOf('const stopCurrentWork = useCallback'); + const stopStart = chatView.indexOf('const stopCurrentWork = useStableCallback'); const stopEnd = chatView.indexOf('const runGoalCommand', stopStart); const stopHandler = chatView.slice(stopStart, stopEnd); diff --git a/web/src/lib/modelReasoningSync.test.js b/web/src/lib/modelReasoningSync.test.js index 072f33812..2cddc6c6c 100644 --- a/web/src/lib/modelReasoningSync.test.js +++ b/web/src/lib/modelReasoningSync.test.js @@ -80,5 +80,5 @@ const app = readFileSync(new URL('../App.jsx', import.meta.url), 'utf8'); assert.match(app, /subscribeModelProfileUpdates\(connection,[\s\S]*?setModelProfileRevision/); const chat = readFileSync(new URL('../components/ChatView.jsx', import.meta.url), 'utf8'); assert.match(chat, /useEffect\(\(\) => \{\s*if \(sid\) return;[\s\S]*?requestSavedModelReasoningSync\(api\);\s*\}, \[api, sid\]\)/); -assert.match(chat, /const refreshSessionModels = useCallback\(async \(\) => \{[\s\S]*?requestSavedModelReasoningSync\(api\)/); +assert.match(chat, /const refreshSessionModels = useStableCallback\(async \(\) => \{[\s\S]*?requestSavedModelReasoningSync\(api\)/); console.log('modelReasoningSync.test.js: all tests passed'); diff --git a/web/src/lib/newSessionFirstUserMessage.test.js b/web/src/lib/newSessionFirstUserMessage.test.js index 689a4389c..63c6055c2 100644 --- a/web/src/lib/newSessionFirstUserMessage.test.js +++ b/web/src/lib/newSessionFirstUserMessage.test.js @@ -137,8 +137,8 @@ run('缺少 session 或非空文本时不创建临时行', () => { run('架构: 只在主页首发接入临时行并在 session 提升前记录', () => { const chat = readFileSync(new URL('../components/ChatView.jsx', import.meta.url), 'utf8') .replace(/\r\n?/g, '\n'); - const createStart = chat.indexOf('const createHomeComposerSession = useCallback'); - const createEnd = chat.indexOf('const stageMediaFiles = useCallback', createStart); + const createStart = chat.indexOf('const createHomeComposerSession = useStableCallback'); + const createEnd = chat.indexOf('const stageMediaFiles = useStableCallback', createStart); const createFlow = chat.slice(createStart, createEnd); const homeStart = chat.indexOf('if (!sid) {', chat.indexOf('const isBuiltin =')); const activeStart = chat.indexOf('if (composerSubmitting) return;', homeStart); diff --git a/web/src/lib/pastedTextArchitecture.test.js b/web/src/lib/pastedTextArchitecture.test.js index 8ab683ec5..70d93fb7a 100644 --- a/web/src/lib/pastedTextArchitecture.test.js +++ b/web/src/lib/pastedTextArchitecture.test.js @@ -116,14 +116,14 @@ run('ChatView:正文拼接、唯一导入点与按 payload 路由', () => { assert.match(normalize, /text: appendPastedTextToSubmission\(sessionReferences\.displayText, content\)/); assert.match(normalize, /store: item\.store, store_scope: item\.store_scope/); - const send = between(chatView, 'const sendInputOrBuiltin = useCallback(', 'const submit = useCallback('); + const send = between(chatView, 'const sendInputOrBuiltin = useCallback(', 'const submit = useStableCallback('); before(send, 'materializeWorkspaceDraftPastes(', 'api.sendInput('); assert.match(send, /inputRouteForPayload\(requestPayload\)/); const turn = between(chatView, "if (route.kind === 'turn_steer') {", 'const isBuiltin = '); before(turn, 'materializeWorkspaceDraftPastes(', 'api.interruptTurn('); - const submit = between(chatView, 'const submit = useCallback(', 'const drainQueuedInput = useCallback('); + const submit = between(chatView, 'const submit = useStableCallback(', 'const drainQueuedInput = useStableCallback('); assert.match(submit, /const route = inputRouteForPayload\(payload\)/); before(submit, "route.kind === 'paste_too_long'", "route.kind === 'desktop_feedback'"); before(submit, "route.kind === 'paste_too_long'", "route.kind === 'side_question'"); @@ -134,10 +134,10 @@ run('ChatView:正文拼接、唯一导入点与按 payload 路由', () => { // 连同 /turn 被折叠成粘贴块,发出去成了普通消息。 assert.doesNotMatch(chatView, /recordInputHistory\(route\.display_text\)/); assert.match(submit, /recordHistory: true,\s*historyText,/); - const side = between(chatView, 'const runSideQuestion = useCallback(', 'const openSideQuestionComposer'); + const side = between(chatView, 'const runSideQuestion = useStableCallback(', 'const openSideQuestionComposer'); assert.match(side, /typeof historyText === 'string' \? historyText :/); - const drain = between(chatView, 'const drainQueuedInput = useCallback(', 'sendInputOrBuiltin(targetSid, sendPayload)'); + const drain = between(chatView, 'const drainQueuedInput = useStableCallback(', 'sendInputOrBuiltin(targetSid, sendPayload)'); assert.match(drain, /inputRouteForPayload\(/); assert.doesNotMatch(chatView, /inputRouteForText\(/, '所有路由都按 payload 判定'); @@ -152,14 +152,14 @@ run('ChatView:正文拼接、唯一导入点与按 payload 路由', () => { // 期望行为:persistOne 从 File 上取描述(WeakMap),上传体带 origin/paste,回填资源带 paste; // 粘贴分段顺序上传(并发会把服务端峰值内存放大到 GB 级)。 run('ChatView:上传从 File 取粘贴描述,回填资源带 paste,分段顺序上传', () => { - const persist = between(chatView, 'const persistMediaFilesToSession = useCallback(', 'const handleMediaFiles = useCallback('); + const persist = between(chatView, 'const persistMediaFilesToSession = useCallback(', 'const handleMediaFiles = useStableCallback('); assert.match(persist, /const paste = pastedTextFileMeta\(file\)/); assert.match(persist, /\.\.\.\(paste \? pastedTextUploadBody\(paste\) : \{\}\)/); assert.match(persist, /timeoutMs: PASTED_TEXT_UPLOAD_TIMEOUT_MS/); assert.match(persist, /\{ \.\.\.uploadedItem, preview_url: item\.preview_url \|\| '', \.\.\.\(paste \? \{ paste \} : \{\}\) \}/); assert.match(persist, /for \(const index of pasteIndexes\)/); - const draftUpload = between(chatView, 'const persistPastedTextToWorkspaceDraft = useCallback(', 'const uploadPasteReservations = useCallback('); + const draftUpload = between(chatView, 'const persistPastedTextToWorkspaceDraft = useStableCallback(', 'const uploadPasteReservations = useStableCallback('); assert.match(draftUpload, /api\.uploadWorkspaceDraftAttachment\(scope,/); assert.match(draftUpload, /onHomeComposerDraftPatch\?\.\(hash, \(draft\) => reconcileHomeDraftUpload\(draft, uploadedItem, scope\), api\)/); assert.match(app, /onHomeComposerDraftPatch=\{patchHomeComposerDraft\}/); @@ -178,7 +178,7 @@ run('ChatView:旧长文本折叠的文件块上传完成前不覆盖服务端草 assert.match(restore, /legacyFoldGuardRef\.current = null/); assert.match(restore, /handleLargeTextPasteRef\.current\(storedText, \{ legacyDraft: true \}\)/); - const classify = between(chatView, 'const handleLargeTextPaste = useCallback(', 'handleLargeTextPasteRef.current = handleLargeTextPaste'); + const classify = between(chatView, 'const handleLargeTextPaste = useStableCallback(', 'handleLargeTextPasteRef.current = handleLargeTextPaste'); before(classify, 'if (legacyDraft) legacyFoldGuardRef.current =', 'stagePastedTextFiles(reservedFiles', 'guard must be registered before staging triggers the home draft write'); diff --git a/web/src/lib/previewWorkbenchArchitecture.test.js b/web/src/lib/previewWorkbenchArchitecture.test.js index b5aa4c742..4e04c5f4d 100644 --- a/web/src/lib/previewWorkbenchArchitecture.test.js +++ b/web/src/lib/previewWorkbenchArchitecture.test.js @@ -71,7 +71,7 @@ run('preview file action uses the native single-file picker when present and the // add-web-path-picker:没有 bridge 时退到 web 路径选择器的文件模式,按钮不再随 bridge 隐藏。 assert.match(picker, /export async function pickPreviewFile\(/); assert.match(picker, /webPicker\(\{ mode: 'file', initialPath: String\(cwd \|\| ''\), purpose: 'preview'/); - assert.match(chat, /const openPreviewFilePicker = useCallback\(async/); + assert.match(chat, /const openPreviewFilePicker = useStableCallback\(async/); assert.match(chat, /pickPreviewFile\(sidePanelCwd, \{ api \}\)/); assert.match(chat, /openFilePreview\(picked\.path\)/); assert.match(chat, /onOpenFile=\{sidePanelCwd \? openPreviewFilePicker : null\}/); @@ -92,7 +92,7 @@ run('details close button hides the panel without closing tabs or Browser pages' const closeButtonStart = preview.lastIndexOf('', closeTitleIndex); const closeAction = preview.slice(closeButtonStart, closeButtonEnd); - const hideCallback = between(chat, 'const hidePreviewPanel = useCallback', 'const reorderPreview'); + const hideCallback = between(chat, 'const hidePreviewPanel = useStableCallback', 'const reorderPreview'); assert.match(closeAction, /onClick=\{onHide\}/); assert.doesNotMatch(closeAction, /onCloseAll/); diff --git a/web/src/lib/runTests.js b/web/src/lib/runTests.js index 0eebff587..c4a82beb4 100644 --- a/web/src/lib/runTests.js +++ b/web/src/lib/runTests.js @@ -262,7 +262,7 @@ import './desktopCloseBehavior.test.js'; import './desktopGuidedTour.test.js'; import './desktopTrayMenu.test.js'; import './structuralState.test.js'; -import './sidebarStableCallbacksArchitecture.test.js'; +import './stableCallbacksArchitecture.test.js'; import './opencodeImport.test.js'; import './updateJob.test.js'; import './newSession.test.js'; diff --git a/web/src/lib/sessionCreationActionGuardsArchitecture.test.js b/web/src/lib/sessionCreationActionGuardsArchitecture.test.js index 86a37506c..5c33abe8c 100644 --- a/web/src/lib/sessionCreationActionGuardsArchitecture.test.js +++ b/web/src/lib/sessionCreationActionGuardsArchitecture.test.js @@ -49,7 +49,7 @@ test('chat fork is single-flight and the originating message owns persistent loa const styles = source('styles/globals.css'); const forkFlow = section( chat, - 'const forkAndSwitch = useCallback(async (messageId) => {', + 'const forkAndSwitch = useStableCallback(async (messageId) => {', '\n\n useEffect(() => {', ); diff --git a/web/src/lib/sessionHeaderContextMenu.test.js b/web/src/lib/sessionHeaderContextMenu.test.js index b7d669bc5..4c6a33b5f 100644 --- a/web/src/lib/sessionHeaderContextMenu.test.js +++ b/web/src/lib/sessionHeaderContextMenu.test.js @@ -180,7 +180,7 @@ test('ChatView 的按钮左键与顶栏右键共用同一个菜单构造,重命 const chat = source('components/ChatView.jsx'); assert.match(chat, /window\.addEventListener\(CONTEXT_MENU_DELEGATE_EVENT, handler\)/); assert.match(chat, /placement: 'pointer'/); - assert.match(chat, /const openSessionContextMenu = useCallback\(\(event\) => \{[\s\S]*?openSessionMenu\(/); + assert.match(chat, /const openSessionContextMenu = useStableCallback\(\(event\) => \{[\s\S]*?openSessionMenu\(/); assert.match(chat, /\[DESKTOP_CONTEXT_ACTIONS\.RENAME_SESSION\]: startHeaderRename/); assert.match(chat, /renaming=\{headerRenaming/); }); diff --git a/web/src/lib/sidebarStableCallbacksArchitecture.test.js b/web/src/lib/sidebarStableCallbacksArchitecture.test.js deleted file mode 100644 index 0879e5d09..000000000 --- a/web/src/lib/sidebarStableCallbacksArchitecture.test.js +++ /dev/null @@ -1,78 +0,0 @@ -// 侧栏回调的结构守卫。 -// -// 背景:Desktop 挂机一两天后渲染进程内存耗尽。侧栏里依赖各异的 useCallback 在不同 -// 渲染里交替重建,V8 让同一次渲染的闭包共享一个作用域,旧回调把旧渲染的整个作用域 -// (含当时的会话列表)留住,一份接一份连成链,随渲染次数无限增长。修法是依赖会变的 -// 回调一律用 useStableCallback;这里保证 Sidebar 里剩下的 useCallback 依赖全是稳定的 -// (setter、ref、其它稳定回调),身份从第一次渲染起就不再变化,不会接链。 - -import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; -import { parseSync } from '@babel/core'; - -const source = readFileSync(new URL('../components/Sidebar.jsx', import.meta.url), 'utf8'); -const ast = parseSync(source, { configFile: false, babelrc: false, parserOpts: { plugins: ['jsx'] } }); -const component = ast.program.body.map((node) => node.declaration || node) - .find((node) => node.id?.name === 'Sidebar'); -assert.ok(component, 'Missing production Sidebar'); - -const STATE_HOOKS = new Set(['useState', 'useStructuralState', 'usePreference', 'useReducer']); -const calleeName = (init) => (init?.type === 'CallExpression' && init.callee.type === 'Identifier' - ? init.callee.name : ''); - -function stableCallbackReport() { - const stable = new Set(); - const unstable = []; - let stableCallbacks = 0; - for (const statement of component.body.body) { - if (statement.type !== 'VariableDeclaration') continue; - for (const declaration of statement.declarations) { - const hook = calleeName(declaration.init); - if (declaration.id.type === 'ArrayPattern' && STATE_HOOKS.has(hook)) { - const setter = declaration.id.elements[1]; - if (setter?.type === 'Identifier') stable.add(setter.name); - continue; - } - if (declaration.id.type !== 'Identifier') continue; - const name = declaration.id.name; - if (hook === 'useRef' || hook === 'useStableCallback') { - stable.add(name); - if (hook === 'useStableCallback') stableCallbacks += 1; - continue; - } - if (hook !== 'useCallback') continue; - const deps = declaration.init.arguments[1]; - assert.equal(deps?.type, 'ArrayExpression', `${name} must list its dependencies`); - const changing = deps.elements - .map((element) => source.slice(element.start, element.end)) - .filter((dep) => !stable.has(dep) && !/Ref$/.test(dep)); - if (changing.length === 0) stable.add(name); - else unstable.push(`${name} [${changing.join(', ')}]`); - } - } - return { unstable, stableCallbacks }; -} - -function run(name, fn) { - try { - fn(); - console.log(`[pass] ${name}`); - } catch (error) { - console.error(`[fail] ${name}`); - throw error; - } -} - -// 场景:有人在侧栏里新增或改回 useCallback(fn, [会变的状态或 props])。 -// 期望:失败并列出回调名与会变的依赖 —— 应改用 useStableCallback。 -run('Sidebar 的 useCallback 只依赖稳定引用', () => { - const { unstable } = stableCallbackReport(); - assert.deepEqual(unstable, [], `改用 useStableCallback:\n${unstable.join('\n')}`); -}); - -// 场景:把 useStableCallback 全部撤掉、改回普通 useCallback 并去掉依赖数组。 -// 期望:失败 —— 上一条用例只在稳定回调确实存在时才有意义。 -run('Sidebar 依赖会变的回调已改用 useStableCallback', () => { - const { stableCallbacks } = stableCallbackReport(); - assert.ok(stableCallbacks >= 13, `expected the converted callbacks, found ${stableCallbacks}`); -}); diff --git a/web/src/lib/singleWriterOwnershipArchitecture.test.js b/web/src/lib/singleWriterOwnershipArchitecture.test.js index 6c012662b..d4699bf6d 100644 --- a/web/src/lib/singleWriterOwnershipArchitecture.test.js +++ b/web/src/lib/singleWriterOwnershipArchitecture.test.js @@ -105,7 +105,7 @@ run('updateQueueState 只转发 producer 给 store', () => { // 取件与标记 sending 必须原子:拆开时这中间的取消/编辑会被过期快照覆盖。 run('drainQueuedInput 在单次提交内取件并标记 sending', () => { const chatView = source('components/ChatView.jsx'); - const start = chatView.indexOf('const drainQueuedInput = useCallback'); + const start = chatView.indexOf('const drainQueuedInput = useStableCallback'); assert.notEqual(start, -1, '找不到 drainQueuedInput'); const block = chatView.slice(start, chatView.indexOf('\n }, [applyEvent, busy,', start)); diff --git a/web/src/lib/stableCallbacksArchitecture.test.js b/web/src/lib/stableCallbacksArchitecture.test.js new file mode 100644 index 000000000..95b535b39 --- /dev/null +++ b/web/src/lib/stableCallbacksArchitecture.test.js @@ -0,0 +1,121 @@ +// 大组件回调的结构守卫(Sidebar / ChatView / App)。 +// +// 背景:Desktop 挂机一两天后渲染进程内存耗尽。组件里依赖各异的 useCallback 在不同渲染里 +// 交替重建,V8 让同一次渲染的闭包共享一个作用域,旧回调把旧渲染的整个作用域(含当时的会话 +// 列表、transcript 条目)留住,一份接一份连成链,随渲染次数无限增长。修法是依赖会变的回调 +// 一律用 useStableCallback;这里保证剩下的 useCallback 依赖全是稳定的(setter、ref、取自 ref +// 的值、其它稳定回调),或者在白名单里写明了为什么必须保留。 + +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { parseSync } from '@babel/core'; + +const STATE_HOOKS = new Set(['useState', 'useStructuralState', 'usePreference', 'useReducer']); + +// 必须保留 useCallback 的回调,值是原因。 +const KEEP = { + Sidebar: {}, + ChatView: { + // 绑定会话:submit 等异步流程在 await 之后调用它们,必须拿到发起那一刻渲染里的版本; + // 改成稳定引用会在用户中途切会话时作用到新会话上(ChatView 不随会话重新挂载)。 + persistDraftValue: '离开会话的 effect 清理里保存旧会话草稿,要用旧会话的 api', + clearCurrentSessionDraft: '提交成功后清草稿,绑定发起时的会话', + recordInputHistory: '提交后写输入历史,绑定发起时的工作区', + executeBuiltinCommand: '提交中途执行内置命令,绑定发起时的会话', + materializeWorkspaceDraftPastes: '提交中途上传草稿里的粘贴块,绑定发起时的工作区', + sendInputOrBuiltin: '提交中途发送输入,绑定发起时的会话', + persistMediaFilesToSession: '提交中途上传附件,绑定发起时的会话', + handleComposerChange: '上传完成后回填输入框,绑定发起时的会话', + openFilePreview: '文件选择器关闭后打开预览,绑定发起时的会话预览区', + showBrowserPage: '浏览器页创建完成后显示,绑定发起时的会话预览区', + }, + App: { + setStoredUiPrefs: '作为参数交给 usePreviewPresentation', + applyAppearance: '作为 useRef 的初值', + }, +}; +const MIN_STABLE = { Sidebar: 13, ChatView: 100, App: 34 }; +const FILES = { Sidebar: '../components/Sidebar.jsx', ChatView: '../components/ChatView.jsx', App: '../App.jsx' }; + +function report(name) { + const source = readFileSync(new URL(FILES[name], import.meta.url), 'utf8'); + const ast = parseSync(source, { configFile: false, babelrc: false, parserOpts: { plugins: ['jsx'] } }); + const component = ast.program.body.map((node) => node.declaration || node) + .find((node) => node.id?.name === name); + assert.ok(component, `Missing production component ${name}`); + const calleeName = (init) => (init?.type === 'CallExpression' && init.callee.type === 'Identifier' + ? init.callee.name : ''); + const stable = new Set(); + const callbacks = new Set(); + const unstable = []; + let stableCallbacks = 0; + for (const statement of component.body.body) { + if (statement.type !== 'VariableDeclaration') continue; + for (const declaration of statement.declarations) { + const hook = calleeName(declaration.init); + if (declaration.id.type === 'ArrayPattern' && STATE_HOOKS.has(hook)) { + const setter = declaration.id.elements[1]; + if (setter?.type === 'Identifier') stable.add(setter.name); + continue; + } + if (declaration.id.type !== 'Identifier') continue; + const id = declaration.id.name; + const init = declaration.init; + if (hook === 'useRef' || hook === 'useStableCallback') { + stable.add(id); + if (hook === 'useStableCallback') stableCallbacks += 1; + continue; + } + if (init?.type === 'MemberExpression' && init.property.name === 'current' + && init.object.type === 'Identifier' && /Ref$/.test(init.object.name)) { + stable.add(id); + continue; + } + if (hook !== 'useCallback') continue; + callbacks.add(id); + const deps = init.arguments[1]; + assert.equal(deps?.type, 'ArrayExpression', `${name}.${id} must list its dependencies`); + const changing = deps.elements + .map((element) => source.slice(element.start, element.end)) + .filter((dep) => !stable.has(dep) && !/Ref$/.test(dep)); + if (changing.length === 0) stable.add(id); + else if (!Object.hasOwn(KEEP[name], id)) unstable.push(`${id} [${changing.join(', ')}]`); + } + } + return { unstable, stableCallbacks, callbacks }; +} + +function run(label, fn) { + try { + fn(); + console.log(`[pass] ${label}`); + } catch (error) { + console.error(`[fail] ${label}`); + throw error; + } +} + +for (const name of Object.keys(FILES)) { + // 场景:有人新增或改回 useCallback(fn, [会变的状态或 props])。 + // 期望:失败并列出回调名与会变的依赖 —— 应改用 useStableCallback,或在 KEEP 里写明原因。 + run(`${name} 的 useCallback 只依赖稳定引用`, () => { + const { unstable } = report(name); + assert.deepEqual(unstable, [], `改用 useStableCallback:\n${unstable.join('\n')}`); + }); + + // 场景:白名单里的回调被删除、改名或已经改成 useStableCallback。 + // 期望:失败,提示清理白名单 —— 过期条目会掩盖之后真正需要审查的回调。 + run(`${name} 的保留名单与源码一致`, () => { + const { callbacks } = report(name); + for (const id of Object.keys(KEEP[name])) { + assert.ok(callbacks.has(id), `${name}.${id} 已不是 useCallback,从 KEEP 里删掉`); + } + }); + + // 场景:把 useStableCallback 全部撤掉、改回普通 useCallback 并去掉依赖数组。 + // 期望:失败 —— 上面的检查只在改造确实存在时才有意义。 + run(`${name} 依赖会变的回调已改用 useStableCallback`, () => { + const { stableCallbacks } = report(name); + assert.ok(stableCallbacks >= MIN_STABLE[name], `expected at least ${MIN_STABLE[name]}, found ${stableCallbacks}`); + }); +} diff --git a/web/src/lib/turnInterruptionArchitecture.test.js b/web/src/lib/turnInterruptionArchitecture.test.js index 6cd328607..afc287540 100644 --- a/web/src/lib/turnInterruptionArchitecture.test.js +++ b/web/src/lib/turnInterruptionArchitecture.test.js @@ -30,7 +30,7 @@ test('queue-card and slash interjection both use the immediate interrupt endpoin const chat = source('components/ChatView.jsx'); const guideFlow = section( chat, - 'const guideQueued = useCallback((queuedId) => {', + 'const guideQueued = useStableCallback((queuedId) => {', '\n\n const executeBuiltinCommand', ); const slashFlow = section(