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

View File

@@ -28,6 +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";
type QueuedMessage = {
id: string;
@@ -98,6 +99,8 @@ export function AgentInputArea({
const [selectedImages, setSelectedImages] = useState<ImageAttachment[]>([]);
const [isCancellingAgent, setIsCancellingAgent] = useState(false);
const [commandSelectedIndex, setCommandSelectedIndex] = useState(0);
const focusChatInputRequest = useKeyboardNavStore((s) => s.focusChatInputRequest);
const clearFocusChatInputRequest = useKeyboardNavStore((s) => s.clearFocusChatInputRequest);
// Command autocomplete logic
const showCommandAutocomplete = userInput.startsWith("/") && !userInput.includes(" ");
@@ -398,6 +401,19 @@ export function AgentInputArea({
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
const handleCommandSelect = useCallback(
(cmd: { name: string; description: string; argumentHint: string }) => {

View File

@@ -48,7 +48,11 @@ export function CommandCenter() {
const { agents } = useAggregatedAgents();
const open = useKeyboardNavStore((s) => s.commandCenterOpen);
const setOpen = useKeyboardNavStore((s) => s.setCommandCenterOpen);
const requestFocusChatInput = useKeyboardNavStore((s) => s.requestFocusChatInput);
const takeFocusRestoreElement = useKeyboardNavStore((s) => s.takeFocusRestoreElement);
const inputRef = useRef<TextInput>(null);
const didNavigateRef = useRef(false);
const prevOpenRef = useRef(open);
const [query, setQuery] = useState("");
const [activeIndex, setActiveIndex] = useState(0);
@@ -59,17 +63,39 @@ export function CommandCenter() {
}, [agents, query]);
useEffect(() => {
const prevOpen = prevOpenRef.current;
prevOpenRef.current = open;
if (!open) {
setQuery("");
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;
}
didNavigateRef.current = false;
const id = setTimeout(() => {
inputRef.current?.focus();
}, 0);
return () => clearTimeout(id);
}, [open]);
}, [open, takeFocusRestoreElement]);
useEffect(() => {
if (!open) return;
@@ -84,16 +110,20 @@ export function CommandCenter() {
const handleSelect = useCallback(
(agent: AggregatedAgent) => {
didNavigateRef.current = true;
const session = useSessionStore.getState().sessions[agent.serverId];
session?.client?.clearAgentAttention(agent.id);
const shouldReplace = pathname.startsWith("/agent/");
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);
navigate(`/agent/${agent.serverId}/${agent.id}` as any);
},
[pathname, setOpen]
[pathname, requestFocusChatInput, setOpen, takeFocusRestoreElement]
);
useEffect(() => {

View File

@@ -34,6 +34,17 @@ export function useGlobalKeyboardNav({
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")) {
@@ -89,6 +100,11 @@ export function useGlobalKeyboardNav({
(event.metaKey || event.ctrlKey) &&
(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;
@@ -101,6 +117,9 @@ export function useGlobalKeyboardNav({
(event.metaKey || event.ctrlKey) &&
(event.code === "KeyE" || lowerKey === "e")
) {
if (isEditableTarget(event)) {
return;
}
event.preventDefault();
toggleFileExplorer();
return;
@@ -110,6 +129,10 @@ export function useGlobalKeyboardNav({
if ((event.metaKey || event.ctrlKey) && lowerKey === "k") {
event.preventDefault();
const s = useKeyboardNavStore.getState();
if (!s.commandCenterOpen) {
const active = document.activeElement;
s.setFocusRestoreElement(active instanceof HTMLElement ? active : null);
}
s.setCommandCenterOpen(!s.commandCenterOpen);
return;
}

View File

@@ -1,5 +1,10 @@
import { create } from "zustand";
type FocusChatInputRequest = {
id: number;
agentKey: string;
};
interface KeyboardNavState {
commandCenterOpen: boolean;
altDown: boolean;
@@ -7,6 +12,19 @@ interface KeyboardNavState {
/** Sidebar-visible agent keys (up to 9), in top-to-bottom visual order. */
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;
setAltDown: (down: boolean) => void;
setCmdOrCtrlDown: (down: boolean) => void;
@@ -14,16 +32,31 @@ interface KeyboardNavState {
resetModifiers: () => void;
}
export const useKeyboardNavStore = create<KeyboardNavState>((set) => ({
export const useKeyboardNavStore = create<KeyboardNavState>((set, get) => ({
commandCenterOpen: false,
altDown: false,
cmdOrCtrlDown: false,
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 }),
setAltDown: (down) => set({ altDown: down }),
setCmdOrCtrlDown: (down) => set({ cmdOrCtrlDown: down }),
setSidebarShortcutAgentKeys: (keys) => set({ sidebarShortcutAgentKeys: keys }),
resetModifiers: () => set({ altDown: false, cmdOrCtrlDown: false }),
}));