Fix Cmd+K focus restore and focus input
This commit is contained in:
@@ -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 }) => {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user