diff --git a/CLAUDE.md b/CLAUDE.md index 2a263b2b..eecc769e 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 c2fe8a13..c99aea81 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 d09e2235..1bf36317 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 ec848c56..9315516c 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 a45c98ca..97525464 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 e369d3fd..113ca761 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 174963c3..7f9902d5 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 4b10af5d..1477c1e1 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 072f3381..2cddc6c6 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 689a4389..63c6055c 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 8ab683ec..70d93fb7 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 b5aa4c74..4e04c5f4 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 0eebff58..c4a82beb 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 86a37506..5c33abe8 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 b7d669bc..4c6a33b5 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 0879e5d0..00000000 --- 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 6c012662..d4699bf6 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 00000000..95b535b3 --- /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 6cd32860..afc28754 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(