Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions crates/agent-gui/test/chat/sidebar-selection.test.mjs
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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/);
});
45 changes: 43 additions & 2 deletions crates/agent-ui/src/components/chat/ChatHistorySidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<number | null>(null);
const longPressStartRef = useRef<{ x: number; y: number } | null>(null);
const longPressTriggeredRef = useRef(false);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 ? (
Expand All @@ -879,7 +902,7 @@ const HistoryRow = memo(function HistoryRow(props: HistoryRowProps) {
</DropdownMenuItem>
<DropdownMenuItem
disabled={isInteractionDisabled}
onSelect={handleStartRenaming}
onSelect={handleStartRenamingFromMenu}
className="gap-2"
>
<Edit3 className="h-3.5 w-3.5" />
Expand Down Expand Up @@ -1002,6 +1025,9 @@ const ProjectRow = memo(function ProjectRow(props: {
const rowRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(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;
Expand All @@ -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;
Expand Down Expand Up @@ -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"
>
<DropdownMenuItem
Expand All @@ -1363,7 +1404,7 @@ const ProjectRow = memo(function ProjectRow(props: {
<>
<DropdownMenuItem
disabled={isInteractionDisabled}
onSelect={() => onStartRenamingProject(project)}
onSelect={handleStartRenamingFromMenu}
className="gap-2"
>
<Edit3 className="h-3.5 w-3.5" />
Expand Down
Loading