Improve command center keyboard navigation and new tab shortcut

This commit is contained in:
Mohamed Boudra
2026-03-10 20:32:22 +07:00
parent 6b4978b428
commit 4e4a751921
6 changed files with 201 additions and 60 deletions
+94 -19
View File
@@ -7,6 +7,7 @@ import {
View, View,
Platform, Platform,
} from "react-native"; } from "react-native";
import { memo, useEffect, useMemo, useRef, type ReactNode } from "react";
import { Plus, Settings } from "lucide-react-native"; import { Plus, Settings } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useCommandCenter } from "@/hooks/use-command-center"; import { useCommandCenter } from "@/hooks/use-command-center";
@@ -20,6 +21,37 @@ function agentKey(agent: Pick<AggregatedAgent, "serverId" | "id">): string {
return `${agent.serverId}:${agent.id}`; 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 (
<Pressable
ref={registerRow}
style={({ hovered, pressed }) => [
styles.row,
(hovered || pressed || active) && {
backgroundColor: theme.colors.surface1,
},
]}
onPress={onPress}
>
{children}
</Pressable>
);
});
export function CommandCenter() { export function CommandCenter() {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { const {
@@ -33,10 +65,52 @@ export function CommandCenter() {
handleSelectItem, handleSelectItem,
} = useCommandCenter(); } = useCommandCenter();
const rowRefs = useRef<Map<number, View>>(new Map());
const resultsRef = useRef<ScrollView>(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; if (Platform.OS !== "web") return null;
const actionItems = items.filter((item) => item.kind === "action"); const actionItems = useMemo(
const agentItems = items.filter((item) => item.kind === "agent"); () => items.filter((item) => item.kind === "action"),
[items]
);
const agentItems = useMemo(
() => items.filter((item) => item.kind === "agent"),
[items]
);
return ( return (
<Modal <Modal
@@ -71,6 +145,7 @@ export function CommandCenter() {
</View> </View>
<ScrollView <ScrollView
ref={resultsRef}
style={styles.results} style={styles.results}
contentContainerStyle={styles.resultsContent} contentContainerStyle={styles.resultsContent}
keyboardShouldPersistTaps="always" keyboardShouldPersistTaps="always"
@@ -105,14 +180,13 @@ export function CommandCenter() {
/> />
) : null; ) : null;
return ( return (
<Pressable <CommandCenterRow
key={`action:${action.id}`} key={`action:${action.id}`}
style={({ hovered, pressed }) => [ registerRow={(el: View | null) => {
styles.row, if (el) rowRefs.current.set(index, el);
(hovered || pressed || active) && { else rowRefs.current.delete(index);
backgroundColor: theme.colors.surface1, }}
}, active={active}
]}
onPress={() => handleSelectItem(item)} onPress={() => handleSelectItem(item)}
> >
<View style={styles.rowContent}> <View style={styles.rowContent}>
@@ -133,7 +207,7 @@ export function CommandCenter() {
<Shortcut keys={action.shortcutKeys} style={styles.rowShortcut} /> <Shortcut keys={action.shortcutKeys} style={styles.rowShortcut} />
) : null} ) : null}
</View> </View>
</Pressable> </CommandCenterRow>
); );
})} })}
</> </>
@@ -154,14 +228,13 @@ export function CommandCenter() {
const active = rowIndex === activeIndex; const active = rowIndex === activeIndex;
const agent = item.agent; const agent = item.agent;
return ( return (
<Pressable <CommandCenterRow
key={agentKey(agent)} key={agentKey(agent)}
style={({ hovered, pressed }) => [ registerRow={(el: View | null) => {
styles.row, if (el) rowRefs.current.set(rowIndex, el);
(hovered || pressed || active) && { else rowRefs.current.delete(rowIndex);
backgroundColor: theme.colors.surface1, }}
}, active={active}
]}
onPress={() => handleSelectItem(item)} onPress={() => handleSelectItem(item)}
> >
<View style={styles.rowContent}> <View style={styles.rowContent}>
@@ -184,12 +257,12 @@ export function CommandCenter() {
style={[styles.subtitle, { color: theme.colors.foregroundMuted }]} style={[styles.subtitle, { color: theme.colors.foregroundMuted }]}
numberOfLines={1} numberOfLines={1}
> >
{agent.serverLabel} · {shortenPath(agent.cwd)} · {formatTimeAgo(agent.lastActivityAt)} {shortenPath(agent.cwd)} · {formatTimeAgo(agent.lastActivityAt)}
</Text> </Text>
</View> </View>
</View> </View>
</View> </View>
</Pressable> </CommandCenterRow>
); );
})} })}
</> </>
@@ -277,6 +350,8 @@ const styles = StyleSheet.create((theme) => ({
justifyContent: "center", justifyContent: "center",
}, },
textContent: { textContent: {
flex: 1,
minWidth: 0,
gap: 2, gap: 2,
}, },
rowShortcut: { rowShortcut: {
+56 -20
View File
@@ -3,7 +3,9 @@ import type { TextInput } from "react-native";
import { router, usePathname, type Href } from "expo-router"; import { router, usePathname, type Href } from "expo-router";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher"; 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 { import {
clearCommandCenterFocusRestoreElement, clearCommandCenterFocusRestoreElement,
takeCommandCenterFocusRestoreElement, takeCommandCenterFocusRestoreElement,
@@ -23,8 +25,7 @@ function isMatch(agent: AggregatedAgent, query: string): boolean {
const q = query.toLowerCase(); const q = query.toLowerCase();
const title = (agent.title ?? "New agent").toLowerCase(); const title = (agent.title ?? "New agent").toLowerCase();
const cwd = agent.cwd.toLowerCase(); const cwd = agent.cwd.toLowerCase();
const host = agent.serverLabel.toLowerCase(); return title.includes(q) || cwd.includes(q);
return title.includes(q) || cwd.includes(q) || host.includes(q);
} }
function sortAgents(left: AggregatedAgent, right: AggregatedAgent): number { function sortAgents(left: AggregatedAgent, right: AggregatedAgent): number {
@@ -103,34 +104,49 @@ export type CommandCenterItem =
export function useCommandCenter() { export function useCommandCenter() {
const pathname = usePathname(); const pathname = usePathname();
const { agents } = useAggregatedAgents(); const { daemons } = useDaemonRegistry();
const open = useKeyboardShortcutsStore((s) => s.commandCenterOpen); const open = useKeyboardShortcutsStore((s) => s.commandCenterOpen);
const setOpen = useKeyboardShortcutsStore((s) => s.setCommandCenterOpen); const setOpen = useKeyboardShortcutsStore((s) => s.setCommandCenterOpen);
const inputRef = useRef<TextInput>(null); const inputRef = useRef<TextInput>(null);
const didNavigateRef = useRef(false); const didNavigateRef = useRef(false);
const prevOpenRef = useRef(open); const prevOpenRef = useRef(open);
const activeIndexRef = useRef(0);
const itemsRef = useRef<CommandCenterItem[]>([]);
const handleCloseRef = useRef<() => void>(() => undefined);
const handleSelectItemRef = useRef<(item: CommandCenterItem) => void>(() => undefined);
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [activeIndex, setActiveIndex] = useState(0); 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 agentResults = useMemo(() => {
const filtered = agents.filter((agent) => isMatch(agent, query)); const filtered = agents.filter((agent) => isMatch(agent, query));
filtered.sort(sortAgents); filtered.sort(sortAgents);
return filtered; return filtered;
}, [agents, query]); }, [agents, query]);
const fallbackServerId = agents[0]?.serverId ?? null;
const newAgentRoute = useMemo<Href>(() => { const newAgentRoute = useMemo<Href>(() => {
const serverIdFromPath = const serverIdFromPath = activeServerId;
parseServerIdFromPathname(pathname) ?? fallbackServerId;
return serverIdFromPath ? (buildHostOpenProjectRoute(serverIdFromPath) as Href) : "/"; return serverIdFromPath ? (buildHostOpenProjectRoute(serverIdFromPath) as Href) : "/";
}, [fallbackServerId, pathname]); }, [activeServerId]);
const settingsRoute = useMemo<Href>(() => { const settingsRoute = useMemo<Href>(() => {
const serverIdFromPath = const serverIdFromPath = activeServerId;
parseServerIdFromPathname(pathname) ?? fallbackServerId;
return serverIdFromPath ? (buildHostSettingsRoute(serverIdFromPath) as Href) : "/"; return serverIdFromPath ? (buildHostSettingsRoute(serverIdFromPath) as Href) : "/";
}, [fallbackServerId, pathname]); }, [activeServerId]);
const actionItems = useMemo(() => { const actionItems = useMemo(() => {
return COMMAND_CENTER_ACTIONS.filter((action) => return COMMAND_CENTER_ACTIONS.filter((action) =>
@@ -209,6 +225,22 @@ export function useCommandCenter() {
[handleSelectAction, handleSelectAgent] [handleSelectAction, handleSelectAgent]
); );
useEffect(() => {
activeIndexRef.current = activeIndex;
}, [activeIndex]);
useEffect(() => {
itemsRef.current = items;
}, [items]);
useEffect(() => {
handleCloseRef.current = handleClose;
}, [handleClose]);
useEffect(() => {
handleSelectItemRef.current = handleSelectItem;
}, [handleSelectItem]);
useEffect(() => { useEffect(() => {
const prevOpen = prevOpenRef.current; const prevOpen = prevOpenRef.current;
prevOpenRef.current = open; prevOpenRef.current = open;
@@ -259,6 +291,7 @@ export function useCommandCenter() {
if (!open) return; if (!open) return;
const handler = (event: KeyboardEvent) => { const handler = (event: KeyboardEvent) => {
const currentItems = itemsRef.current;
const key = event.key; const key = event.key;
if ( if (
key !== "ArrowDown" && key !== "ArrowDown" &&
@@ -271,26 +304,29 @@ export function useCommandCenter() {
if (key === "Escape") { if (key === "Escape") {
event.preventDefault(); event.preventDefault();
handleClose(); handleCloseRef.current();
return; return;
} }
if (key === "Enter") { if (key === "Enter") {
if (items.length === 0) return; if (currentItems.length === 0) return;
event.preventDefault(); event.preventDefault();
const index = Math.max(0, Math.min(activeIndex, items.length - 1)); const index = Math.max(
handleSelectItem(items[index]!); 0,
Math.min(activeIndexRef.current, currentItems.length - 1)
);
handleSelectItemRef.current(currentItems[index]!);
return; return;
} }
if (key === "ArrowDown" || key === "ArrowUp") { if (key === "ArrowDown" || key === "ArrowUp") {
if (items.length === 0) return; if (currentItems.length === 0) return;
event.preventDefault(); event.preventDefault();
setActiveIndex((current) => { setActiveIndex((current) => {
const delta = key === "ArrowDown" ? 1 : -1; const delta = key === "ArrowDown" ? 1 : -1;
const next = current + delta; const next = current + delta;
if (next < 0) return items.length - 1; if (next < 0) return currentItems.length - 1;
if (next >= items.length) return 0; if (next >= currentItems.length) return 0;
return next; return next;
}); });
} }
@@ -299,7 +335,7 @@ export function useCommandCenter() {
// react-native-web can stop propagation on key events, so listen in capture phase. // react-native-web can stop propagation on key events, so listen in capture phase.
window.addEventListener("keydown", handler, true); window.addEventListener("keydown", handler, true);
return () => window.removeEventListener("keydown", handler, true); return () => window.removeEventListener("keydown", handler, true);
}, [activeIndex, handleClose, handleSelectItem, items, open]); }, [open]);
return { return {
open, open,
@@ -152,8 +152,9 @@ describe("keyboard-shortcuts", () => {
payload: { delta: 1 }, payload: { delta: 1 },
}, },
{ {
name: "matches Alt+Shift+T to open new tab", name: "matches Mod+T to open new tab",
event: { key: "T", code: "KeyT", altKey: true, shiftKey: true }, event: { key: "t", code: "KeyT", metaKey: true },
context: { isMac: true },
action: "workspace.tab.new", action: "workspace.tab.new",
}, },
{ {
@@ -222,6 +223,10 @@ describe("keyboard-shortcuts", () => {
event: { key: "n", code: "KeyN", metaKey: true, altKey: true }, event: { key: "n", code: "KeyN", metaKey: true, altKey: true },
context: { isMac: 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", name: "does not match question-mark shortcut inside editable scopes",
event: { key: "?", code: "Slash", shiftKey: true }, event: { key: "?", code: "Slash", shiftKey: true },
@@ -269,6 +274,7 @@ describe("keyboard-shortcut help sections", () => {
context: { isMac: true, isTauri: false }, context: { isMac: true, isTauri: false },
expectedKeys: { expectedKeys: {
"new-agent": ["mod", "shift", "O"], "new-agent": ["mod", "shift", "O"],
"workspace-tab-new": ["mod", "T"],
"workspace-jump-index": ["alt", "1-9"], "workspace-jump-index": ["alt", "1-9"],
"workspace-tab-jump-index": ["alt", "shift", "1-9"], "workspace-tab-jump-index": ["alt", "shift", "1-9"],
"workspace-tab-close-current": ["alt", "shift", "W"], "workspace-tab-close-current": ["alt", "shift", "W"],
@@ -279,6 +285,7 @@ describe("keyboard-shortcut help sections", () => {
context: { isMac: true, isTauri: true }, context: { isMac: true, isTauri: true },
expectedKeys: { expectedKeys: {
"new-agent": ["mod", "shift", "O"], "new-agent": ["mod", "shift", "O"],
"workspace-tab-new": ["mod", "T"],
"workspace-jump-index": ["mod", "1-9"], "workspace-jump-index": ["mod", "1-9"],
"workspace-tab-jump-index": ["alt", "1-9"], "workspace-tab-jump-index": ["alt", "1-9"],
"workspace-tab-close-current": ["mod", "W"], "workspace-tab-close-current": ["mod", "W"],
@@ -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", action: "workspace.tab.new",
matches: (event) => matches: (event) =>
!event.metaKey && isMod(event) &&
!event.ctrlKey && !event.altKey &&
event.altKey && !event.shiftKey &&
event.shiftKey &&
(event.code === "KeyT" || event.key.toLowerCase() === "t"), (event.code === "KeyT" || event.key.toLowerCase() === "t"),
when: (context) => !context.commandCenterOpen, when: (context) => !context.commandCenterOpen,
help: { help: {
id: "workspace-tab-new", id: "workspace-tab-new",
section: "global", section: "global",
label: "New agent tab", label: "New agent tab",
keys: ["alt", "shift", "T"], keys: ["mod", "T"],
}, },
}, },
{ {
@@ -10,6 +10,7 @@ import {
ContextMenuSeparator, ContextMenuSeparator,
ContextMenuTrigger, ContextMenuTrigger,
} from "@/components/ui/context-menu"; } from "@/components/ui/context-menu";
import { Shortcut } from "@/components/ui/shortcut";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useWorkspaceTabLayout } from "@/screens/workspace/use-workspace-tab-layout"; import { useWorkspaceTabLayout } from "@/screens/workspace/use-workspace-tab-layout";
import { import {
@@ -367,7 +368,10 @@ export function WorkspaceDesktopTabsRow({
<Plus size={theme.iconSize.sm} color={theme.colors.foregroundMuted} /> <Plus size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="bottom" align="end" offset={8}> <TooltipContent side="bottom" align="end" offset={8}>
<Text style={styles.newTabTooltipText}>New agent tab</Text> <View style={styles.newTabTooltipRow}>
<Text style={styles.newTabTooltipText}>New agent tab</Text>
<Shortcut keys={["mod", "T"]} style={styles.newTabTooltipShortcut} />
</View>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</View> </View>
@@ -488,4 +492,13 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foreground, color: theme.colors.foreground,
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
}, },
newTabTooltipRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
newTabTooltipShortcut: {
backgroundColor: theme.colors.surface3,
borderColor: theme.colors.borderAccent,
},
})); }));
@@ -26,6 +26,7 @@ import { SidebarMenuToggle } from "@/components/headers/menu-header";
import { HeaderToggleButton } from "@/components/headers/header-toggle-button"; import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
import { ScreenHeader } from "@/components/headers/screen-header"; import { ScreenHeader } from "@/components/headers/screen-header";
import { Combobox } from "@/components/ui/combobox"; import { Combobox } from "@/components/ui/combobox";
import { Shortcut } from "@/components/ui/shortcut";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -1157,38 +1158,36 @@ function WorkspaceScreenContent({
return; return;
} }
if (workspaceTabActionRequest.kind === "new") { const request = workspaceTabActionRequest;
clearWorkspaceTabActionRequest(request.id);
if (request.kind === "new") {
handleCreateDraftTab(); handleCreateDraftTab();
clearWorkspaceTabActionRequest(workspaceTabActionRequest.id);
return; return;
} }
if (workspaceTabActionRequest.kind === "close-current") { if (request.kind === "close-current") {
if (activeTabId) { if (activeTabId) {
void handleCloseTabById(activeTabId); void handleCloseTabById(activeTabId);
} }
clearWorkspaceTabActionRequest(workspaceTabActionRequest.id);
return; return;
} }
if (workspaceTabActionRequest.kind === "navigate-index") { if (request.kind === "navigate-index") {
const next = tabs[workspaceTabActionRequest.index - 1] ?? null; const next = tabs[request.index - 1] ?? null;
if (next?.tabId) { if (next?.tabId) {
navigateToTabId(next.tabId); navigateToTabId(next.tabId);
} }
clearWorkspaceTabActionRequest(workspaceTabActionRequest.id);
return; return;
} }
if (workspaceTabActionRequest.kind === "navigate-relative") { if (request.kind === "navigate-relative") {
if (tabs.length > 0) { if (tabs.length > 0) {
const currentIndex = tabs.findIndex((tab) => tab.tabId === activeTabId); const currentIndex = tabs.findIndex((tab) => tab.tabId === activeTabId);
const fromIndex = currentIndex >= 0 ? currentIndex : 0; const fromIndex = currentIndex >= 0 ? currentIndex : 0;
const nextIndex = const nextIndex = (fromIndex + request.delta + tabs.length) % tabs.length;
(fromIndex + workspaceTabActionRequest.delta + tabs.length) % tabs.length;
const next = tabs[nextIndex] ?? null; const next = tabs[nextIndex] ?? null;
if (next?.tabId) { if (next?.tabId) {
navigateToTabId(next.tabId); navigateToTabId(next.tabId);
} }
} }
clearWorkspaceTabActionRequest(workspaceTabActionRequest.id);
} }
}, [ }, [
activeTabId, activeTabId,
@@ -1525,7 +1524,10 @@ function WorkspaceScreenContent({
<Plus size={theme.iconSize.sm} color={theme.colors.foregroundMuted} /> <Plus size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="bottom" align="end" offset={8}> <TooltipContent side="bottom" align="end" offset={8}>
<Text style={styles.newTabTooltipText}>New agent tab</Text> <View style={styles.newTabTooltipRow}>
<Text style={styles.newTabTooltipText}>New agent tab</Text>
<Shortcut keys={["mod", "T"]} style={styles.newTabTooltipShortcut} />
</View>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
@@ -1728,6 +1730,15 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
color: theme.colors.popoverForeground, color: theme.colors.popoverForeground,
}, },
newTabTooltipRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
newTabTooltipShortcut: {
backgroundColor: theme.colors.surface3,
borderColor: theme.colors.borderAccent,
},
mobileTabsRow: { mobileTabsRow: {
borderBottomWidth: 1, borderBottomWidth: 1,
borderBottomColor: theme.colors.border, borderBottomColor: theme.colors.border,