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.