From 9e9578edd3ebf04fca49537571c7e8e08b143410 Mon Sep 17 00:00:00 2001 From: Shivam Sharma <91240327+shivamhwp@users.noreply.github.com> Date: Mon, 30 Mar 2026 03:40:30 +0530 Subject: [PATCH] Add keyboard access to sidebar action menus - Support context-menu key and Shift+F10 on threads and projects - Expose thread and project action menus in the sidebar UI - Factor shared sidebar action handlers into reusable helpers --- apps/web/src/components/Sidebar.logic.test.ts | 36 ++ apps/web/src/components/Sidebar.logic.ts | 4 + apps/web/src/components/Sidebar.tsx | 377 +++++++++++++----- 3 files changed, 315 insertions(+), 102 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index d7f93f371d..632b08957d 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -8,6 +8,7 @@ import { getProjectSortTimestamp, hasUnseenCompletion, isContextMenuPointerDown, + isKeyboardContextMenuKey, resolveProjectStatusIndicator, resolveSidebarNewThreadEnvMode, resolveThreadRowClassName, @@ -205,6 +206,41 @@ describe("isContextMenuPointerDown", () => { }); }); +describe("isKeyboardContextMenuKey", () => { + it("treats the dedicated context-menu key as a context menu trigger", () => { + expect( + isKeyboardContextMenuKey({ + key: "ContextMenu", + shiftKey: false, + }), + ).toBe(true); + }); + + it("treats Shift+F10 as a context menu trigger", () => { + expect( + isKeyboardContextMenuKey({ + key: "F10", + shiftKey: true, + }), + ).toBe(true); + }); + + it("ignores unrelated keyboard events", () => { + expect( + isKeyboardContextMenuKey({ + key: "F10", + shiftKey: false, + }), + ).toBe(false); + expect( + isKeyboardContextMenuKey({ + key: "Enter", + shiftKey: false, + }), + ).toBe(false); + }); +}); + describe("resolveThreadStatusPill", () => { const baseThread = { interactionMode: "plan" as const, diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index f911775446..225e400092 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -117,6 +117,10 @@ export function isContextMenuPointerDown(input: { return input.isMac && input.button === 0 && input.ctrlKey; } +export function isKeyboardContextMenuKey(input: { key: string; shiftKey: boolean }): boolean { + return input.key === "ContextMenu" || (input.key === "F10" && input.shiftKey); +} + export function resolveThreadRowClassName(input: { isActive: boolean; isSelected: boolean; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index dbff140b49..a0c544f3e9 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -2,6 +2,7 @@ import { ArchiveIcon, ArrowUpDownIcon, ChevronRightIcon, + EllipsisIcon, FolderIcon, GitPullRequestIcon, PlusIcon, @@ -85,7 +86,15 @@ import { } from "./desktopUpdate.logic"; import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert"; import { Button } from "./ui/button"; -import { Menu, MenuGroup, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "./ui/menu"; +import { + Menu, + MenuGroup, + MenuItem, + MenuPopup, + MenuRadioGroup, + MenuRadioItem, + MenuTrigger, +} from "./ui/menu"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { SidebarContent, @@ -109,6 +118,7 @@ import { getVisibleThreadsForProject, resolveAdjacentThreadId, isContextMenuPointerDown, + isKeyboardContextMenuKey, resolveProjectStatusIndicator, resolveSidebarNewThreadEnvMode, resolveThreadRowClassName, @@ -405,6 +415,8 @@ export default function Sidebar() { const dragInProgressRef = useRef(false); const suppressProjectClickAfterDragRef = useRef(false); const suppressProjectClickForContextMenuRef = useRef(false); + const threadActionMenuTriggerRefs = useRef(new Map()); + const projectActionMenuTriggerRefs = useRef(new Map()); const [desktopUpdateState, setDesktopUpdateState] = useState(null); const selectedThreadIds = useThreadSelectionStore((s) => s.selectedThreadIds); const toggleThreadSelection = useThreadSelectionStore((s) => s.toggleThread); @@ -723,14 +735,163 @@ export default function Sidebar() { }); }, }); + const setThreadActionMenuTriggerRef = useCallback( + (threadId: ThreadId, node: HTMLButtonElement | null) => { + if (node) { + threadActionMenuTriggerRefs.current.set(threadId, node); + return; + } + threadActionMenuTriggerRefs.current.delete(threadId); + }, + [], + ); + const setProjectActionMenuTriggerRef = useCallback( + (projectId: ProjectId, node: HTMLButtonElement | null) => { + if (node) { + projectActionMenuTriggerRefs.current.set(projectId, node); + return; + } + projectActionMenuTriggerRefs.current.delete(projectId); + }, + [], + ); + const startThreadRename = useCallback((threadId: ThreadId, title: string) => { + setRenamingThreadId(threadId); + setRenamingTitle(title); + renamingCommittedRef.current = false; + }, []); + const copyThreadPath = useCallback( + (threadId: ThreadId) => { + const thread = threads.find((entry) => entry.id === threadId); + if (!thread) return; + const threadWorkspacePath = + thread.worktreePath ?? projectCwdById.get(thread.projectId) ?? null; + if (!threadWorkspacePath) { + toastManager.add({ + type: "error", + title: "Path unavailable", + description: "This thread does not have a workspace path to copy.", + }); + return; + } + copyPathToClipboard(threadWorkspacePath, { path: threadWorkspacePath }); + }, + [copyPathToClipboard, projectCwdById, threads], + ); + const deleteSingleThread = useCallback( + async (threadId: ThreadId) => { + const thread = threads.find((entry) => entry.id === threadId); + if (!thread) return; + const api = readNativeApi(); + if (!api) return; + + if (appSettings.confirmThreadDelete) { + const confirmed = await api.dialogs.confirm( + [ + `Delete thread "${thread.title}"?`, + "This permanently clears conversation history for this thread.", + ].join("\n"), + ); + if (!confirmed) return; + } + + await deleteThread(threadId); + }, + [appSettings.confirmThreadDelete, deleteThread, threads], + ); + const markSelectedThreadsUnread = useCallback(() => { + if (selectedThreadIds.size === 0) return; + for (const id of selectedThreadIds) { + markThreadUnread(id); + } + clearSelection(); + }, [clearSelection, markThreadUnread, selectedThreadIds]); + const deleteSelectedThreads = useCallback(async () => { + const api = readNativeApi(); + if (!api) return; + const ids = [...selectedThreadIds]; + if (ids.length === 0) return; + const count = ids.length; + + if (appSettings.confirmThreadDelete) { + const confirmed = await api.dialogs.confirm( + [ + `Delete ${count} thread${count === 1 ? "" : "s"}?`, + "This permanently clears conversation history for these threads.", + ].join("\n"), + ); + if (!confirmed) return; + } + + const deletedIds = new Set(ids); + for (const id of ids) { + await deleteThread(id, { deletedThreadIds: deletedIds }); + } + removeFromSelection(ids); + }, [appSettings.confirmThreadDelete, deleteThread, removeFromSelection, selectedThreadIds]); + const copyProjectPath = useCallback( + (projectId: ProjectId) => { + const project = projects.find((entry) => entry.id === projectId); + if (!project) return; + copyPathToClipboard(project.cwd, { path: project.cwd }); + }, + [copyPathToClipboard, projects], + ); + const removeProject = useCallback( + async (projectId: ProjectId) => { + const api = readNativeApi(); + if (!api) return; + const project = projects.find((entry) => entry.id === projectId); + if (!project) return; + + const projectThreads = threads.filter((thread) => thread.projectId === projectId); + if (projectThreads.length > 0) { + toastManager.add({ + type: "warning", + title: "Project is not empty", + description: "Delete all threads in this project before removing it.", + }); + return; + } + + const confirmed = await api.dialogs.confirm(`Remove project "${project.name}"?`); + if (!confirmed) return; + + try { + const projectDraftThread = getDraftThreadByProjectId(projectId); + if (projectDraftThread) { + clearComposerDraftForThread(projectDraftThread.threadId); + } + clearProjectDraftThreadId(projectId); + await api.orchestration.dispatchCommand({ + type: "project.delete", + commandId: newCommandId(), + projectId, + }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error removing project."; + console.error("Failed to remove project", { projectId, error }); + toastManager.add({ + type: "error", + title: `Failed to remove "${project.name}"`, + description: message, + }); + } + }, + [ + clearComposerDraftForThread, + clearProjectDraftThreadId, + getDraftThreadByProjectId, + projects, + threads, + ], + ); const handleThreadContextMenu = useCallback( async (threadId: ThreadId, position: { x: number; y: number }) => { const api = readNativeApi(); if (!api) return; const thread = threads.find((t) => t.id === threadId); if (!thread) return; - const threadWorkspacePath = - thread.worktreePath ?? projectCwdById.get(thread.projectId) ?? null; const clicked = await api.contextMenu.show( [ { id: "rename", label: "Rename thread" }, @@ -743,9 +904,7 @@ export default function Sidebar() { ); if (clicked === "rename") { - setRenamingThreadId(threadId); - setRenamingTitle(thread.title); - renamingCommittedRef.current = false; + startThreadRename(threadId, thread.title); return; } @@ -754,15 +913,7 @@ export default function Sidebar() { return; } if (clicked === "copy-path") { - if (!threadWorkspacePath) { - toastManager.add({ - type: "error", - title: "Path unavailable", - description: "This thread does not have a workspace path to copy.", - }); - return; - } - copyPathToClipboard(threadWorkspacePath, { path: threadWorkspacePath }); + copyThreadPath(threadId); return; } if (clicked === "copy-thread-id") { @@ -770,26 +921,14 @@ export default function Sidebar() { return; } if (clicked !== "delete") return; - if (appSettings.confirmThreadDelete) { - const confirmed = await api.dialogs.confirm( - [ - `Delete thread "${thread.title}"?`, - "This permanently clears conversation history for this thread.", - ].join("\n"), - ); - if (!confirmed) { - return; - } - } - await deleteThread(threadId); + await deleteSingleThread(threadId); }, [ - appSettings.confirmThreadDelete, - copyPathToClipboard, + copyThreadPath, copyThreadIdToClipboard, - deleteThread, + deleteSingleThread, markThreadUnread, - projectCwdById, + startThreadRename, threads, ], ); @@ -811,39 +950,14 @@ export default function Sidebar() { ); if (clicked === "mark-unread") { - for (const id of ids) { - markThreadUnread(id); - } - clearSelection(); + markSelectedThreadsUnread(); return; } if (clicked !== "delete") return; - - if (appSettings.confirmThreadDelete) { - const confirmed = await api.dialogs.confirm( - [ - `Delete ${count} thread${count === 1 ? "" : "s"}?`, - "This permanently clears conversation history for these threads.", - ].join("\n"), - ); - if (!confirmed) return; - } - - const deletedIds = new Set(ids); - for (const id of ids) { - await deleteThread(id, { deletedThreadIds: deletedIds }); - } - removeFromSelection(ids); + await deleteSelectedThreads(); }, - [ - appSettings.confirmThreadDelete, - clearSelection, - deleteThread, - markThreadUnread, - removeFromSelection, - selectedThreadIds, - ], + [deleteSelectedThreads, markSelectedThreadsUnread, selectedThreadIds], ); const handleThreadClick = useCallback( @@ -913,53 +1027,13 @@ export default function Sidebar() { position, ); if (clicked === "copy-path") { - copyPathToClipboard(project.cwd, { path: project.cwd }); + copyProjectPath(projectId); return; } if (clicked !== "delete") return; - - const projectThreads = threads.filter((thread) => thread.projectId === projectId); - if (projectThreads.length > 0) { - toastManager.add({ - type: "warning", - title: "Project is not empty", - description: "Delete all threads in this project before removing it.", - }); - return; - } - - const confirmed = await api.dialogs.confirm(`Remove project "${project.name}"?`); - if (!confirmed) return; - - try { - const projectDraftThread = getDraftThreadByProjectId(projectId); - if (projectDraftThread) { - clearComposerDraftForThread(projectDraftThread.threadId); - } - clearProjectDraftThreadId(projectId); - await api.orchestration.dispatchCommand({ - type: "project.delete", - commandId: newCommandId(), - projectId, - }); - } catch (error) { - const message = error instanceof Error ? error.message : "Unknown error removing project."; - console.error("Failed to remove project", { projectId, error }); - toastManager.add({ - type: "error", - title: `Failed to remove "${project.name}"`, - description: message, - }); - } + await removeProject(projectId); }, - [ - clearComposerDraftForThread, - clearProjectDraftThreadId, - copyPathToClipboard, - getDraftThreadByProjectId, - projects, - threads, - ], + [copyProjectPath, projects, removeProject], ); const projectDnDSensors = useSensors( @@ -1264,6 +1338,7 @@ export default function Sidebar() { const renderThreadRow = (thread: (typeof projectThreads)[number]) => { const isActive = routeThreadId === thread.id; const isSelected = selectedThreadIds.has(thread.id); + const isSelectionContext = selectedThreadIds.size > 0 && isSelected; const isHighlighted = isActive || isSelected; const jumpLabel = threadJumpLabelById.get(thread.id) ?? null; const isThreadRunning = @@ -1273,6 +1348,11 @@ export default function Sidebar() { const terminalStatus = terminalStatusFromRunningIds( selectThreadTerminalState(terminalStateByThreadId, thread.id).runningTerminalIds, ); + const threadActionLabel = isSelectionContext + ? `Actions for ${selectedThreadIds.size} selected thread${ + selectedThreadIds.size === 1 ? "" : "s" + }` + : `Thread actions for ${thread.title}`; return ( { + if (isKeyboardContextMenuKey(event)) { + event.preventDefault(); + threadActionMenuTriggerRefs.current.get(thread.id)?.click(); + return; + } + if (event.key !== "Enter" && event.key !== " ") return; event.preventDefault(); navigateToThread(thread.id); @@ -1484,6 +1570,59 @@ export default function Sidebar() { + + { + setThreadActionMenuTriggerRef(thread.id, node); + }} + type="button" + data-thread-selection-safe + aria-label={threadActionLabel} + className="absolute top-1/2 right-6 z-10 inline-flex size-5 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground/60 opacity-0 transition hover:bg-accent hover:text-foreground focus-visible:opacity-100 focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring group-hover/menu-sub-item:opacity-100 group-focus-within/menu-sub-item:opacity-100 data-[popup-open]:opacity-100" + onClick={() => { + if (!isSelectionContext && selectedThreadIds.size > 0) { + clearSelection(); + } + }} + /> + } + > + + + + {isSelectionContext ? ( + <> + + Mark unread ({selectedThreadIds.size}) + + void deleteSelectedThreads()} variant="destructive"> + Delete ({selectedThreadIds.size}) + + + ) : ( + <> + startThreadRename(thread.id, thread.title)}> + Rename thread + + markThreadUnread(thread.id)}>Mark unread + copyThreadPath(thread.id)}>Copy Path + copyThreadIdToClipboard(thread.id, { threadId: thread.id })} + > + Copy Thread ID + + void deleteSingleThread(thread.id)} + variant="destructive" + > + Delete + + + )} + + ); }; @@ -1538,6 +1677,34 @@ export default function Sidebar() { {project.name} + + { + setProjectActionMenuTriggerRef(project.id, node); + }} + type="button" + aria-label={`Project actions for ${project.name}`} + data-thread-selection-safe + /> + } + showOnHover + className="top-1 right-7 size-5 rounded-md p-0 text-muted-foreground/70 hover:bg-secondary hover:text-foreground" + /> + } + > + + + + copyProjectPath(project.id)}>Copy Project Path + void removeProject(project.id)} variant="destructive"> + Remove project + + + , projectId: ProjectId) => { + if (isKeyboardContextMenuKey(event)) { + event.preventDefault(); + projectActionMenuTriggerRefs.current.get(projectId)?.click(); + return; + } + if (event.key !== "Enter" && event.key !== " ") return; event.preventDefault(); if (dragInProgressRef.current) {