diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 06813228b8e4..27dddf00ee53 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -508,7 +508,10 @@ export function CommandPalette({ children }: { children: ReactNode }) { setOpen(open); }} > - {children} + {/* Block background focus calls for the entire time the palette is open. */} +
+ {children} +
group.items) .find((item) => item.shortcutCommand === command); if (matchingItem) { - event.preventDefault(); - event.stopPropagation(); executeItem(matchingItem); - return; } + return; } if (command === "thread.copyReference" && activeThreadReferenceCopyTarget !== null) { event.preventDefault(); diff --git a/apps/web/src/components/CommandPaletteContent.tsx b/apps/web/src/components/CommandPaletteContent.tsx index af3c1b671704..8c1a5b0e3c83 100644 --- a/apps/web/src/components/CommandPaletteContent.tsx +++ b/apps/web/src/components/CommandPaletteContent.tsx @@ -1,5 +1,5 @@ import { ArrowDownIcon, ArrowUpIcon } from "lucide-react"; -import type { ComponentProps, ReactNode } from "react"; +import { type ComponentProps, type ReactNode, useLayoutEffect, useRef } from "react"; import { Command, CommandFooter, CommandInput, CommandPanel } from "./ui/command"; import { Kbd, KbdGroup } from "./ui/kbd"; @@ -33,11 +33,20 @@ export function CommandPaletteContent({ testId, ...commandProps }: CommandPaletteContentProps) { + const inputRef = useRef(null); + + // Direct-open flows replace the initial palette view after the dialog has + // already moved focus. Reclaim it when the replacement input mounts so + // typing cannot continue in the composer behind the modal. + useLayoutEffect(() => { + inputRef.current?.focus(); + }, []); + return (
- + {inputAccessory}
{children} diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index ea910905efe0..650ad12bebcb 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -172,7 +172,7 @@ import { useSidebar, } from "./ui/sidebar"; import { useThreadSelectionStore } from "../threadSelectionStore"; -import { openCommandPalette } from "../commandPaletteBus"; +import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus"; import { archiveSelectedThreadEntries, buildMultiSelectThreadContextMenuItems, @@ -3510,7 +3510,7 @@ export default function LegacySidebar() { const onWindowKeyDown = (event: globalThis.KeyboardEvent) => { const shortcutContext = getCurrentSidebarShortcutContext(); - if (event.defaultPrevented || event.repeat) { + if (event.defaultPrevented || event.repeat || isCommandPaletteOpen() || isModelPickerOpen()) { return; } diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 4509fde6ceb9..50bb876058c1 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -104,7 +104,7 @@ import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore" import { useThreadSelectionStore } from "../threadSelectionStore"; import { useThreadActions } from "../hooks/useThreadActions"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; -import { openCommandPalette } from "../commandPaletteBus"; +import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus"; import { startNewThreadFromContext } from "../lib/chatThreadActions"; import { useClientSettings } from "../hooks/useSettings"; import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; @@ -3493,7 +3493,9 @@ export default function Sidebar() { ); useEffect(() => { const onWindowKeyDown = (event: KeyboardEvent) => { - if (event.defaultPrevented || event.repeat) return; + if (event.defaultPrevented || event.repeat || isCommandPaletteOpen() || isModelPickerOpen()) { + return; + } const command = resolveShortcutCommand(event, keybindings, { platform: navigator.platform, context: { diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 89cdc3649acd..9f4956aae682 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -516,7 +516,10 @@ export function TerminalViewport({ // never started, so only "exited" triggers the message — as with xterm.) synchronizedStatusRef.current = "closed"; synchronizeTerminalStatus(terminal, latestSession.status); - if (autoFocus && visibleRef.current) window.requestAnimationFrame(() => terminal.focus()); + // Startup may finish after the user has returned to the composer. + if (visibleRef.current && mount.contains(document.activeElement)) { + terminal.focus(); + } const dismissSelectionAction = (supersede = false) => { const ownsMenu = @@ -870,10 +873,10 @@ export function TerminalViewport({ return () => { cancelled = true; + const hadFocus = mount.contains(document.activeElement); teardown?.(); + if (hadFocus && mount.isConnected) mount.focus({ preventScroll: true }); }; - // autoFocus is intentionally omitted; - // it is only read at mount time and must not trigger terminal teardown/recreation. }, [cwd, environmentId, runtimeEnvKey, terminalId, threadId, worktreePath]); useEffect(() => { @@ -904,24 +907,14 @@ export function TerminalViewport({ writeSystemMessage(terminal, current.error); } - if (previous.version === 0 && autoFocus && visibleRef.current) { - window.requestAnimationFrame(() => { - terminal.focus(); - }); - } previousSessionRef.current = current; - }, [autoFocus, terminalOutput, terminalError, terminalStatus, terminalVersion]); + }, [terminalOutput, terminalError, terminalStatus, terminalVersion]); useEffect(() => { if (!autoFocus || !visible) return; - const terminal = terminalRef.current; - if (!terminal) return; - const frame = window.requestAnimationFrame(() => { - terminal.focus(); - }); - return () => { - window.cancelAnimationFrame(frame); - }; + // Claim focus when requested, then hand it to the terminal once ready only + // if the user has not focused something else in the meantime. + (terminalRef.current ?? containerRef.current)?.focus(); }, [autoFocus, focusRequestId, visible]); useEffect(() => { @@ -944,6 +937,7 @@ export function TerminalViewport({ return (
); diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index 8880369a18c9..63dea7844c46 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -5,6 +5,7 @@ import { type ResolvedKeybindingsConfig, } from "@t3tools/contracts"; import { resolveSelectableModel } from "@t3tools/shared/model"; +import { useAtomValue } from "@effect/atom-react"; import { LegendList, type LegendListRef } from "@legendapp/list/react"; import { memo, useMemo, useState, useCallback, useEffect, useLayoutEffect, useRef } from "react"; import { ChevronRightIcon, SearchIcon } from "lucide-react"; @@ -26,6 +27,8 @@ import { ComboboxListVirtualized, } from "../ui/combobox"; import { ModelEsque } from "./providerIconUtils"; +import { isCommandPaletteOpen } from "../../commandPaletteBus"; +import { primaryServerKeybindingsAtom } from "../../state/server"; import { modelPickerJumpCommandForIndex, modelPickerJumpIndexFromCommand, @@ -217,10 +220,8 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { : [], ), ); - const keybindings = useMemo( - () => providedKeybindings ?? [], - [providedKeybindings], - ); + const serverKeybindings = useAtomValue(primaryServerKeybindingsAtom); + const keybindings = providedKeybindings ?? serverKeybindings; const updateSettings = useUpdateClientSettings(); const focusSearchInput = useCallback(() => { @@ -678,7 +679,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { useEffect(() => { const onWindowKeyDown = (event: globalThis.KeyboardEvent) => { - if (event.defaultPrevented || event.repeat) { + if (event.defaultPrevented || event.repeat || isCommandPaletteOpen()) { return; } @@ -690,6 +691,8 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { if (jumpIndex === null) { return; } + event.preventDefault(); + event.stopPropagation(); const targetModelKey = modelJumpModelKeys[jumpIndex]; if (!targetModelKey) { @@ -699,8 +702,6 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { if (!model) { return; } - event.preventDefault(); - event.stopPropagation(); handleModelSelect(model.slug, model.instanceId); }; diff --git a/docs/user/keyboard-focus.md b/docs/user/keyboard-focus.md new file mode 100644 index 000000000000..50b9c87c24f3 --- /dev/null +++ b/docs/user/keyboard-focus.md @@ -0,0 +1,9 @@ +# Keyboard focus + +The command palette keeps focus while open. Closing it returns focus to the composer. +While the palette or model picker is open, number shortcuts select its entries instead of +switching threads. Model shortcuts work in Settings as well as the composer. +See [Keybindings](./keybindings.md) to customize these shortcuts. + +If you return to typing while a terminal is starting, the composer keeps focus when the terminal +becomes ready. Opening or switching to a terminal explicitly still focuses it.