diff --git a/crates/agent-gui/test/chat/sidebar-selection.test.mjs b/crates/agent-gui/test/chat/sidebar-selection.test.mjs index ce49b34c7..8ab1505cf 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,25 @@ 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("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", + ); + + // 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.doesNotMatch(source, /ignoreMenuCloseBlurRef/); +}); diff --git a/crates/agent-ui/src/components/chat/ChatHistorySidebar.tsx b/crates/agent-ui/src/components/chat/ChatHistorySidebar.tsx index 484fc5e56..3793155ed 100644 --- a/crates/agent-ui/src/components/chat/ChatHistorySidebar.tsx +++ b/crates/agent-ui/src/components/chat/ChatHistorySidebar.tsx @@ -331,6 +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); + // 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); @@ -372,6 +380,14 @@ const HistoryRow = memo(function HistoryRow(props: HistoryRowProps) { onStartRenaming(item); }, [isInteractionDisabled, item, onStartRenaming]); + const handleStartRenamingFromMenu = useCallback(() => { + if (isInteractionDisabled) { + return; + } + suppressMenuReturnFocusRef.current = true; + onStartRenaming(item); + }, [isInteractionDisabled, item, onStartRenaming]); + const handleRequestDelete = useCallback(() => { if (isInteractionDisabled) { return; @@ -853,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 ? ( @@ -879,7 +902,7 @@ const HistoryRow = memo(function HistoryRow(props: HistoryRowProps) { @@ -1002,6 +1025,9 @@ const ProjectRow = memo(function ProjectRow(props: { const rowRef = useRef(null); const inputRef = useRef(null); const skipNextBlurCommitRef = 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; @@ -1013,6 +1039,14 @@ const ProjectRow = memo(function ProjectRow(props: { inputRef.current?.select(); }, [isRenaming]); + const handleStartRenamingFromMenu = useCallback(() => { + if (isInteractionDisabled) { + return; + } + suppressMenuReturnFocusRef.current = true; + onStartRenamingProject(project); + }, [isInteractionDisabled, onStartRenamingProject, project]); + const handleRequestRemove = useCallback(() => { if (isInteractionDisabled) { return; @@ -1349,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" > onStartRenamingProject(project)} + onSelect={handleStartRenamingFromMenu} className="gap-2" >