From 0de466b20ca4db99ab68273c6878bed4412aa805 Mon Sep 17 00:00:00 2001 From: devil233-ui Date: Mon, 10 Aug 2026 20:02:28 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=90=9B=20fix(sidebar):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8DWindows=E8=8F=9C=E5=8D=95=E6=94=B9=E5=90=8D=E6=97=A0?= =?UTF-8?q?=E5=93=8D=E5=BA=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 会话与工作空间通过下拉菜单进入改名时,Base UI关闭菜单会归还焦点并触发输入框首次blur,导致原标题立即提交、编辑状态退出。 仅对菜单触发的改名忽略这次焦点交接,保留双击改名和正常失焦提交行为;补充源码回归测试。 验证:TypeScript类型检查通过;sidebar-selection测试8/8通过;目标UI文件Biome检查通过。Vite完整构建因执行工具30秒上限未完成。 --- .../test/chat/sidebar-selection.test.mjs | 16 ++++++ .../components/chat/ChatHistorySidebar.tsx | 50 +++++++++++++++++-- 2 files changed, 62 insertions(+), 4 deletions(-) diff --git a/crates/agent-gui/test/chat/sidebar-selection.test.mjs b/crates/agent-gui/test/chat/sidebar-selection.test.mjs index ce49b34c7..c0d36865f 100644 --- a/crates/agent-gui/test/chat/sidebar-selection.test.mjs +++ b/crates/agent-gui/test/chat/sidebar-selection.test.mjs @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; import test from "node:test"; import { createTsModuleLoader } from "../helpers/load-ts-module.mjs"; @@ -126,3 +127,18 @@ test("a stop request halts the batch and reports the rest as skipped", async () assert.deepEqual(result.failedIds, []); assert.deepEqual(result.skippedIds, ["two", "three"]); }); + +test("rename menu guards the menu-close blur without changing double-click rename", () => { + const source = readFileSync( + new URL("../../../agent-ui/src/components/chat/ChatHistorySidebar.tsx", import.meta.url), + "utf8", + ); + + assert.match( + source, + /const handleStartRenamingFromMenu = useCallback\(\(\) => \{[\s\S]*?ignoreMenuCloseBlurRef\.current = true;[\s\S]*?onStartRenaming\(item\);/, + ); + assert.match(source, /onDoubleClick=\{\(event\) => \{[\s\S]*?handleStartRenaming\(\);/); + assert.match(source, /onSelect=\{handleStartRenamingFromMenu\}/); + assert.equal((source.match(/if \(ignoreMenuCloseBlurRef\.current\)/g) ?? []).length, 2); +}); diff --git a/crates/agent-ui/src/components/chat/ChatHistorySidebar.tsx b/crates/agent-ui/src/components/chat/ChatHistorySidebar.tsx index 484fc5e56..4f06943ce 100644 --- a/crates/agent-ui/src/components/chat/ChatHistorySidebar.tsx +++ b/crates/agent-ui/src/components/chat/ChatHistorySidebar.tsx @@ -331,6 +331,11 @@ const HistoryRow = memo(function HistoryRow(props: HistoryRowProps) { // Enter/Escape mark the blur as handled so onBlur commits exactly once — // symmetric with ProjectRow's skipNextBlurCommitRef. const skipNextBlurCommitRef = useRef(false); + // The row swaps the dropdown for the rename input, so Base UI hands focus + // back to the now-unmounted trigger right after the menu closes. That focus + // handoff blurred the freshly focused input and committed the untouched + // title, which read as "Rename does nothing" on Windows. + const ignoreMenuCloseBlurRef = useRef(false); const longPressTimerRef = useRef(null); const longPressStartRef = useRef<{ x: number; y: number } | null>(null); const longPressTriggeredRef = useRef(false); @@ -372,6 +377,14 @@ const HistoryRow = memo(function HistoryRow(props: HistoryRowProps) { onStartRenaming(item); }, [isInteractionDisabled, item, onStartRenaming]); + const handleStartRenamingFromMenu = useCallback(() => { + if (isInteractionDisabled) { + return; + } + ignoreMenuCloseBlurRef.current = true; + onStartRenaming(item); + }, [isInteractionDisabled, item, onStartRenaming]); + const handleRequestDelete = useCallback(() => { if (isInteractionDisabled) { return; @@ -584,7 +597,10 @@ const HistoryRow = memo(function HistoryRow(props: HistoryRowProps) { const shouldShowMobilePressFeedback = isMobileMenuLayout && (isLongPressActive || menuOpen); useEffect(() => { - if (!isRenaming) return; + if (!isRenaming) { + ignoreMenuCloseBlurRef.current = false; + return; + } skipNextBlurCommitRef.current = false; inputRef.current?.focus(); inputRef.current?.select(); @@ -658,6 +674,12 @@ const HistoryRow = memo(function HistoryRow(props: HistoryRowProps) { skipNextBlurCommitRef.current = false; return; } + if (ignoreMenuCloseBlurRef.current) { + ignoreMenuCloseBlurRef.current = false; + inputRef.current?.focus(); + inputRef.current?.select(); + return; + } onCommitRename(); }} onKeyDown={(e) => { @@ -879,7 +901,7 @@ const HistoryRow = memo(function HistoryRow(props: HistoryRowProps) { @@ -1002,17 +1024,31 @@ const ProjectRow = memo(function ProjectRow(props: { const rowRef = useRef(null); const inputRef = useRef(null); const skipNextBlurCommitRef = useRef(false); + // Same menu-close focus handoff as HistoryRow: the trigger unmounts when the + // row swaps to the rename input, so the first blur must not commit. + const ignoreMenuCloseBlurRef = useRef(false); const isDefaultProject = project.id === DEFAULT_WORKSPACE_PROJECT_ID; const isPinned = project.isPinned === true; const ProjectFolderIcon = isActive ? FolderOpen : FolderClosed; useEffect(() => { - if (!isRenaming) return; + if (!isRenaming) { + ignoreMenuCloseBlurRef.current = false; + return; + } skipNextBlurCommitRef.current = false; inputRef.current?.focus(); inputRef.current?.select(); }, [isRenaming]); + const handleStartRenamingFromMenu = useCallback(() => { + if (isInteractionDisabled) { + return; + } + ignoreMenuCloseBlurRef.current = true; + onStartRenamingProject(project); + }, [isInteractionDisabled, onStartRenamingProject, project]); + const handleRequestRemove = useCallback(() => { if (isInteractionDisabled) { return; @@ -1148,6 +1184,12 @@ const ProjectRow = memo(function ProjectRow(props: { skipNextBlurCommitRef.current = false; return; } + if (ignoreMenuCloseBlurRef.current) { + ignoreMenuCloseBlurRef.current = false; + inputRef.current?.focus(); + inputRef.current?.select(); + return; + } onCommitProjectRename(); }} onKeyDown={(e) => { @@ -1363,7 +1405,7 @@ const ProjectRow = memo(function ProjectRow(props: { <> onStartRenamingProject(project)} + onSelect={handleStartRenamingFromMenu} className="gap-2" > From 504223a43f2d5e553d9c6619e18c446bb5b85afe Mon Sep 17 00:00:00 2001 From: su-fen <715041@qq.com> Date: Mon, 10 Aug 2026 21:54:48 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(sidebar):=20=E8=8F=9C=E5=8D=95=E6=94=B9?= =?UTF-8?q?=E5=90=8D=E6=94=B9=E7=94=A8=20finalFocus=20=E5=A3=B0=E6=98=8E?= =?UTF-8?q?=E5=BC=8F=E6=8A=91=E5=88=B6=20return-focus?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 吞 blur 的 ignoreMenuCloseBlurRef 守卫没有时间边界:在 menu-close blur 不会发生的平台(macOS/WebUI)上标志位会悬留整个改名过程,把用户第一次 真实的外部点击吞掉(不提交、焦点被抢回并全选)。 改为 Base UI Menu.Popup 的声明式出口:菜单入口置位一次性标志, finalFocus 回调在菜单关闭时(必然发生)消费它并返回 false,从源头不再 排队任何 return-focus;其余菜单关闭路径保持默认的 trigger 回焦。onBlur 恢复原始语义(skip 一次或提交),无悬留状态。 没有直接用 finalFocus={inputRef}:FloatingFocusManager 在卸载 cleanup 里同步解析返回元素(React mutation 阶段,先处理删除),此时新挂载输入 框的 ref 尚未附着(layout 阶段才附着),ref 会解析为 null 并静默回退到 默认行为,Windows 上 bug 依旧。函数形式返回 false 不需要元素,无此时序 问题。 --- .../test/chat/sidebar-selection.test.mjs | 19 ++++-- .../components/chat/ChatHistorySidebar.tsx | 59 +++++++++---------- 2 files changed, 42 insertions(+), 36 deletions(-) diff --git a/crates/agent-gui/test/chat/sidebar-selection.test.mjs b/crates/agent-gui/test/chat/sidebar-selection.test.mjs index c0d36865f..8ab1505cf 100644 --- a/crates/agent-gui/test/chat/sidebar-selection.test.mjs +++ b/crates/agent-gui/test/chat/sidebar-selection.test.mjs @@ -128,17 +128,24 @@ test("a stop request halts the batch and reports the rest as skipped", async () assert.deepEqual(result.skippedIds, ["two", "three"]); }); -test("rename menu guards the menu-close blur without changing double-click rename", () => { +test("menu rename suppresses the menu's return-focus without changing double-click rename", () => { const source = readFileSync( new URL("../../../agent-ui/src/components/chat/ChatHistorySidebar.tsx", import.meta.url), "utf8", ); - assert.match( - source, - /const handleStartRenamingFromMenu = useCallback\(\(\) => \{[\s\S]*?ignoreMenuCloseBlurRef\.current = true;[\s\S]*?onStartRenaming\(item\);/, + // Both menu entries (HistoryRow + ProjectRow) arm the one-shot flag. + assert.equal((source.match(/suppressMenuReturnFocusRef\.current = true;/g) ?? []).length, 2); + assert.equal((source.match(/onSelect=\{handleStartRenamingFromMenu\}/g) ?? []).length, 2); + // Both dropdowns consume it declaratively via Base UI's finalFocus, keeping + // the default trigger return-focus for every other menu close. + assert.equal((source.match(/finalFocus=\{\(\) => \{/g) ?? []).length, 2); + assert.equal( + (source.match(/suppressMenuReturnFocusRef\.current = false;\s*return false;/g) ?? []).length, + 2, ); + // Double-click rename keeps the plain path, and the retired blur-swallowing + // guard must not come back — blur either skips once (Enter/Escape) or commits. assert.match(source, /onDoubleClick=\{\(event\) => \{[\s\S]*?handleStartRenaming\(\);/); - assert.match(source, /onSelect=\{handleStartRenamingFromMenu\}/); - assert.equal((source.match(/if \(ignoreMenuCloseBlurRef\.current\)/g) ?? []).length, 2); + assert.doesNotMatch(source, /ignoreMenuCloseBlurRef/); }); diff --git a/crates/agent-ui/src/components/chat/ChatHistorySidebar.tsx b/crates/agent-ui/src/components/chat/ChatHistorySidebar.tsx index 4f06943ce..3793155ed 100644 --- a/crates/agent-ui/src/components/chat/ChatHistorySidebar.tsx +++ b/crates/agent-ui/src/components/chat/ChatHistorySidebar.tsx @@ -331,11 +331,14 @@ const HistoryRow = memo(function HistoryRow(props: HistoryRowProps) { // Enter/Escape mark the blur as handled so onBlur commits exactly once — // symmetric with ProjectRow's skipNextBlurCommitRef. const skipNextBlurCommitRef = useRef(false); - // The row swaps the dropdown for the rename input, so Base UI hands focus - // back to the now-unmounted trigger right after the menu closes. That focus - // handoff blurred the freshly focused input and committed the untouched - // title, which read as "Rename does nothing" on Windows. - const ignoreMenuCloseBlurRef = useRef(false); + // Renaming from the menu unmounts the whole dropdown in the commit that + // mounts the rename input, and Base UI resolves its return-focus target + // synchronously during that unmount — before the input's ref attaches — so + // the deferred focus() landed on a fallback element, blurring the input and + // committing the untouched title ("rename does nothing" on Windows). The + // menu's finalFocus callback consumes this one-shot flag to skip that + // return-focus entirely; the isRenaming effect owns focus placement instead. + const suppressMenuReturnFocusRef = useRef(false); const longPressTimerRef = useRef(null); const longPressStartRef = useRef<{ x: number; y: number } | null>(null); const longPressTriggeredRef = useRef(false); @@ -381,7 +384,7 @@ const HistoryRow = memo(function HistoryRow(props: HistoryRowProps) { if (isInteractionDisabled) { return; } - ignoreMenuCloseBlurRef.current = true; + suppressMenuReturnFocusRef.current = true; onStartRenaming(item); }, [isInteractionDisabled, item, onStartRenaming]); @@ -597,10 +600,7 @@ const HistoryRow = memo(function HistoryRow(props: HistoryRowProps) { const shouldShowMobilePressFeedback = isMobileMenuLayout && (isLongPressActive || menuOpen); useEffect(() => { - if (!isRenaming) { - ignoreMenuCloseBlurRef.current = false; - return; - } + if (!isRenaming) return; skipNextBlurCommitRef.current = false; inputRef.current?.focus(); inputRef.current?.select(); @@ -674,12 +674,6 @@ const HistoryRow = memo(function HistoryRow(props: HistoryRowProps) { skipNextBlurCommitRef.current = false; return; } - if (ignoreMenuCloseBlurRef.current) { - ignoreMenuCloseBlurRef.current = false; - inputRef.current?.focus(); - inputRef.current?.select(); - return; - } onCommitRename(); }} onKeyDown={(e) => { @@ -875,6 +869,13 @@ const HistoryRow = memo(function HistoryRow(props: HistoryRowProps) { align="start" sideOffset={8} collisionPadding={12} + finalFocus={() => { + if (suppressMenuReturnFocusRef.current) { + suppressMenuReturnFocusRef.current = false; + return false; + } + return true; + }} className="sidebar-context-menu min-w-[10rem] rounded-xl border-border/60 bg-background/95 backdrop-blur-xl" > {isMobileMenuLayout && !item.isPending ? ( @@ -1024,18 +1025,15 @@ const ProjectRow = memo(function ProjectRow(props: { const rowRef = useRef(null); const inputRef = useRef(null); const skipNextBlurCommitRef = useRef(false); - // Same menu-close focus handoff as HistoryRow: the trigger unmounts when the - // row swaps to the rename input, so the first blur must not commit. - const ignoreMenuCloseBlurRef = useRef(false); + // Same menu-unmount return-focus hazard as HistoryRow: the menu's finalFocus + // callback consumes this one-shot flag so the rename input keeps focus. + const suppressMenuReturnFocusRef = useRef(false); const isDefaultProject = project.id === DEFAULT_WORKSPACE_PROJECT_ID; const isPinned = project.isPinned === true; const ProjectFolderIcon = isActive ? FolderOpen : FolderClosed; useEffect(() => { - if (!isRenaming) { - ignoreMenuCloseBlurRef.current = false; - return; - } + if (!isRenaming) return; skipNextBlurCommitRef.current = false; inputRef.current?.focus(); inputRef.current?.select(); @@ -1045,7 +1043,7 @@ const ProjectRow = memo(function ProjectRow(props: { if (isInteractionDisabled) { return; } - ignoreMenuCloseBlurRef.current = true; + suppressMenuReturnFocusRef.current = true; onStartRenamingProject(project); }, [isInteractionDisabled, onStartRenamingProject, project]); @@ -1184,12 +1182,6 @@ const ProjectRow = memo(function ProjectRow(props: { skipNextBlurCommitRef.current = false; return; } - if (ignoreMenuCloseBlurRef.current) { - ignoreMenuCloseBlurRef.current = false; - inputRef.current?.focus(); - inputRef.current?.select(); - return; - } onCommitProjectRename(); }} onKeyDown={(e) => { @@ -1391,6 +1383,13 @@ const ProjectRow = memo(function ProjectRow(props: { side="right" align="start" sideOffset={6} + finalFocus={() => { + if (suppressMenuReturnFocusRef.current) { + suppressMenuReturnFocusRef.current = false; + return false; + } + return true; + }} className="sidebar-context-menu" >