refactor(app): centralize keyboard actions and shortcuts
This commit is contained in:
@@ -14,7 +14,7 @@ test('sidebar toggle shows tooltip on the right', async ({ page }) => {
|
||||
const tooltip = page.getByTestId('menu-button-tooltip');
|
||||
await expect(tooltip).toBeVisible();
|
||||
await expect(tooltip).toContainText('Toggle sidebar');
|
||||
await expect(tooltip).toContainText(/⌘B|Ctrl\+B/);
|
||||
await expect(tooltip).toContainText(/⌘B|Ctrl\+\./);
|
||||
await page.waitForTimeout(250);
|
||||
await expect(tooltip).toBeVisible();
|
||||
|
||||
|
||||
@@ -95,6 +95,24 @@ async function openTerminalsPanel(page: Page): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
async function getDesktopAgentSidebarOpen(page: Page): Promise<boolean | null> {
|
||||
return await page.evaluate(() => {
|
||||
const raw = localStorage.getItem("panel-state");
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as {
|
||||
state?: { desktop?: { agentListOpen?: boolean } };
|
||||
};
|
||||
const value = parsed?.state?.desktop?.agentListOpen;
|
||||
return typeof value === "boolean" ? value : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
async function selectNewestTerminalTab(page: Page): Promise<void> {
|
||||
const tabs = page.locator('[data-testid^="terminal-tab-"]');
|
||||
@@ -256,6 +274,75 @@ test("terminals are shared by agents on the same cwd", async ({ page }) => {
|
||||
}
|
||||
});
|
||||
|
||||
test("terminal captures escape and ctrl+c key input", async ({ page }) => {
|
||||
const repo = await createTempGitRepo("paseo-e2e-terminal-keys-");
|
||||
|
||||
try {
|
||||
await openNewAgentDraft(page);
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await ensureHostSelected(page);
|
||||
await createAgent(page, "Terminal key combo capture");
|
||||
|
||||
await openTerminalsPanel(page);
|
||||
|
||||
const surface = page.getByTestId("terminal-surface").first();
|
||||
await expect(surface).toBeVisible({ timeout: 30000 });
|
||||
await surface.click({ force: true });
|
||||
|
||||
await page.keyboard.type("cat -v", { delay: 1 });
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(surface).toContainText("cat -v", { timeout: 30000 });
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(surface).toContainText("^[", { timeout: 30000 });
|
||||
|
||||
await page.keyboard.press("Control+C");
|
||||
await expect(surface).toContainText("^C", { timeout: 30000 });
|
||||
|
||||
await page.keyboard.press("Control+B");
|
||||
await expect(surface).toContainText("^B", { timeout: 30000 });
|
||||
|
||||
const marker = `terminal-key-capture-${Date.now()}`;
|
||||
await page.keyboard.type(`echo ${marker}`, { delay: 1 });
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(surface).toContainText(marker, { timeout: 30000 });
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("Cmd+B toggles sidebar even when terminal is focused", async ({ page }) => {
|
||||
const repo = await createTempGitRepo("paseo-e2e-terminal-cmd-b-");
|
||||
|
||||
try {
|
||||
await openNewAgentDraft(page);
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await ensureHostSelected(page);
|
||||
await createAgent(page, "Terminal Cmd+B");
|
||||
|
||||
await openTerminalsPanel(page);
|
||||
const surface = page.getByTestId("terminal-surface").first();
|
||||
await expect(surface).toBeVisible({ timeout: 30000 });
|
||||
await surface.click({ force: true });
|
||||
|
||||
await expect
|
||||
.poll(async () => await getDesktopAgentSidebarOpen(page), { timeout: 30000 })
|
||||
.toBe(true);
|
||||
|
||||
await page.keyboard.press("Meta+B");
|
||||
await expect
|
||||
.poll(async () => await getDesktopAgentSidebarOpen(page), { timeout: 30000 })
|
||||
.toBe(false);
|
||||
|
||||
await page.keyboard.press("Meta+B");
|
||||
await expect
|
||||
.poll(async () => await getDesktopAgentSidebarOpen(page), { timeout: 30000 })
|
||||
.toBe(true);
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
async function getTerminalRows(page: Page): Promise<number> {
|
||||
return await page.evaluate(() => {
|
||||
const terminal = (window as { __paseoTerminal?: { rows?: unknown } }).__paseoTerminal;
|
||||
|
||||
@@ -36,7 +36,7 @@ import {
|
||||
import { getIsTauri, getIsTauriMac } from "@/constants/layout";
|
||||
import { useTrafficLightPadding } from "@/utils/tauri-window";
|
||||
import { CommandCenter } from "@/components/command-center";
|
||||
import { useGlobalKeyboardNav } from "@/hooks/use-global-keyboard-nav";
|
||||
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
||||
import { queryClient } from "@/query/query-client";
|
||||
import {
|
||||
WEB_NOTIFICATION_CLICK_EVENT,
|
||||
@@ -147,7 +147,7 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) {
|
||||
const openGestureEnabled =
|
||||
chromeEnabled && isMobile && mobileView === "agent";
|
||||
|
||||
useGlobalKeyboardNav({
|
||||
useKeyboardShortcuts({
|
||||
enabled: chromeEnabled,
|
||||
isMobile,
|
||||
toggleAgentList,
|
||||
|
||||
@@ -28,7 +28,7 @@ import { Theme } from "@/styles/theme";
|
||||
import { CommandAutocomplete } from "./command-autocomplete";
|
||||
import { useAgentCommandsQuery } from "@/hooks/use-agent-commands-query";
|
||||
import { encodeImages } from "@/utils/encode-images";
|
||||
import { useKeyboardNavStore } from "@/stores/keyboard-nav-store";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { focusWithRetries } from "@/utils/web-focus";
|
||||
import { useVoiceOptional } from "@/contexts/voice-context";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
@@ -74,8 +74,12 @@ export function AgentInputArea({
|
||||
const insets = useSafeAreaInsets();
|
||||
const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
|
||||
const isScreenFocused = useIsFocused();
|
||||
const focusChatInputRequest = useKeyboardNavStore((s) => s.focusChatInputRequest);
|
||||
const clearFocusChatInputRequest = useKeyboardNavStore((s) => s.clearFocusChatInputRequest);
|
||||
const messageInputActionRequest = useKeyboardShortcutsStore(
|
||||
(s) => s.messageInputActionRequest
|
||||
);
|
||||
const clearMessageInputActionRequest = useKeyboardShortcutsStore(
|
||||
(s) => s.clearMessageInputActionRequest
|
||||
);
|
||||
|
||||
const client = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.client ?? null
|
||||
@@ -108,7 +112,7 @@ export function AgentInputArea({
|
||||
const [isCancellingAgent, setIsCancellingAgent] = useState(false);
|
||||
const [sendError, setSendError] = useState<string | null>(null);
|
||||
const [commandSelectedIndex, setCommandSelectedIndex] = useState(0);
|
||||
const lastHandledFocusRequestIdRef = useRef<number | null>(null);
|
||||
const lastHandledMessageInputActionRequestIdRef = useRef<number | null>(null);
|
||||
|
||||
// Command autocomplete logic
|
||||
const showCommandAutocomplete = userInput.startsWith("/") && !userInput.includes(" ");
|
||||
@@ -400,21 +404,36 @@ export function AgentInputArea({
|
||||
saveDraftInput(agentId, { text: userInput, images: selectedImages });
|
||||
}, [agentId, userInput, selectedImages, getDraftInput, saveDraftInput]);
|
||||
|
||||
// When switching agents from the command center, auto-focus the input on web.
|
||||
// Keyboard-dispatched message-input actions are routed through store requests.
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== "web") return;
|
||||
if (!isScreenFocused) return;
|
||||
if (!focusChatInputRequest) return;
|
||||
if (!messageInputActionRequest) return;
|
||||
|
||||
const currentKey = `${serverId}:${agentId}`;
|
||||
if (focusChatInputRequest.agentKey !== currentKey) {
|
||||
if (messageInputActionRequest.agentKey !== currentKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (lastHandledFocusRequestIdRef.current === focusChatInputRequest.id) {
|
||||
if (
|
||||
lastHandledMessageInputActionRequestIdRef.current ===
|
||||
messageInputActionRequest.id
|
||||
) {
|
||||
return;
|
||||
}
|
||||
lastHandledMessageInputActionRequestIdRef.current =
|
||||
messageInputActionRequest.id;
|
||||
|
||||
if (messageInputActionRequest.kind !== "focus") {
|
||||
messageInputRef.current?.runKeyboardAction(messageInputActionRequest.kind);
|
||||
clearMessageInputActionRequest(messageInputActionRequest.id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Platform.OS !== "web") {
|
||||
messageInputRef.current?.focus();
|
||||
clearMessageInputActionRequest(messageInputActionRequest.id);
|
||||
return;
|
||||
}
|
||||
lastHandledFocusRequestIdRef.current = focusChatInputRequest.id;
|
||||
|
||||
return focusWithRetries({
|
||||
focus: () => messageInputRef.current?.focus(),
|
||||
@@ -424,14 +443,16 @@ export function AgentInputArea({
|
||||
typeof document !== "undefined" ? document.activeElement : null;
|
||||
return Boolean(el) && active === el;
|
||||
},
|
||||
onSuccess: () => clearFocusChatInputRequest(),
|
||||
onTimeout: () => clearFocusChatInputRequest(),
|
||||
onSuccess: () =>
|
||||
clearMessageInputActionRequest(messageInputActionRequest.id),
|
||||
onTimeout: () =>
|
||||
clearMessageInputActionRequest(messageInputActionRequest.id),
|
||||
});
|
||||
}, [
|
||||
agentId,
|
||||
clearFocusChatInputRequest,
|
||||
focusChatInputRequest,
|
||||
clearMessageInputActionRequest,
|
||||
isScreenFocused,
|
||||
messageInputActionRequest,
|
||||
serverId,
|
||||
]);
|
||||
|
||||
|
||||
@@ -48,9 +48,13 @@ export function CommandCenter() {
|
||||
<View style={styles.overlay}>
|
||||
<Pressable style={styles.backdrop} onPress={handleClose} />
|
||||
|
||||
<View style={[styles.panel, { borderColor: theme.colors.border, backgroundColor: theme.colors.surface0 }]}>
|
||||
<View
|
||||
testID="command-center-panel"
|
||||
style={[styles.panel, { borderColor: theme.colors.border, backgroundColor: theme.colors.surface0 }]}
|
||||
>
|
||||
<View style={[styles.header, { borderBottomColor: theme.colors.border }]}>
|
||||
<TextInput
|
||||
testID="command-center-input"
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChangeText={setQuery}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Menu, PanelLeft } from "lucide-react-native";
|
||||
import { ScreenHeader } from "./screen-header";
|
||||
import { HeaderToggleButton } from "./header-toggle-button";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
|
||||
interface MenuHeaderProps {
|
||||
title?: string;
|
||||
@@ -18,6 +19,7 @@ export function MenuHeader({ title, rightContent }: MenuHeaderProps) {
|
||||
const mobileView = usePanelStore((state) => state.mobileView);
|
||||
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
||||
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
|
||||
const toggleShortcutKeys = getShortcutOs() === "mac" ? ["mod", "B"] : ["mod", "."];
|
||||
|
||||
const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
|
||||
const MenuIcon = isMobile ? Menu : PanelLeft;
|
||||
@@ -32,7 +34,7 @@ export function MenuHeader({ title, rightContent }: MenuHeaderProps) {
|
||||
<HeaderToggleButton
|
||||
onPress={toggleAgentList}
|
||||
tooltipLabel="Toggle sidebar"
|
||||
tooltipKeys={["mod", "B"]}
|
||||
tooltipKeys={toggleShortcutKeys}
|
||||
tooltipSide="right"
|
||||
testID="menu-button"
|
||||
nativeID="menu-button"
|
||||
|
||||
@@ -30,13 +30,13 @@ import { useDictation } from "@/hooks/use-dictation";
|
||||
import { DictationOverlay } from "./dictation-controls";
|
||||
import { RealtimeVoiceOverlay } from "./realtime-voice-overlay";
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useVoiceOptional } from "@/contexts/voice-context";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { resolveVoiceUnavailableMessage } from "@/utils/server-info-capabilities";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
|
||||
|
||||
export interface ImageAttachment {
|
||||
uri: string;
|
||||
@@ -82,6 +82,7 @@ export interface MessageInputProps {
|
||||
export interface MessageInputRef {
|
||||
focus: () => void;
|
||||
blur: () => void;
|
||||
runKeyboardAction: (action: MessageInputKeyboardActionKind) => void;
|
||||
/**
|
||||
* Web-only: return the underlying DOM element for focus assertions/retries.
|
||||
* May return null if not mounted or on native.
|
||||
@@ -138,8 +139,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
const { theme } = useUnistyles();
|
||||
const toast = useToast();
|
||||
const voice = useVoiceOptional();
|
||||
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
|
||||
const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer);
|
||||
const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT);
|
||||
const textInputRef = useRef<
|
||||
TextInput | (TextInput & { getNativeRef?: () => unknown }) | null
|
||||
@@ -153,6 +152,40 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
blur: () => {
|
||||
textInputRef.current?.blur?.();
|
||||
},
|
||||
runKeyboardAction: (action) => {
|
||||
if (action === "focus") {
|
||||
textInputRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "voice-toggle") {
|
||||
handleToggleRealtimeVoiceShortcut();
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "voice-mute-toggle") {
|
||||
if (isRealtimeVoiceForCurrentAgent) {
|
||||
voice?.toggleMute();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "dictation-cancel") {
|
||||
if (isDictatingRef.current) {
|
||||
cancelDictation();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "dictation-toggle") {
|
||||
if (isDictatingRef.current) {
|
||||
sendAfterTranscriptRef.current = true;
|
||||
confirmDictation();
|
||||
} else {
|
||||
void startDictationIfAvailable();
|
||||
}
|
||||
}
|
||||
},
|
||||
getNativeElement: () => {
|
||||
if (!IS_WEB) return null;
|
||||
const current = textInputRef.current as
|
||||
@@ -295,121 +328,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
await startDictation();
|
||||
}, [dictationUnavailableMessage, startDictation, toast]);
|
||||
|
||||
// Cmd+D to start/submit dictation, Cmd+Shift+D toggles realtime voice, Escape cancels dictation
|
||||
useEffect(() => {
|
||||
if (!IS_WEB) return;
|
||||
const toggleRealtimeVoice = () => {
|
||||
if (!voice || !voiceServerId || !voiceAgentId || !isConnected || disabled) {
|
||||
return;
|
||||
}
|
||||
if (voice.isVoiceSwitching) {
|
||||
return;
|
||||
}
|
||||
if (voice.isVoiceModeForAgent(voiceServerId, voiceAgentId)) {
|
||||
const tasks: Promise<unknown>[] = [];
|
||||
if (isAgentRunning && client) {
|
||||
tasks.push(client.cancelAgent(voiceAgentId));
|
||||
}
|
||||
tasks.push(voice.stopVoice());
|
||||
void Promise.allSettled(tasks).then((results) => {
|
||||
results.forEach((result) => {
|
||||
if (result.status === "rejected") {
|
||||
console.error(
|
||||
"[MessageInput] Failed to stop realtime voice",
|
||||
result.reason
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
void voice.startVoice(voiceServerId, voiceAgentId).catch((error) => {
|
||||
console.error("[MessageInput] Failed to start realtime voice", error);
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: typeof error === "string"
|
||||
? error
|
||||
: null;
|
||||
if (message && message.trim().length > 0) {
|
||||
toast.error(message);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const resolveNativeInput = (): unknown => {
|
||||
const current = textInputRef.current as any;
|
||||
if (!current) return null;
|
||||
if (typeof current.getNativeRef === "function") {
|
||||
return current.getNativeRef();
|
||||
}
|
||||
return current;
|
||||
};
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
const nativeInput = resolveNativeInput();
|
||||
const isFromInput = Boolean(nativeInput && event.target === nativeInput);
|
||||
if (!isScreenFocused && !isInputFocusedRef.current && !isFromInput) {
|
||||
return;
|
||||
}
|
||||
const isMod = event.metaKey || event.ctrlKey;
|
||||
const isKeyD = event.code === "KeyD" || event.key.toLowerCase() === "d";
|
||||
|
||||
if (isMod && event.shiftKey && isKeyD && !event.repeat) {
|
||||
event.preventDefault();
|
||||
toggleRealtimeVoice();
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
isRealtimeVoiceForCurrentAgent &&
|
||||
!event.metaKey &&
|
||||
!event.ctrlKey &&
|
||||
!event.altKey &&
|
||||
!event.shiftKey &&
|
||||
(event.code === "Space" || event.key === " ") &&
|
||||
!event.repeat
|
||||
) {
|
||||
event.preventDefault();
|
||||
voice?.toggleMute();
|
||||
return;
|
||||
}
|
||||
|
||||
const dictating = isDictatingRef.current;
|
||||
// Cmd+D: start dictation or submit if already dictating
|
||||
if (isMod && isKeyD) {
|
||||
event.preventDefault();
|
||||
if (dictating) {
|
||||
sendAfterTranscriptRef.current = true;
|
||||
confirmDictation();
|
||||
} else {
|
||||
void startDictationIfAvailable();
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Escape: cancel dictation
|
||||
if (event.key === "Escape" && dictating) {
|
||||
event.preventDefault();
|
||||
cancelDictation();
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [
|
||||
cancelDictation,
|
||||
client,
|
||||
confirmDictation,
|
||||
disabled,
|
||||
isAgentRunning,
|
||||
isConnected,
|
||||
isRealtimeVoiceForCurrentAgent,
|
||||
isScreenFocused,
|
||||
startDictationIfAvailable,
|
||||
toast,
|
||||
voiceAgentId,
|
||||
voiceServerId,
|
||||
voice,
|
||||
]);
|
||||
|
||||
// Animate overlay
|
||||
useEffect(() => {
|
||||
overlayTransition.value = withTiming(showOverlay ? 1 : 0, {
|
||||
@@ -636,60 +554,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
|
||||
const { shiftKey, metaKey, ctrlKey } = event.nativeEvent;
|
||||
|
||||
const key = event.nativeEvent.key.toLowerCase();
|
||||
|
||||
// Cmd+B or Ctrl+B: toggle sidebar
|
||||
if ((metaKey || ctrlKey) && key === "b") {
|
||||
event.preventDefault();
|
||||
toggleAgentList();
|
||||
return;
|
||||
}
|
||||
|
||||
// Cmd+E or Ctrl+E: toggle explorer sidebar
|
||||
if ((metaKey || ctrlKey) && key === "e") {
|
||||
event.preventDefault();
|
||||
toggleFileExplorer();
|
||||
return;
|
||||
}
|
||||
|
||||
// Cmd+Shift+D or Ctrl+Shift+D: toggle realtime voice mode
|
||||
if ((metaKey || ctrlKey) && shiftKey && key === "d") {
|
||||
event.preventDefault();
|
||||
handleToggleRealtimeVoiceShortcut();
|
||||
return;
|
||||
}
|
||||
|
||||
// Cmd+D or Ctrl+D: start dictation or submit if already dictating
|
||||
if ((metaKey || ctrlKey) && key === "d") {
|
||||
event.preventDefault();
|
||||
if (isDictating) {
|
||||
sendAfterTranscriptRef.current = true;
|
||||
confirmDictation();
|
||||
} else {
|
||||
void startDictationIfAvailable();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Escape: cancel dictation
|
||||
if (event.nativeEvent.key === "Escape" && isDictating) {
|
||||
event.preventDefault();
|
||||
cancelDictation();
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
isRealtimeVoiceForCurrentAgent &&
|
||||
!metaKey &&
|
||||
!ctrlKey &&
|
||||
!shiftKey &&
|
||||
event.nativeEvent.key === " "
|
||||
) {
|
||||
event.preventDefault();
|
||||
voice?.toggleMute();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.nativeEvent.key !== "Enter") return;
|
||||
|
||||
// Shift+Enter: add newline (default behavior, don't intercept)
|
||||
@@ -714,7 +578,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
const shouldShowSendButton = hasSendableContent || isSubmitLoading;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.container} testID="message-input-root">
|
||||
{/* Regular input */}
|
||||
<Animated.View style={[styles.inputWrapper, inputAnimatedStyle]}>
|
||||
{/* Image preview pills */}
|
||||
|
||||
@@ -45,7 +45,7 @@ import {
|
||||
type SidebarSectionData,
|
||||
} from "@/hooks/use-sidebar-agents-grouped";
|
||||
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
|
||||
import { useKeyboardNavStore } from "@/stores/keyboard-nav-store";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { getIsTauri } from "@/constants/layout";
|
||||
import { AgentStatusDot } from "@/components/agent-status-dot";
|
||||
|
||||
@@ -370,9 +370,11 @@ export function SidebarAgentList({
|
||||
const collapsedProjectKeys = useSidebarCollapsedSectionsStore((s) => s.collapsedProjectKeys);
|
||||
const toggleProjectCollapsed = useSidebarCollapsedSectionsStore((s) => s.toggleProjectCollapsed);
|
||||
|
||||
const altDown = useKeyboardNavStore((s) => s.altDown);
|
||||
const cmdOrCtrlDown = useKeyboardNavStore((s) => s.cmdOrCtrlDown);
|
||||
const sidebarShortcutAgentKeys = useKeyboardNavStore((s) => s.sidebarShortcutAgentKeys);
|
||||
const altDown = useKeyboardShortcutsStore((s) => s.altDown);
|
||||
const cmdOrCtrlDown = useKeyboardShortcutsStore((s) => s.cmdOrCtrlDown);
|
||||
const sidebarShortcutAgentKeys = useKeyboardShortcutsStore(
|
||||
(s) => s.sidebarShortcutAgentKeys
|
||||
);
|
||||
const isTauri = getIsTauri();
|
||||
const showShortcutBadges = altDown || (isTauri && cmdOrCtrlDown);
|
||||
const shortcutIndexByAgentKey = useMemo(() => {
|
||||
|
||||
@@ -18,7 +18,7 @@ import { useSidebarAgentsGrouped } from "@/hooks/use-sidebar-agents-grouped";
|
||||
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
|
||||
import { useTauriDragHandlers, useTrafficLightPadding } from "@/utils/tauri-window";
|
||||
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
|
||||
import { useKeyboardNavStore } from "@/stores/keyboard-nav-store";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { deriveSidebarShortcutAgentKeys } from "@/utils/sidebar-shortcuts";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
|
||||
@@ -121,7 +121,9 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
}, [isRevalidating, isManualRefresh]);
|
||||
|
||||
const collapsedProjectKeys = useSidebarCollapsedSectionsStore((s) => s.collapsedProjectKeys);
|
||||
const setSidebarShortcutAgentKeys = useKeyboardNavStore((s) => s.setSidebarShortcutAgentKeys);
|
||||
const setSidebarShortcutAgentKeys = useKeyboardShortcutsStore(
|
||||
(s) => s.setSidebarShortcutAgentKeys
|
||||
);
|
||||
const sidebarShortcutAgentKeys = useMemo(() => {
|
||||
return deriveSidebarShortcutAgentKeys(sections, collapsedProjectKeys, 9);
|
||||
}, [collapsedProjectKeys, sections]);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { TextInput } from "react-native";
|
||||
import { router, usePathname } from "expo-router";
|
||||
import { useKeyboardNavStore } from "@/stores/keyboard-nav-store";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { useAggregatedAgents, type AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
@@ -126,9 +126,11 @@ export type CommandCenterItem =
|
||||
export function useCommandCenter() {
|
||||
const pathname = usePathname();
|
||||
const { agents } = useAggregatedAgents();
|
||||
const open = useKeyboardNavStore((s) => s.commandCenterOpen);
|
||||
const setOpen = useKeyboardNavStore((s) => s.setCommandCenterOpen);
|
||||
const requestFocusChatInput = useKeyboardNavStore((s) => s.requestFocusChatInput);
|
||||
const open = useKeyboardShortcutsStore((s) => s.commandCenterOpen);
|
||||
const setOpen = useKeyboardShortcutsStore((s) => s.setCommandCenterOpen);
|
||||
const requestMessageInputAction = useKeyboardShortcutsStore(
|
||||
(s) => s.requestMessageInputAction
|
||||
);
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
const didNavigateRef = useRef(false);
|
||||
const prevOpenRef = useRef(open);
|
||||
@@ -220,13 +222,16 @@ export function useCommandCenter() {
|
||||
const shouldReplace = Boolean(parseHostAgentRouteFromPathname(pathname));
|
||||
const navigate = shouldReplace ? router.replace : router.push;
|
||||
|
||||
requestFocusChatInput(agentKey(agent));
|
||||
requestMessageInputAction({
|
||||
agentKey: agentKey(agent),
|
||||
kind: "focus",
|
||||
});
|
||||
// Don't restore focus back to the prior element after we navigate.
|
||||
clearCommandCenterFocusRestoreElement();
|
||||
setOpen(false);
|
||||
navigate(buildHostAgentDetailRoute(agent.serverId, agent.id) as any);
|
||||
},
|
||||
[pathname, requestFocusChatInput, setOpen]
|
||||
[pathname, requestMessageInputAction, setOpen]
|
||||
);
|
||||
|
||||
const handleSelectAction = useCallback((action: CommandCenterActionItem) => {
|
||||
@@ -267,7 +272,10 @@ export function useCommandCenter() {
|
||||
isFocused,
|
||||
onTimeout: () => {
|
||||
if (agentKeyFromPathname) {
|
||||
requestFocusChatInput(agentKeyFromPathname);
|
||||
requestMessageInputAction({
|
||||
agentKey: agentKeyFromPathname,
|
||||
kind: "focus",
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -283,7 +291,7 @@ export function useCommandCenter() {
|
||||
inputRef.current?.focus();
|
||||
}, 0);
|
||||
return () => clearTimeout(id);
|
||||
}, [agentKeyFromPathname, open, requestFocusChatInput]);
|
||||
}, [agentKeyFromPathname, open, requestMessageInputAction]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
@@ -1,316 +0,0 @@
|
||||
import { useEffect } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import { usePathname, useRouter } from "expo-router";
|
||||
import { getIsTauri } from "@/constants/layout";
|
||||
import { useKeyboardNavStore } from "@/stores/keyboard-nav-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { parseSidebarAgentKey } from "@/utils/sidebar-shortcuts";
|
||||
import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
|
||||
import {
|
||||
checkoutStatusQueryKey,
|
||||
type CheckoutStatusPayload,
|
||||
} from "@/hooks/use-checkout-status-query";
|
||||
import { queryClient } from "@/query/query-client";
|
||||
import {
|
||||
buildNewAgentRoute,
|
||||
resolveNewAgentWorkingDir,
|
||||
} from "@/utils/new-agent-routing";
|
||||
import {
|
||||
buildHostAgentDetailRoute,
|
||||
parseHostAgentRouteFromPathname,
|
||||
parseServerIdFromPathname,
|
||||
} from "@/utils/host-routes";
|
||||
|
||||
export function useGlobalKeyboardNav({
|
||||
enabled,
|
||||
isMobile,
|
||||
toggleAgentList,
|
||||
selectedAgentId,
|
||||
toggleFileExplorer,
|
||||
}: {
|
||||
enabled: boolean;
|
||||
isMobile: boolean;
|
||||
toggleAgentList: () => void;
|
||||
selectedAgentId?: string;
|
||||
toggleFileExplorer?: () => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const resetModifiers = useKeyboardNavStore((s) => s.resetModifiers);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
if (Platform.OS !== "web") return;
|
||||
if (isMobile) return;
|
||||
|
||||
const isTauri = getIsTauri();
|
||||
const shouldHandle = () => {
|
||||
if (typeof document === "undefined") return false;
|
||||
if (document.visibilityState !== "visible") return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
const isEditableTarget = (event: KeyboardEvent): boolean => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Element)) return false;
|
||||
|
||||
if ((target as HTMLElement).isContentEditable) return true;
|
||||
const tag = target.tagName.toLowerCase();
|
||||
if (tag === "input" || tag === "textarea") return true;
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const parseShortcutDigit = (event: KeyboardEvent): number | null => {
|
||||
const code = event.code ?? "";
|
||||
if (code.startsWith("Digit")) {
|
||||
const n = Number(code.slice("Digit".length));
|
||||
return Number.isFinite(n) && n >= 1 && n <= 9 ? n : null;
|
||||
}
|
||||
if (code.startsWith("Numpad")) {
|
||||
const n = Number(code.slice("Numpad".length));
|
||||
return Number.isFinite(n) && n >= 1 && n <= 9 ? n : null;
|
||||
}
|
||||
const key = event.key ?? "";
|
||||
if (key >= "1" && key <= "9") {
|
||||
return Number(key);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const navigateToSidebarShortcut = (digit: number) => {
|
||||
const state = useKeyboardNavStore.getState();
|
||||
const targetKey = state.sidebarShortcutAgentKeys[digit - 1] ?? null;
|
||||
if (!targetKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = parseSidebarAgentKey(targetKey);
|
||||
if (!parsed) {
|
||||
return;
|
||||
}
|
||||
const { serverId, agentId } = parsed;
|
||||
|
||||
const shouldReplace = Boolean(parseHostAgentRouteFromPathname(pathname));
|
||||
const navigate = shouldReplace ? router.replace : router.push;
|
||||
navigate(buildHostAgentDetailRoute(serverId, agentId) as any);
|
||||
};
|
||||
|
||||
const navigateToNewAgent = () => {
|
||||
let targetServerId = parseServerIdFromPathname(pathname);
|
||||
let targetWorkingDir: string | null = null;
|
||||
if (selectedAgentId) {
|
||||
const separatorIndex = selectedAgentId.indexOf(":");
|
||||
if (separatorIndex > 0) {
|
||||
const serverId = selectedAgentId.slice(0, separatorIndex);
|
||||
const agentId = selectedAgentId.slice(separatorIndex + 1);
|
||||
targetServerId = serverId;
|
||||
const agent = useSessionStore.getState().sessions[serverId]?.agents?.get(agentId);
|
||||
const cwd = agent?.cwd?.trim();
|
||||
if (cwd) {
|
||||
const checkout =
|
||||
queryClient.getQueryData<CheckoutStatusPayload>(
|
||||
checkoutStatusQueryKey(serverId, cwd)
|
||||
) ?? null;
|
||||
targetWorkingDir = resolveNewAgentWorkingDir(cwd, checkout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetServerId) {
|
||||
const sessionServerIds = Object.keys(useSessionStore.getState().sessions);
|
||||
targetServerId = sessionServerIds[0] ?? null;
|
||||
}
|
||||
|
||||
if (!targetServerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
router.push(buildNewAgentRoute(targetServerId, targetWorkingDir) as any);
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (!shouldHandle()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = event.key ?? "";
|
||||
const lowerKey = key.toLowerCase();
|
||||
|
||||
if (key === "Alt" && !event.shiftKey) {
|
||||
useKeyboardNavStore.getState().setAltDown(true);
|
||||
}
|
||||
if (isTauri && (key === "Meta" || key === "Control") && !event.shiftKey) {
|
||||
useKeyboardNavStore.getState().setCmdOrCtrlDown(true);
|
||||
}
|
||||
// If shift is pressed while a modifier is held, hide the badges
|
||||
if (key === "Shift") {
|
||||
const state = useKeyboardNavStore.getState();
|
||||
if (state.altDown || state.cmdOrCtrlDown) {
|
||||
state.resetModifiers();
|
||||
}
|
||||
}
|
||||
|
||||
const isMod = event.metaKey || event.ctrlKey;
|
||||
const isKeyN = event.code === "KeyN" || lowerKey === "n";
|
||||
|
||||
// Cmd/Ctrl+Alt+N: new agent (web + Tauri)
|
||||
// Note: intentionally works even when focus is inside an input/textarea.
|
||||
if (isMod && event.altKey && !event.shiftKey && isKeyN) {
|
||||
event.preventDefault();
|
||||
navigateToNewAgent();
|
||||
return;
|
||||
}
|
||||
|
||||
// Cmd/Ctrl+N: new agent (Tauri only)
|
||||
// Note: intentionally works even when focus is inside an input/textarea.
|
||||
if (isTauri && isMod && !event.altKey && !event.shiftKey && isKeyN) {
|
||||
event.preventDefault();
|
||||
navigateToNewAgent();
|
||||
return;
|
||||
}
|
||||
|
||||
// Cmd+B: toggle sidebar
|
||||
if (
|
||||
isMod &&
|
||||
(event.code === "KeyB" || lowerKey === "b")
|
||||
) {
|
||||
// The MessageInput already handles Cmd+B inside editable fields. If we also
|
||||
// handle it globally, it can double-toggle and look like it "doesn't work".
|
||||
if (isEditableTarget(event)) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
toggleAgentList();
|
||||
return;
|
||||
}
|
||||
|
||||
// Cmd+.: toggle sidebar (VS Code quick-fix muscle memory)
|
||||
// Note: intentionally works even when focus is inside an input/textarea.
|
||||
if (
|
||||
isMod &&
|
||||
(event.code === "Period" || key === ".")
|
||||
) {
|
||||
// Ignore while command center is open.
|
||||
if (useKeyboardNavStore.getState().commandCenterOpen) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
toggleAgentList();
|
||||
return;
|
||||
}
|
||||
|
||||
// Cmd+E: toggle explorer sidebar (only when an agent is selected)
|
||||
if (
|
||||
selectedAgentId &&
|
||||
toggleFileExplorer &&
|
||||
isMod &&
|
||||
(event.code === "KeyE" || lowerKey === "e")
|
||||
) {
|
||||
// Same double-toggle issue as Cmd+B when focus is inside a text input.
|
||||
if (isEditableTarget(event)) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
toggleFileExplorer();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+`: toggle explorer sidebar (VS Code muscle memory)
|
||||
// Note: intentionally works even when focus is inside an input/textarea.
|
||||
if (
|
||||
selectedAgentId &&
|
||||
toggleFileExplorer &&
|
||||
event.ctrlKey &&
|
||||
!event.metaKey &&
|
||||
(event.code === "Backquote" || key === "`")
|
||||
) {
|
||||
// Ignore while command center is open.
|
||||
if (useKeyboardNavStore.getState().commandCenterOpen) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
toggleFileExplorer();
|
||||
return;
|
||||
}
|
||||
|
||||
// Cmd+K: command center
|
||||
if (isMod && lowerKey === "k") {
|
||||
event.preventDefault();
|
||||
const s = useKeyboardNavStore.getState();
|
||||
if (!s.commandCenterOpen) {
|
||||
const target =
|
||||
event.target instanceof Element ? (event.target as Element) : null;
|
||||
const targetEl =
|
||||
target?.closest?.("textarea, input, [contenteditable='true']") ??
|
||||
(target instanceof HTMLElement ? target : null);
|
||||
const active = document.activeElement;
|
||||
const activeEl = active instanceof HTMLElement ? active : null;
|
||||
setCommandCenterFocusRestoreElement(
|
||||
(targetEl as HTMLElement | null) ?? activeEl ?? null
|
||||
);
|
||||
}
|
||||
s.setCommandCenterOpen(!s.commandCenterOpen);
|
||||
return;
|
||||
}
|
||||
|
||||
// Number switching: ignore while command center is open.
|
||||
if (useKeyboardNavStore.getState().commandCenterOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const digit = parseShortcutDigit(event);
|
||||
if (!digit) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Alt/Option+number: always (web + Tauri)
|
||||
if (event.altKey) {
|
||||
event.preventDefault();
|
||||
navigateToSidebarShortcut(digit);
|
||||
return;
|
||||
}
|
||||
|
||||
// Cmd/Ctrl+number: Tauri only (avoid browser tab switching)
|
||||
if (isTauri && isMod) {
|
||||
event.preventDefault();
|
||||
navigateToSidebarShortcut(digit);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyUp = (event: KeyboardEvent) => {
|
||||
const key = event.key ?? "";
|
||||
if (key === "Alt") {
|
||||
useKeyboardNavStore.getState().setAltDown(false);
|
||||
}
|
||||
if (isTauri && (key === "Meta" || key === "Control")) {
|
||||
useKeyboardNavStore.getState().setCmdOrCtrlDown(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlurOrHide = () => {
|
||||
resetModifiers();
|
||||
};
|
||||
|
||||
// react-native-web can stop propagation on key events, so listen in capture phase.
|
||||
window.addEventListener("keydown", handleKeyDown, true);
|
||||
window.addEventListener("keyup", handleKeyUp, true);
|
||||
window.addEventListener("blur", handleBlurOrHide);
|
||||
document.addEventListener("visibilitychange", handleBlurOrHide);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown, true);
|
||||
window.removeEventListener("keyup", handleKeyUp, true);
|
||||
window.removeEventListener("blur", handleBlurOrHide);
|
||||
document.removeEventListener("visibilitychange", handleBlurOrHide);
|
||||
};
|
||||
}, [
|
||||
enabled,
|
||||
isMobile,
|
||||
pathname,
|
||||
resetModifiers,
|
||||
router,
|
||||
selectedAgentId,
|
||||
toggleAgentList,
|
||||
toggleFileExplorer,
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import { useEffect } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import { usePathname, useRouter } from "expo-router";
|
||||
import { getIsTauri } from "@/constants/layout";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { parseSidebarAgentKey } from "@/utils/sidebar-shortcuts";
|
||||
import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
|
||||
import {
|
||||
checkoutStatusQueryKey,
|
||||
type CheckoutStatusPayload,
|
||||
} from "@/hooks/use-checkout-status-query";
|
||||
import { queryClient } from "@/query/query-client";
|
||||
import {
|
||||
buildNewAgentRoute,
|
||||
resolveNewAgentWorkingDir,
|
||||
} from "@/utils/new-agent-routing";
|
||||
import {
|
||||
buildHostAgentDetailRoute,
|
||||
parseHostAgentRouteFromPathname,
|
||||
parseServerIdFromPathname,
|
||||
} from "@/utils/host-routes";
|
||||
import {
|
||||
type MessageInputKeyboardActionKind,
|
||||
type KeyboardShortcutPayload,
|
||||
} from "@/keyboard/actions";
|
||||
import { resolveKeyboardShortcut } from "@/keyboard/keyboard-shortcuts";
|
||||
import { resolveKeyboardFocusScope } from "@/keyboard/focus-scope";
|
||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
|
||||
function resolveSelectedOrRouteAgentKey(input: {
|
||||
selectedAgentId?: string;
|
||||
pathname: string;
|
||||
}): string | null {
|
||||
if (input.selectedAgentId) {
|
||||
return input.selectedAgentId;
|
||||
}
|
||||
const route = parseHostAgentRouteFromPathname(input.pathname);
|
||||
if (!route) {
|
||||
return null;
|
||||
}
|
||||
return `${route.serverId}:${route.agentId}`;
|
||||
}
|
||||
|
||||
export function useKeyboardShortcuts({
|
||||
enabled,
|
||||
isMobile,
|
||||
toggleAgentList,
|
||||
selectedAgentId,
|
||||
toggleFileExplorer,
|
||||
}: {
|
||||
enabled: boolean;
|
||||
isMobile: boolean;
|
||||
toggleAgentList: () => void;
|
||||
selectedAgentId?: string;
|
||||
toggleFileExplorer?: () => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const resetModifiers = useKeyboardShortcutsStore((s) => s.resetModifiers);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
if (Platform.OS !== "web") return;
|
||||
if (isMobile) return;
|
||||
|
||||
const isTauri = getIsTauri();
|
||||
const isMac = getShortcutOs() === "mac";
|
||||
|
||||
const shouldHandle = () => {
|
||||
if (typeof document === "undefined") return false;
|
||||
if (document.visibilityState !== "visible") return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
const navigateToSidebarShortcut = (digit: number): boolean => {
|
||||
const state = useKeyboardShortcutsStore.getState();
|
||||
const targetKey = state.sidebarShortcutAgentKeys[digit - 1] ?? null;
|
||||
if (!targetKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const parsed = parseSidebarAgentKey(targetKey);
|
||||
if (!parsed) {
|
||||
return false;
|
||||
}
|
||||
const { serverId, agentId } = parsed;
|
||||
|
||||
const shouldReplace = Boolean(parseHostAgentRouteFromPathname(pathname));
|
||||
const navigate = shouldReplace ? router.replace : router.push;
|
||||
navigate(buildHostAgentDetailRoute(serverId, agentId) as any);
|
||||
return true;
|
||||
};
|
||||
|
||||
const navigateToNewAgent = (): boolean => {
|
||||
let targetServerId = parseServerIdFromPathname(pathname);
|
||||
let targetWorkingDir: string | null = null;
|
||||
if (selectedAgentId) {
|
||||
const separatorIndex = selectedAgentId.indexOf(":");
|
||||
if (separatorIndex > 0) {
|
||||
const serverId = selectedAgentId.slice(0, separatorIndex);
|
||||
const agentId = selectedAgentId.slice(separatorIndex + 1);
|
||||
targetServerId = serverId;
|
||||
const agent = useSessionStore.getState().sessions[serverId]?.agents?.get(agentId);
|
||||
const cwd = agent?.cwd?.trim();
|
||||
if (cwd) {
|
||||
const checkout =
|
||||
queryClient.getQueryData<CheckoutStatusPayload>(
|
||||
checkoutStatusQueryKey(serverId, cwd)
|
||||
) ?? null;
|
||||
targetWorkingDir = resolveNewAgentWorkingDir(cwd, checkout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetServerId) {
|
||||
const sessionServerIds = Object.keys(useSessionStore.getState().sessions);
|
||||
targetServerId = sessionServerIds[0] ?? null;
|
||||
}
|
||||
|
||||
if (!targetServerId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
router.push(buildNewAgentRoute(targetServerId, targetWorkingDir) as any);
|
||||
return true;
|
||||
};
|
||||
|
||||
const requestMessageInputAction = (
|
||||
kind: MessageInputKeyboardActionKind
|
||||
): boolean => {
|
||||
const agentKey = resolveSelectedOrRouteAgentKey({ selectedAgentId, pathname });
|
||||
if (!agentKey) {
|
||||
return false;
|
||||
}
|
||||
useKeyboardShortcutsStore.getState().requestMessageInputAction({
|
||||
agentKey,
|
||||
kind,
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleAction = (input: {
|
||||
action: string;
|
||||
payload: KeyboardShortcutPayload;
|
||||
event: KeyboardEvent;
|
||||
}): boolean => {
|
||||
switch (input.action) {
|
||||
case "agent.new":
|
||||
return navigateToNewAgent();
|
||||
case "sidebar.toggle.left":
|
||||
toggleAgentList();
|
||||
return true;
|
||||
case "sidebar.toggle.right":
|
||||
if (!selectedAgentId || !toggleFileExplorer) {
|
||||
return false;
|
||||
}
|
||||
toggleFileExplorer();
|
||||
return true;
|
||||
case "sidebar.navigate.shortcut":
|
||||
if (!input.payload || typeof input.payload !== "object" || !("digit" in input.payload)) {
|
||||
return false;
|
||||
}
|
||||
return navigateToSidebarShortcut(input.payload.digit);
|
||||
case "command-center.toggle": {
|
||||
const store = useKeyboardShortcutsStore.getState();
|
||||
if (!store.commandCenterOpen) {
|
||||
const target =
|
||||
input.event.target instanceof Element ? (input.event.target as Element) : null;
|
||||
const targetEl =
|
||||
target?.closest?.("textarea, input, [contenteditable='true']") ??
|
||||
(target instanceof HTMLElement ? target : null);
|
||||
const active = document.activeElement;
|
||||
const activeEl = active instanceof HTMLElement ? active : null;
|
||||
setCommandCenterFocusRestoreElement(
|
||||
(targetEl as HTMLElement | null) ?? activeEl ?? null
|
||||
);
|
||||
}
|
||||
store.setCommandCenterOpen(!store.commandCenterOpen);
|
||||
return true;
|
||||
}
|
||||
case "message-input.action":
|
||||
if (!input.payload || typeof input.payload !== "object" || !("kind" in input.payload)) {
|
||||
return false;
|
||||
}
|
||||
return requestMessageInputAction(input.payload.kind);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (!shouldHandle()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = event.key ?? "";
|
||||
if (key === "Alt" && !event.shiftKey) {
|
||||
useKeyboardShortcutsStore.getState().setAltDown(true);
|
||||
}
|
||||
if (isTauri && (key === "Meta" || key === "Control") && !event.shiftKey) {
|
||||
useKeyboardShortcutsStore.getState().setCmdOrCtrlDown(true);
|
||||
}
|
||||
if (key === "Shift") {
|
||||
const state = useKeyboardShortcutsStore.getState();
|
||||
if (state.altDown || state.cmdOrCtrlDown) {
|
||||
state.resetModifiers();
|
||||
}
|
||||
}
|
||||
|
||||
const store = useKeyboardShortcutsStore.getState();
|
||||
const focusScope = resolveKeyboardFocusScope({
|
||||
target: event.target,
|
||||
commandCenterOpen: store.commandCenterOpen,
|
||||
});
|
||||
const match = resolveKeyboardShortcut({
|
||||
event,
|
||||
context: {
|
||||
isMac,
|
||||
isTauri,
|
||||
focusScope,
|
||||
commandCenterOpen: store.commandCenterOpen,
|
||||
hasSelectedAgent: Boolean(selectedAgentId && toggleFileExplorer),
|
||||
},
|
||||
});
|
||||
if (!match) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handled = handleAction({
|
||||
action: match.action,
|
||||
payload: match.payload,
|
||||
event,
|
||||
});
|
||||
if (!handled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (match.preventDefault) {
|
||||
event.preventDefault();
|
||||
}
|
||||
if (match.stopPropagation) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyUp = (event: KeyboardEvent) => {
|
||||
const key = event.key ?? "";
|
||||
if (key === "Alt") {
|
||||
useKeyboardShortcutsStore.getState().setAltDown(false);
|
||||
}
|
||||
if (isTauri && (key === "Meta" || key === "Control")) {
|
||||
useKeyboardShortcutsStore.getState().setCmdOrCtrlDown(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlurOrHide = () => {
|
||||
resetModifiers();
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown, true);
|
||||
window.addEventListener("keyup", handleKeyUp, true);
|
||||
window.addEventListener("blur", handleBlurOrHide);
|
||||
document.addEventListener("visibilitychange", handleBlurOrHide);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown, true);
|
||||
window.removeEventListener("keyup", handleKeyUp, true);
|
||||
window.removeEventListener("blur", handleBlurOrHide);
|
||||
document.removeEventListener("visibilitychange", handleBlurOrHide);
|
||||
};
|
||||
}, [
|
||||
enabled,
|
||||
isMobile,
|
||||
pathname,
|
||||
resetModifiers,
|
||||
router,
|
||||
selectedAgentId,
|
||||
toggleAgentList,
|
||||
toggleFileExplorer,
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
export type KeyboardFocusScope =
|
||||
| "terminal"
|
||||
| "message-input"
|
||||
| "command-center"
|
||||
| "editable"
|
||||
| "other";
|
||||
|
||||
export type MessageInputKeyboardActionKind =
|
||||
| "focus"
|
||||
| "dictation-toggle"
|
||||
| "dictation-cancel"
|
||||
| "voice-toggle"
|
||||
| "voice-mute-toggle";
|
||||
|
||||
export type KeyboardActionId =
|
||||
| "agent.new"
|
||||
| "sidebar.toggle.left"
|
||||
| "sidebar.toggle.right"
|
||||
| "sidebar.navigate.shortcut"
|
||||
| "command-center.toggle"
|
||||
| "message-input.action";
|
||||
|
||||
export type KeyboardShortcutPayload =
|
||||
| { digit: number }
|
||||
| { kind: MessageInputKeyboardActionKind }
|
||||
| null;
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { KeyboardFocusScope } from "@/keyboard/actions";
|
||||
|
||||
export function resolveKeyboardFocusScope(input: {
|
||||
target: EventTarget | null;
|
||||
commandCenterOpen: boolean;
|
||||
}): KeyboardFocusScope {
|
||||
const { target, commandCenterOpen } = input;
|
||||
if (!(target instanceof Element)) {
|
||||
return commandCenterOpen ? "command-center" : "other";
|
||||
}
|
||||
|
||||
if (
|
||||
target.closest("[data-testid='terminal-surface']") ||
|
||||
target.closest(".xterm")
|
||||
) {
|
||||
return "terminal";
|
||||
}
|
||||
|
||||
if (
|
||||
commandCenterOpen &&
|
||||
(target.closest("[data-testid='command-center-panel']") ||
|
||||
target.closest("[data-testid='command-center-input']"))
|
||||
) {
|
||||
return "command-center";
|
||||
}
|
||||
|
||||
if (target.closest("[data-testid='message-input-root']")) {
|
||||
return "message-input";
|
||||
}
|
||||
|
||||
const editable = target as HTMLElement;
|
||||
if (editable.isContentEditable) {
|
||||
return commandCenterOpen ? "command-center" : "editable";
|
||||
}
|
||||
|
||||
const tag = target.tagName.toLowerCase();
|
||||
if (tag === "input" || tag === "textarea" || tag === "select") {
|
||||
return commandCenterOpen ? "command-center" : "editable";
|
||||
}
|
||||
|
||||
return commandCenterOpen ? "command-center" : "other";
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
resolveKeyboardShortcut,
|
||||
type KeyboardShortcutContext,
|
||||
} from "./keyboard-shortcuts";
|
||||
|
||||
function keyboardEvent(overrides: Partial<KeyboardEvent>): KeyboardEvent {
|
||||
return {
|
||||
key: "",
|
||||
code: "",
|
||||
altKey: false,
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
shiftKey: false,
|
||||
repeat: false,
|
||||
...overrides,
|
||||
} as KeyboardEvent;
|
||||
}
|
||||
|
||||
function shortcutContext(
|
||||
overrides: Partial<KeyboardShortcutContext> = {}
|
||||
): KeyboardShortcutContext {
|
||||
return {
|
||||
isMac: false,
|
||||
isTauri: false,
|
||||
focusScope: "other",
|
||||
commandCenterOpen: false,
|
||||
hasSelectedAgent: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("keyboard-shortcuts", () => {
|
||||
it("matches Cmd+B sidebar toggle on macOS", () => {
|
||||
const match = resolveKeyboardShortcut({
|
||||
event: keyboardEvent({
|
||||
key: "b",
|
||||
code: "KeyB",
|
||||
metaKey: true,
|
||||
}),
|
||||
context: shortcutContext({ isMac: true }),
|
||||
});
|
||||
|
||||
expect(match?.action).toBe("sidebar.toggle.left");
|
||||
});
|
||||
|
||||
it("does not bind Ctrl+B on non-mac", () => {
|
||||
const match = resolveKeyboardShortcut({
|
||||
event: keyboardEvent({
|
||||
key: "b",
|
||||
code: "KeyB",
|
||||
ctrlKey: true,
|
||||
}),
|
||||
context: shortcutContext({ isMac: false }),
|
||||
});
|
||||
|
||||
expect(match).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps Mod+. as sidebar toggle fallback", () => {
|
||||
const match = resolveKeyboardShortcut({
|
||||
event: keyboardEvent({
|
||||
key: ".",
|
||||
code: "Period",
|
||||
ctrlKey: true,
|
||||
}),
|
||||
context: shortcutContext({ isMac: false }),
|
||||
});
|
||||
|
||||
expect(match?.action).toBe("sidebar.toggle.left");
|
||||
});
|
||||
|
||||
it("routes Mod+D to message-input action outside terminal", () => {
|
||||
const match = resolveKeyboardShortcut({
|
||||
event: keyboardEvent({
|
||||
key: "d",
|
||||
code: "KeyD",
|
||||
metaKey: true,
|
||||
}),
|
||||
context: shortcutContext({ isMac: true, focusScope: "message-input" }),
|
||||
});
|
||||
|
||||
expect(match?.action).toBe("message-input.action");
|
||||
expect(match?.payload).toEqual({ kind: "dictation-toggle" });
|
||||
});
|
||||
|
||||
it("does not route message-input actions when terminal is focused", () => {
|
||||
const match = resolveKeyboardShortcut({
|
||||
event: keyboardEvent({
|
||||
key: "d",
|
||||
code: "KeyD",
|
||||
metaKey: true,
|
||||
}),
|
||||
context: shortcutContext({ isMac: true, focusScope: "terminal" }),
|
||||
});
|
||||
|
||||
expect(match).toBeNull();
|
||||
});
|
||||
|
||||
it("parses Alt+digit sidebar shortcut payload", () => {
|
||||
const match = resolveKeyboardShortcut({
|
||||
event: keyboardEvent({
|
||||
key: "2",
|
||||
code: "Digit2",
|
||||
altKey: true,
|
||||
}),
|
||||
context: shortcutContext(),
|
||||
});
|
||||
|
||||
expect(match?.action).toBe("sidebar.navigate.shortcut");
|
||||
expect(match?.payload).toEqual({ digit: 2 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,227 @@
|
||||
import type {
|
||||
KeyboardActionId,
|
||||
KeyboardFocusScope,
|
||||
KeyboardShortcutPayload,
|
||||
MessageInputKeyboardActionKind,
|
||||
} from "@/keyboard/actions";
|
||||
|
||||
export type KeyboardShortcutContext = {
|
||||
isMac: boolean;
|
||||
isTauri: boolean;
|
||||
focusScope: KeyboardFocusScope;
|
||||
commandCenterOpen: boolean;
|
||||
hasSelectedAgent: boolean;
|
||||
};
|
||||
|
||||
export type KeyboardShortcutMatch = {
|
||||
action: KeyboardActionId;
|
||||
payload: KeyboardShortcutPayload;
|
||||
preventDefault: boolean;
|
||||
stopPropagation: boolean;
|
||||
};
|
||||
|
||||
type KeyboardShortcutBinding = {
|
||||
id: string;
|
||||
action: KeyboardActionId;
|
||||
matches: (event: KeyboardEvent) => boolean;
|
||||
when: (context: KeyboardShortcutContext) => boolean;
|
||||
payload?: (event: KeyboardEvent) => KeyboardShortcutPayload;
|
||||
preventDefault?: boolean;
|
||||
stopPropagation?: boolean;
|
||||
};
|
||||
|
||||
function isMod(event: KeyboardEvent): boolean {
|
||||
return event.metaKey || event.ctrlKey;
|
||||
}
|
||||
|
||||
function parseDigit(event: KeyboardEvent): number | null {
|
||||
const code = event.code ?? "";
|
||||
if (code.startsWith("Digit")) {
|
||||
const value = Number(code.slice("Digit".length));
|
||||
return Number.isFinite(value) && value >= 1 && value <= 9 ? value : null;
|
||||
}
|
||||
if (code.startsWith("Numpad")) {
|
||||
const value = Number(code.slice("Numpad".length));
|
||||
return Number.isFinite(value) && value >= 1 && value <= 9 ? value : null;
|
||||
}
|
||||
const key = event.key ?? "";
|
||||
if (key >= "1" && key <= "9") {
|
||||
return Number(key);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasDigit(event: KeyboardEvent): boolean {
|
||||
return parseDigit(event) !== null;
|
||||
}
|
||||
|
||||
function withMessageInputAction(
|
||||
kind: MessageInputKeyboardActionKind
|
||||
): (event: KeyboardEvent) => KeyboardShortcutPayload {
|
||||
return () => ({ kind });
|
||||
}
|
||||
|
||||
const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
|
||||
{
|
||||
id: "agent-new-mod-alt-n",
|
||||
action: "agent.new",
|
||||
matches: (event) =>
|
||||
isMod(event) &&
|
||||
event.altKey &&
|
||||
!event.shiftKey &&
|
||||
(event.code === "KeyN" || event.key.toLowerCase() === "n"),
|
||||
when: () => true,
|
||||
},
|
||||
{
|
||||
id: "agent-new-tauri-mod-n",
|
||||
action: "agent.new",
|
||||
matches: (event) =>
|
||||
isMod(event) &&
|
||||
!event.altKey &&
|
||||
!event.shiftKey &&
|
||||
(event.code === "KeyN" || event.key.toLowerCase() === "n"),
|
||||
when: (context) => context.isTauri,
|
||||
},
|
||||
{
|
||||
id: "command-center-toggle",
|
||||
action: "command-center.toggle",
|
||||
matches: (event) =>
|
||||
isMod(event) &&
|
||||
!event.altKey &&
|
||||
!event.shiftKey &&
|
||||
(event.code === "KeyK" || event.key.toLowerCase() === "k"),
|
||||
when: () => true,
|
||||
},
|
||||
{
|
||||
id: "sidebar-toggle-left-mac-cmd-b",
|
||||
action: "sidebar.toggle.left",
|
||||
matches: (event) =>
|
||||
event.metaKey &&
|
||||
!event.ctrlKey &&
|
||||
!event.altKey &&
|
||||
!event.shiftKey &&
|
||||
(event.code === "KeyB" || event.key.toLowerCase() === "b"),
|
||||
when: (context) => context.isMac,
|
||||
},
|
||||
{
|
||||
id: "sidebar-toggle-left-mod-period",
|
||||
action: "sidebar.toggle.left",
|
||||
matches: (event) =>
|
||||
isMod(event) &&
|
||||
!event.altKey &&
|
||||
!event.shiftKey &&
|
||||
(event.code === "Period" || event.key === "."),
|
||||
when: (context) => !context.commandCenterOpen,
|
||||
},
|
||||
{
|
||||
id: "sidebar-toggle-right-mod-e",
|
||||
action: "sidebar.toggle.right",
|
||||
matches: (event) =>
|
||||
isMod(event) &&
|
||||
!event.altKey &&
|
||||
!event.shiftKey &&
|
||||
(event.code === "KeyE" || event.key.toLowerCase() === "e"),
|
||||
when: (context) => context.hasSelectedAgent && !context.commandCenterOpen,
|
||||
},
|
||||
{
|
||||
id: "sidebar-toggle-right-ctrl-backquote",
|
||||
action: "sidebar.toggle.right",
|
||||
matches: (event) =>
|
||||
event.ctrlKey &&
|
||||
!event.metaKey &&
|
||||
!event.altKey &&
|
||||
!event.shiftKey &&
|
||||
(event.code === "Backquote" || event.key === "`"),
|
||||
when: (context) => context.hasSelectedAgent && !context.commandCenterOpen,
|
||||
},
|
||||
{
|
||||
id: "message-input-voice-toggle",
|
||||
action: "message-input.action",
|
||||
matches: (event) =>
|
||||
isMod(event) &&
|
||||
event.shiftKey &&
|
||||
!event.altKey &&
|
||||
(event.code === "KeyD" || event.key.toLowerCase() === "d") &&
|
||||
!event.repeat,
|
||||
payload: withMessageInputAction("voice-toggle"),
|
||||
when: (context) =>
|
||||
!context.commandCenterOpen && context.focusScope !== "terminal",
|
||||
},
|
||||
{
|
||||
id: "message-input-dictation-toggle",
|
||||
action: "message-input.action",
|
||||
matches: (event) =>
|
||||
isMod(event) &&
|
||||
!event.shiftKey &&
|
||||
!event.altKey &&
|
||||
(event.code === "KeyD" || event.key.toLowerCase() === "d"),
|
||||
payload: withMessageInputAction("dictation-toggle"),
|
||||
when: (context) =>
|
||||
!context.commandCenterOpen && context.focusScope !== "terminal",
|
||||
},
|
||||
{
|
||||
id: "message-input-dictation-cancel",
|
||||
action: "message-input.action",
|
||||
matches: (event) => event.key === "Escape",
|
||||
payload: withMessageInputAction("dictation-cancel"),
|
||||
when: (context) =>
|
||||
!context.commandCenterOpen && context.focusScope !== "terminal",
|
||||
},
|
||||
{
|
||||
id: "message-input-voice-mute-toggle",
|
||||
action: "message-input.action",
|
||||
matches: (event) =>
|
||||
!event.metaKey &&
|
||||
!event.ctrlKey &&
|
||||
!event.altKey &&
|
||||
!event.shiftKey &&
|
||||
(event.code === "Space" || event.key === " ") &&
|
||||
!event.repeat,
|
||||
payload: withMessageInputAction("voice-mute-toggle"),
|
||||
when: (context) =>
|
||||
!context.commandCenterOpen && context.focusScope !== "terminal",
|
||||
},
|
||||
{
|
||||
id: "sidebar-shortcut-alt-digit",
|
||||
action: "sidebar.navigate.shortcut",
|
||||
matches: (event) => event.altKey && hasDigit(event),
|
||||
payload: (event) => {
|
||||
const digit = parseDigit(event);
|
||||
return digit ? { digit } : null;
|
||||
},
|
||||
when: (context) => !context.commandCenterOpen,
|
||||
},
|
||||
{
|
||||
id: "sidebar-shortcut-tauri-mod-digit",
|
||||
action: "sidebar.navigate.shortcut",
|
||||
matches: (event) => isMod(event) && hasDigit(event),
|
||||
payload: (event) => {
|
||||
const digit = parseDigit(event);
|
||||
return digit ? { digit } : null;
|
||||
},
|
||||
when: (context) => context.isTauri && !context.commandCenterOpen,
|
||||
},
|
||||
];
|
||||
|
||||
export function resolveKeyboardShortcut(input: {
|
||||
event: KeyboardEvent;
|
||||
context: KeyboardShortcutContext;
|
||||
}): KeyboardShortcutMatch | null {
|
||||
const { event, context } = input;
|
||||
for (const binding of SHORTCUT_BINDINGS) {
|
||||
if (!binding.matches(event)) {
|
||||
continue;
|
||||
}
|
||||
if (!binding.when(context)) {
|
||||
continue;
|
||||
}
|
||||
const payload = binding.payload?.(event) ?? null;
|
||||
return {
|
||||
action: binding.action,
|
||||
payload,
|
||||
preventDefault: binding.preventDefault ?? true,
|
||||
stopPropagation: binding.stopPropagation ?? true,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
type FocusChatInputRequest = {
|
||||
id: number;
|
||||
agentKey: string;
|
||||
};
|
||||
|
||||
interface KeyboardNavState {
|
||||
commandCenterOpen: boolean;
|
||||
altDown: boolean;
|
||||
cmdOrCtrlDown: boolean;
|
||||
/** Sidebar-visible agent keys (up to 9), in top-to-bottom visual order. */
|
||||
sidebarShortcutAgentKeys: string[];
|
||||
|
||||
/** Web-only request to focus the MessageInput for the selected agent. */
|
||||
focusChatInputRequest: FocusChatInputRequest | null;
|
||||
requestFocusChatInput: (agentKey: string) => void;
|
||||
clearFocusChatInputRequest: () => void;
|
||||
|
||||
setCommandCenterOpen: (open: boolean) => void;
|
||||
setAltDown: (down: boolean) => void;
|
||||
setCmdOrCtrlDown: (down: boolean) => void;
|
||||
setSidebarShortcutAgentKeys: (keys: string[]) => void;
|
||||
resetModifiers: () => void;
|
||||
}
|
||||
|
||||
export const useKeyboardNavStore = create<KeyboardNavState>((set, get) => ({
|
||||
commandCenterOpen: false,
|
||||
altDown: false,
|
||||
cmdOrCtrlDown: false,
|
||||
sidebarShortcutAgentKeys: [],
|
||||
|
||||
focusChatInputRequest: null,
|
||||
requestFocusChatInput: (agentKey) => {
|
||||
const prev = get().focusChatInputRequest;
|
||||
const id = (prev?.id ?? 0) + 1;
|
||||
set({ focusChatInputRequest: { id, agentKey } });
|
||||
},
|
||||
clearFocusChatInputRequest: () => set({ focusChatInputRequest: null }),
|
||||
|
||||
setCommandCenterOpen: (open) => set({ commandCenterOpen: open }),
|
||||
setAltDown: (down) => set({ altDown: down }),
|
||||
setCmdOrCtrlDown: (down) => set({ cmdOrCtrlDown: down }),
|
||||
setSidebarShortcutAgentKeys: (keys) => set({ sidebarShortcutAgentKeys: keys }),
|
||||
resetModifiers: () => set({ altDown: false, cmdOrCtrlDown: false }),
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { create } from "zustand";
|
||||
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
|
||||
|
||||
export type MessageInputActionRequest = {
|
||||
id: number;
|
||||
agentKey: string;
|
||||
kind: MessageInputKeyboardActionKind;
|
||||
};
|
||||
|
||||
interface KeyboardShortcutsState {
|
||||
commandCenterOpen: boolean;
|
||||
altDown: boolean;
|
||||
cmdOrCtrlDown: boolean;
|
||||
/** Sidebar-visible agent keys (up to 9), in top-to-bottom visual order. */
|
||||
sidebarShortcutAgentKeys: string[];
|
||||
messageInputActionRequest: MessageInputActionRequest | null;
|
||||
|
||||
setCommandCenterOpen: (open: boolean) => void;
|
||||
setAltDown: (down: boolean) => void;
|
||||
setCmdOrCtrlDown: (down: boolean) => void;
|
||||
setSidebarShortcutAgentKeys: (keys: string[]) => void;
|
||||
resetModifiers: () => void;
|
||||
|
||||
requestMessageInputAction: (input: {
|
||||
agentKey: string;
|
||||
kind: MessageInputKeyboardActionKind;
|
||||
}) => void;
|
||||
clearMessageInputActionRequest: (id: number) => void;
|
||||
}
|
||||
|
||||
export const useKeyboardShortcutsStore = create<KeyboardShortcutsState>(
|
||||
(set, get) => ({
|
||||
commandCenterOpen: false,
|
||||
altDown: false,
|
||||
cmdOrCtrlDown: false,
|
||||
sidebarShortcutAgentKeys: [],
|
||||
messageInputActionRequest: null,
|
||||
|
||||
setCommandCenterOpen: (open) => set({ commandCenterOpen: open }),
|
||||
setAltDown: (down) => set({ altDown: down }),
|
||||
setCmdOrCtrlDown: (down) => set({ cmdOrCtrlDown: down }),
|
||||
setSidebarShortcutAgentKeys: (keys) => set({ sidebarShortcutAgentKeys: keys }),
|
||||
resetModifiers: () => set({ altDown: false, cmdOrCtrlDown: false }),
|
||||
|
||||
requestMessageInputAction: ({ agentKey, kind }) => {
|
||||
const previous = get().messageInputActionRequest;
|
||||
const id = (previous?.id ?? 0) + 1;
|
||||
set({ messageInputActionRequest: { id, agentKey, kind } });
|
||||
},
|
||||
clearMessageInputActionRequest: (id) => {
|
||||
const current = get().messageInputActionRequest;
|
||||
if (!current || current.id !== id) {
|
||||
return;
|
||||
}
|
||||
set({ messageInputActionRequest: null });
|
||||
},
|
||||
})
|
||||
);
|
||||
Reference in New Issue
Block a user