From 4e4a75192162fe3097198004097c86c9837aefa2 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 10 Mar 2026 20:32:22 +0700 Subject: [PATCH] Improve command center keyboard navigation and new tab shortcut --- .../app/src/components/command-center.tsx | 113 +++++++++++++++--- packages/app/src/hooks/use-command-center.ts | 76 ++++++++---- .../src/keyboard/keyboard-shortcuts.test.ts | 11 +- .../app/src/keyboard/keyboard-shortcuts.ts | 11 +- .../workspace/workspace-desktop-tabs-row.tsx | 15 ++- .../screens/workspace/workspace-screen.tsx | 35 ++++-- 6 files changed, 201 insertions(+), 60 deletions(-) diff --git a/packages/app/src/components/command-center.tsx b/packages/app/src/components/command-center.tsx index 3d56b4af0..c329e6fd4 100644 --- a/packages/app/src/components/command-center.tsx +++ b/packages/app/src/components/command-center.tsx @@ -7,6 +7,7 @@ import { View, Platform, } from "react-native"; +import { memo, useEffect, useMemo, useRef, type ReactNode } from "react"; import { Plus, Settings } from "lucide-react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useCommandCenter } from "@/hooks/use-command-center"; @@ -20,6 +21,37 @@ function agentKey(agent: Pick): string { return `${agent.serverId}:${agent.id}`; } +type CommandCenterRowProps = { + active: boolean; + children: ReactNode; + onPress: () => void; + registerRow: (el: View | null) => void; +}; + +const CommandCenterRow = memo(function CommandCenterRow({ + active, + children, + onPress, + registerRow, +}: CommandCenterRowProps) { + const { theme } = useUnistyles(); + + return ( + [ + styles.row, + (hovered || pressed || active) && { + backgroundColor: theme.colors.surface1, + }, + ]} + onPress={onPress} + > + {children} + + ); +}); + export function CommandCenter() { const { theme } = useUnistyles(); const { @@ -33,10 +65,52 @@ export function CommandCenter() { handleSelectItem, } = useCommandCenter(); + const rowRefs = useRef>(new Map()); + const resultsRef = useRef(null); + + useEffect(() => { + const row = rowRefs.current.get(activeIndex); + if (!row || typeof document === "undefined") { + return; + } + const scrollNode = + (resultsRef.current as + | (ScrollView & { + getScrollableNode?: () => HTMLElement | null; + }) + | null)?.getScrollableNode?.() ?? null; + const rowEl = row as unknown as HTMLElement; + + if (!scrollNode) { + rowEl.scrollIntoView?.({ block: "nearest" }); + return; + } + + const rowTop = rowEl.offsetTop; + const rowBottom = rowTop + rowEl.offsetHeight; + const visibleTop = scrollNode.scrollTop; + const visibleBottom = visibleTop + scrollNode.clientHeight; + + if (rowTop < visibleTop) { + scrollNode.scrollTop = rowTop; + return; + } + + if (rowBottom > visibleBottom) { + scrollNode.scrollTop = rowBottom - scrollNode.clientHeight; + } + }, [activeIndex]); + if (Platform.OS !== "web") return null; - const actionItems = items.filter((item) => item.kind === "action"); - const agentItems = items.filter((item) => item.kind === "agent"); + const actionItems = useMemo( + () => items.filter((item) => item.kind === "action"), + [items] + ); + const agentItems = useMemo( + () => items.filter((item) => item.kind === "agent"), + [items] + ); return ( ) : null; return ( - [ - styles.row, - (hovered || pressed || active) && { - backgroundColor: theme.colors.surface1, - }, - ]} + registerRow={(el: View | null) => { + if (el) rowRefs.current.set(index, el); + else rowRefs.current.delete(index); + }} + active={active} onPress={() => handleSelectItem(item)} > @@ -133,7 +207,7 @@ export function CommandCenter() { ) : null} - + ); })} @@ -154,14 +228,13 @@ export function CommandCenter() { const active = rowIndex === activeIndex; const agent = item.agent; return ( - [ - styles.row, - (hovered || pressed || active) && { - backgroundColor: theme.colors.surface1, - }, - ]} + registerRow={(el: View | null) => { + if (el) rowRefs.current.set(rowIndex, el); + else rowRefs.current.delete(rowIndex); + }} + active={active} onPress={() => handleSelectItem(item)} > @@ -184,12 +257,12 @@ export function CommandCenter() { style={[styles.subtitle, { color: theme.colors.foregroundMuted }]} numberOfLines={1} > - {agent.serverLabel} · {shortenPath(agent.cwd)} · {formatTimeAgo(agent.lastActivityAt)} + {shortenPath(agent.cwd)} · {formatTimeAgo(agent.lastActivityAt)} - + ); })} @@ -277,6 +350,8 @@ const styles = StyleSheet.create((theme) => ({ justifyContent: "center", }, textContent: { + flex: 1, + minWidth: 0, gap: 2, }, rowShortcut: { diff --git a/packages/app/src/hooks/use-command-center.ts b/packages/app/src/hooks/use-command-center.ts index 33e845aa1..56aae64f5 100644 --- a/packages/app/src/hooks/use-command-center.ts +++ b/packages/app/src/hooks/use-command-center.ts @@ -3,7 +3,9 @@ import type { TextInput } from "react-native"; import { router, usePathname, type Href } from "expo-router"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher"; -import { useAggregatedAgents, type AggregatedAgent } from "@/hooks/use-aggregated-agents"; +import { useDaemonRegistry } from "@/contexts/daemon-registry-context"; +import { useAllAgentsList } from "@/hooks/use-all-agents-list"; +import type { AggregatedAgent } from "@/hooks/use-aggregated-agents"; import { clearCommandCenterFocusRestoreElement, takeCommandCenterFocusRestoreElement, @@ -23,8 +25,7 @@ function isMatch(agent: AggregatedAgent, query: string): boolean { const q = query.toLowerCase(); const title = (agent.title ?? "New agent").toLowerCase(); const cwd = agent.cwd.toLowerCase(); - const host = agent.serverLabel.toLowerCase(); - return title.includes(q) || cwd.includes(q) || host.includes(q); + return title.includes(q) || cwd.includes(q); } function sortAgents(left: AggregatedAgent, right: AggregatedAgent): number { @@ -103,34 +104,49 @@ export type CommandCenterItem = export function useCommandCenter() { const pathname = usePathname(); - const { agents } = useAggregatedAgents(); + const { daemons } = useDaemonRegistry(); const open = useKeyboardShortcutsStore((s) => s.commandCenterOpen); const setOpen = useKeyboardShortcutsStore((s) => s.setCommandCenterOpen); const inputRef = useRef(null); const didNavigateRef = useRef(false); const prevOpenRef = useRef(open); + const activeIndexRef = useRef(0); + const itemsRef = useRef([]); + const handleCloseRef = useRef<() => void>(() => undefined); + const handleSelectItemRef = useRef<(item: CommandCenterItem) => void>(() => undefined); const [query, setQuery] = useState(""); const [activeIndex, setActiveIndex] = useState(0); + const activeServerId = useMemo(() => { + const serverIdFromPath = parseServerIdFromPathname(pathname); + if (serverIdFromPath) { + const routeMatch = daemons.find((entry) => entry.serverId === serverIdFromPath); + if (routeMatch) { + return routeMatch.serverId; + } + } + return daemons[0]?.serverId ?? null; + }, [daemons, pathname]); + + const { agents } = useAllAgentsList({ + serverId: activeServerId, + }); + const agentResults = useMemo(() => { const filtered = agents.filter((agent) => isMatch(agent, query)); filtered.sort(sortAgents); return filtered; }, [agents, query]); - const fallbackServerId = agents[0]?.serverId ?? null; - const newAgentRoute = useMemo(() => { - const serverIdFromPath = - parseServerIdFromPathname(pathname) ?? fallbackServerId; + const serverIdFromPath = activeServerId; return serverIdFromPath ? (buildHostOpenProjectRoute(serverIdFromPath) as Href) : "/"; - }, [fallbackServerId, pathname]); + }, [activeServerId]); const settingsRoute = useMemo(() => { - const serverIdFromPath = - parseServerIdFromPathname(pathname) ?? fallbackServerId; + const serverIdFromPath = activeServerId; return serverIdFromPath ? (buildHostSettingsRoute(serverIdFromPath) as Href) : "/"; - }, [fallbackServerId, pathname]); + }, [activeServerId]); const actionItems = useMemo(() => { return COMMAND_CENTER_ACTIONS.filter((action) => @@ -209,6 +225,22 @@ export function useCommandCenter() { [handleSelectAction, handleSelectAgent] ); + useEffect(() => { + activeIndexRef.current = activeIndex; + }, [activeIndex]); + + useEffect(() => { + itemsRef.current = items; + }, [items]); + + useEffect(() => { + handleCloseRef.current = handleClose; + }, [handleClose]); + + useEffect(() => { + handleSelectItemRef.current = handleSelectItem; + }, [handleSelectItem]); + useEffect(() => { const prevOpen = prevOpenRef.current; prevOpenRef.current = open; @@ -259,6 +291,7 @@ export function useCommandCenter() { if (!open) return; const handler = (event: KeyboardEvent) => { + const currentItems = itemsRef.current; const key = event.key; if ( key !== "ArrowDown" && @@ -271,26 +304,29 @@ export function useCommandCenter() { if (key === "Escape") { event.preventDefault(); - handleClose(); + handleCloseRef.current(); return; } if (key === "Enter") { - if (items.length === 0) return; + if (currentItems.length === 0) return; event.preventDefault(); - const index = Math.max(0, Math.min(activeIndex, items.length - 1)); - handleSelectItem(items[index]!); + const index = Math.max( + 0, + Math.min(activeIndexRef.current, currentItems.length - 1) + ); + handleSelectItemRef.current(currentItems[index]!); return; } if (key === "ArrowDown" || key === "ArrowUp") { - if (items.length === 0) return; + if (currentItems.length === 0) return; event.preventDefault(); setActiveIndex((current) => { const delta = key === "ArrowDown" ? 1 : -1; const next = current + delta; - if (next < 0) return items.length - 1; - if (next >= items.length) return 0; + if (next < 0) return currentItems.length - 1; + if (next >= currentItems.length) return 0; return next; }); } @@ -299,7 +335,7 @@ export function useCommandCenter() { // react-native-web can stop propagation on key events, so listen in capture phase. window.addEventListener("keydown", handler, true); return () => window.removeEventListener("keydown", handler, true); - }, [activeIndex, handleClose, handleSelectItem, items, open]); + }, [open]); return { open, diff --git a/packages/app/src/keyboard/keyboard-shortcuts.test.ts b/packages/app/src/keyboard/keyboard-shortcuts.test.ts index 2c1d9c19b..15acafab2 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.test.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.test.ts @@ -152,8 +152,9 @@ describe("keyboard-shortcuts", () => { payload: { delta: 1 }, }, { - name: "matches Alt+Shift+T to open new tab", - event: { key: "T", code: "KeyT", altKey: true, shiftKey: true }, + name: "matches Mod+T to open new tab", + event: { key: "t", code: "KeyT", metaKey: true }, + context: { isMac: true }, action: "workspace.tab.new", }, { @@ -222,6 +223,10 @@ describe("keyboard-shortcuts", () => { event: { key: "n", code: "KeyN", metaKey: true, altKey: true }, context: { isMac: true }, }, + { + name: "does not keep old Alt+Shift+T binding", + event: { key: "T", code: "KeyT", altKey: true, shiftKey: true }, + }, { name: "does not match question-mark shortcut inside editable scopes", event: { key: "?", code: "Slash", shiftKey: true }, @@ -269,6 +274,7 @@ describe("keyboard-shortcut help sections", () => { context: { isMac: true, isTauri: false }, expectedKeys: { "new-agent": ["mod", "shift", "O"], + "workspace-tab-new": ["mod", "T"], "workspace-jump-index": ["alt", "1-9"], "workspace-tab-jump-index": ["alt", "shift", "1-9"], "workspace-tab-close-current": ["alt", "shift", "W"], @@ -279,6 +285,7 @@ describe("keyboard-shortcut help sections", () => { context: { isMac: true, isTauri: true }, expectedKeys: { "new-agent": ["mod", "shift", "O"], + "workspace-tab-new": ["mod", "T"], "workspace-jump-index": ["mod", "1-9"], "workspace-tab-jump-index": ["alt", "1-9"], "workspace-tab-close-current": ["mod", "W"], diff --git a/packages/app/src/keyboard/keyboard-shortcuts.ts b/packages/app/src/keyboard/keyboard-shortcuts.ts index 7f5fb904a..cb19e7372 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.ts @@ -138,20 +138,19 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [ }, }, { - id: "workspace-tab-new-alt-shift-t", + id: "workspace-tab-new-mod-t", action: "workspace.tab.new", matches: (event) => - !event.metaKey && - !event.ctrlKey && - event.altKey && - event.shiftKey && + isMod(event) && + !event.altKey && + !event.shiftKey && (event.code === "KeyT" || event.key.toLowerCase() === "t"), when: (context) => !context.commandCenterOpen, help: { id: "workspace-tab-new", section: "global", label: "New agent tab", - keys: ["alt", "shift", "T"], + keys: ["mod", "T"], }, }, { diff --git a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx index 58f54d835..bba5d691d 100644 --- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx +++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx @@ -10,6 +10,7 @@ import { ContextMenuSeparator, ContextMenuTrigger, } from "@/components/ui/context-menu"; +import { Shortcut } from "@/components/ui/shortcut"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { useWorkspaceTabLayout } from "@/screens/workspace/use-workspace-tab-layout"; import { @@ -367,7 +368,10 @@ export function WorkspaceDesktopTabsRow({ - New agent tab + + New agent tab + + @@ -488,4 +492,13 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foreground, fontSize: theme.fontSize.sm, }, + newTabTooltipRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + newTabTooltipShortcut: { + backgroundColor: theme.colors.surface3, + borderColor: theme.colors.borderAccent, + }, })); diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 9563d0e01..0982cdf0e 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -26,6 +26,7 @@ import { SidebarMenuToggle } from "@/components/headers/menu-header"; import { HeaderToggleButton } from "@/components/headers/header-toggle-button"; import { ScreenHeader } from "@/components/headers/screen-header"; import { Combobox } from "@/components/ui/combobox"; +import { Shortcut } from "@/components/ui/shortcut"; import { DropdownMenu, DropdownMenuContent, @@ -1157,38 +1158,36 @@ function WorkspaceScreenContent({ return; } - if (workspaceTabActionRequest.kind === "new") { + const request = workspaceTabActionRequest; + clearWorkspaceTabActionRequest(request.id); + + if (request.kind === "new") { handleCreateDraftTab(); - clearWorkspaceTabActionRequest(workspaceTabActionRequest.id); return; } - if (workspaceTabActionRequest.kind === "close-current") { + if (request.kind === "close-current") { if (activeTabId) { void handleCloseTabById(activeTabId); } - clearWorkspaceTabActionRequest(workspaceTabActionRequest.id); return; } - if (workspaceTabActionRequest.kind === "navigate-index") { - const next = tabs[workspaceTabActionRequest.index - 1] ?? null; + if (request.kind === "navigate-index") { + const next = tabs[request.index - 1] ?? null; if (next?.tabId) { navigateToTabId(next.tabId); } - clearWorkspaceTabActionRequest(workspaceTabActionRequest.id); return; } - if (workspaceTabActionRequest.kind === "navigate-relative") { + if (request.kind === "navigate-relative") { if (tabs.length > 0) { const currentIndex = tabs.findIndex((tab) => tab.tabId === activeTabId); const fromIndex = currentIndex >= 0 ? currentIndex : 0; - const nextIndex = - (fromIndex + workspaceTabActionRequest.delta + tabs.length) % tabs.length; + const nextIndex = (fromIndex + request.delta + tabs.length) % tabs.length; const next = tabs[nextIndex] ?? null; if (next?.tabId) { navigateToTabId(next.tabId); } } - clearWorkspaceTabActionRequest(workspaceTabActionRequest.id); } }, [ activeTabId, @@ -1525,7 +1524,10 @@ function WorkspaceScreenContent({ - New agent tab + + New agent tab + + @@ -1728,6 +1730,15 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.sm, color: theme.colors.popoverForeground, }, + newTabTooltipRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + newTabTooltipShortcut: { + backgroundColor: theme.colors.surface3, + borderColor: theme.colors.borderAccent, + }, mobileTabsRow: { borderBottomWidth: 1, borderBottomColor: theme.colors.border,