Fix Cmd+K focus restore and focus input

This commit is contained in:
Mohamed Boudra
2026-02-04 11:50:39 +07:00
parent 1e2fbb04ff
commit d839716e0c
4 changed files with 106 additions and 4 deletions
@@ -28,6 +28,7 @@ import { Theme } from "@/styles/theme";
import { CommandAutocomplete } from "./command-autocomplete"; import { CommandAutocomplete } from "./command-autocomplete";
import { useAgentCommandsQuery } from "@/hooks/use-agent-commands-query"; import { useAgentCommandsQuery } from "@/hooks/use-agent-commands-query";
import { encodeImages } from "@/utils/encode-images"; import { encodeImages } from "@/utils/encode-images";
import { useKeyboardNavStore } from "@/stores/keyboard-nav-store";
type QueuedMessage = { type QueuedMessage = {
id: string; id: string;
@@ -98,6 +99,8 @@ export function AgentInputArea({
const [selectedImages, setSelectedImages] = useState<ImageAttachment[]>([]); const [selectedImages, setSelectedImages] = useState<ImageAttachment[]>([]);
const [isCancellingAgent, setIsCancellingAgent] = useState(false); const [isCancellingAgent, setIsCancellingAgent] = useState(false);
const [commandSelectedIndex, setCommandSelectedIndex] = useState(0); const [commandSelectedIndex, setCommandSelectedIndex] = useState(0);
const focusChatInputRequest = useKeyboardNavStore((s) => s.focusChatInputRequest);
const clearFocusChatInputRequest = useKeyboardNavStore((s) => s.clearFocusChatInputRequest);
// Command autocomplete logic // Command autocomplete logic
const showCommandAutocomplete = userInput.startsWith("/") && !userInput.includes(" "); const showCommandAutocomplete = userInput.startsWith("/") && !userInput.includes(" ");
@@ -398,6 +401,19 @@ export function AgentInputArea({
queueMessage(payload.text, payload.images); queueMessage(payload.text, payload.images);
}, []); }, []);
useEffect(() => {
const req = focusChatInputRequest;
if (!req) return;
if (req.agentKey !== `${serverId}:${agentId}`) return;
requestAnimationFrame(() => {
requestAnimationFrame(() => {
messageInputRef.current?.focus();
clearFocusChatInputRequest();
});
});
}, [agentId, clearFocusChatInputRequest, focusChatInputRequest, serverId]);
// Handle command selection from autocomplete // Handle command selection from autocomplete
const handleCommandSelect = useCallback( const handleCommandSelect = useCallback(
(cmd: { name: string; description: string; argumentHint: string }) => { (cmd: { name: string; description: string; argumentHint: string }) => {
+32 -2
View File
@@ -48,7 +48,11 @@ export function CommandCenter() {
const { agents } = useAggregatedAgents(); const { agents } = useAggregatedAgents();
const open = useKeyboardNavStore((s) => s.commandCenterOpen); const open = useKeyboardNavStore((s) => s.commandCenterOpen);
const setOpen = useKeyboardNavStore((s) => s.setCommandCenterOpen); const setOpen = useKeyboardNavStore((s) => s.setCommandCenterOpen);
const requestFocusChatInput = useKeyboardNavStore((s) => s.requestFocusChatInput);
const takeFocusRestoreElement = useKeyboardNavStore((s) => s.takeFocusRestoreElement);
const inputRef = useRef<TextInput>(null); const inputRef = useRef<TextInput>(null);
const didNavigateRef = useRef(false);
const prevOpenRef = useRef(open);
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [activeIndex, setActiveIndex] = useState(0); const [activeIndex, setActiveIndex] = useState(0);
@@ -59,17 +63,39 @@ export function CommandCenter() {
}, [agents, query]); }, [agents, query]);
useEffect(() => { useEffect(() => {
const prevOpen = prevOpenRef.current;
prevOpenRef.current = open;
if (!open) { if (!open) {
setQuery(""); setQuery("");
setActiveIndex(0); setActiveIndex(0);
if (prevOpen && !didNavigateRef.current) {
const el = takeFocusRestoreElement();
if (el && el.isConnected) {
// Modal unmount can steal focus; restore on next tick.
requestAnimationFrame(() => {
requestAnimationFrame(() => {
try {
el.focus();
} catch {
// ignore
}
});
});
}
}
return; return;
} }
didNavigateRef.current = false;
const id = setTimeout(() => { const id = setTimeout(() => {
inputRef.current?.focus(); inputRef.current?.focus();
}, 0); }, 0);
return () => clearTimeout(id); return () => clearTimeout(id);
}, [open]); }, [open, takeFocusRestoreElement]);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
@@ -84,16 +110,20 @@ export function CommandCenter() {
const handleSelect = useCallback( const handleSelect = useCallback(
(agent: AggregatedAgent) => { (agent: AggregatedAgent) => {
didNavigateRef.current = true;
const session = useSessionStore.getState().sessions[agent.serverId]; const session = useSessionStore.getState().sessions[agent.serverId];
session?.client?.clearAgentAttention(agent.id); session?.client?.clearAgentAttention(agent.id);
const shouldReplace = pathname.startsWith("/agent/"); const shouldReplace = pathname.startsWith("/agent/");
const navigate = shouldReplace ? router.replace : router.push; const navigate = shouldReplace ? router.replace : router.push;
requestFocusChatInput(`${agent.serverId}:${agent.id}`);
// Don't restore focus back to the prior element after we navigate.
takeFocusRestoreElement();
setOpen(false); setOpen(false);
navigate(`/agent/${agent.serverId}/${agent.id}` as any); navigate(`/agent/${agent.serverId}/${agent.id}` as any);
}, },
[pathname, setOpen] [pathname, requestFocusChatInput, setOpen, takeFocusRestoreElement]
); );
useEffect(() => { useEffect(() => {
@@ -34,6 +34,17 @@ export function useGlobalKeyboardNav({
return true; 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 parseShortcutDigit = (event: KeyboardEvent): number | null => {
const code = event.code ?? ""; const code = event.code ?? "";
if (code.startsWith("Digit")) { if (code.startsWith("Digit")) {
@@ -89,6 +100,11 @@ export function useGlobalKeyboardNav({
(event.metaKey || event.ctrlKey) && (event.metaKey || event.ctrlKey) &&
(event.code === "KeyB" || lowerKey === "b") (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(); event.preventDefault();
toggleAgentList(); toggleAgentList();
return; return;
@@ -101,6 +117,9 @@ export function useGlobalKeyboardNav({
(event.metaKey || event.ctrlKey) && (event.metaKey || event.ctrlKey) &&
(event.code === "KeyE" || lowerKey === "e") (event.code === "KeyE" || lowerKey === "e")
) { ) {
if (isEditableTarget(event)) {
return;
}
event.preventDefault(); event.preventDefault();
toggleFileExplorer(); toggleFileExplorer();
return; return;
@@ -110,6 +129,10 @@ export function useGlobalKeyboardNav({
if ((event.metaKey || event.ctrlKey) && lowerKey === "k") { if ((event.metaKey || event.ctrlKey) && lowerKey === "k") {
event.preventDefault(); event.preventDefault();
const s = useKeyboardNavStore.getState(); const s = useKeyboardNavStore.getState();
if (!s.commandCenterOpen) {
const active = document.activeElement;
s.setFocusRestoreElement(active instanceof HTMLElement ? active : null);
}
s.setCommandCenterOpen(!s.commandCenterOpen); s.setCommandCenterOpen(!s.commandCenterOpen);
return; return;
} }
+35 -2
View File
@@ -1,5 +1,10 @@
import { create } from "zustand"; import { create } from "zustand";
type FocusChatInputRequest = {
id: number;
agentKey: string;
};
interface KeyboardNavState { interface KeyboardNavState {
commandCenterOpen: boolean; commandCenterOpen: boolean;
altDown: boolean; altDown: boolean;
@@ -7,6 +12,19 @@ interface KeyboardNavState {
/** Sidebar-visible agent keys (up to 9), in top-to-bottom visual order. */ /** Sidebar-visible agent keys (up to 9), in top-to-bottom visual order. */
sidebarShortcutAgentKeys: string[]; sidebarShortcutAgentKeys: string[];
/**
* Web-only focus restore element used by the command center. Stored when opening
* Cmd/Ctrl+K so we can restore focus when closing without navigating.
*/
focusRestoreElement: HTMLElement | null;
setFocusRestoreElement: (el: HTMLElement | null) => void;
takeFocusRestoreElement: () => HTMLElement | null;
/** Web-only request to focus the MessageInput for the selected agent. */
focusChatInputRequest: FocusChatInputRequest | null;
requestFocusChatInput: (agentKey: string) => void;
clearFocusChatInputRequest: () => void;
setCommandCenterOpen: (open: boolean) => void; setCommandCenterOpen: (open: boolean) => void;
setAltDown: (down: boolean) => void; setAltDown: (down: boolean) => void;
setCmdOrCtrlDown: (down: boolean) => void; setCmdOrCtrlDown: (down: boolean) => void;
@@ -14,16 +32,31 @@ interface KeyboardNavState {
resetModifiers: () => void; resetModifiers: () => void;
} }
export const useKeyboardNavStore = create<KeyboardNavState>((set) => ({ export const useKeyboardNavStore = create<KeyboardNavState>((set, get) => ({
commandCenterOpen: false, commandCenterOpen: false,
altDown: false, altDown: false,
cmdOrCtrlDown: false, cmdOrCtrlDown: false,
sidebarShortcutAgentKeys: [], sidebarShortcutAgentKeys: [],
focusRestoreElement: null,
setFocusRestoreElement: (el) => set({ focusRestoreElement: el }),
takeFocusRestoreElement: () => {
const el = get().focusRestoreElement;
set({ focusRestoreElement: null });
return el;
},
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 }), setCommandCenterOpen: (open) => set({ commandCenterOpen: open }),
setAltDown: (down) => set({ altDown: down }), setAltDown: (down) => set({ altDown: down }),
setCmdOrCtrlDown: (down) => set({ cmdOrCtrlDown: down }), setCmdOrCtrlDown: (down) => set({ cmdOrCtrlDown: down }),
setSidebarShortcutAgentKeys: (keys) => set({ sidebarShortcutAgentKeys: keys }), setSidebarShortcutAgentKeys: (keys) => set({ sidebarShortcutAgentKeys: keys }),
resetModifiers: () => set({ altDown: false, cmdOrCtrlDown: false }), resetModifiers: () => set({ altDown: false, cmdOrCtrlDown: false }),
})); }));