authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-04 21:11:01-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-04 21:11:01-04:00
log896fe82f2f191eb8ac0fd620e19c25dd48253592
tree728934ad9ce069671019580bc5694a2541341c2f
parentc3caceade14ccda0686db0031aca79d7d38a71d0
signature Signed by PGP key B5690EEEBB952194

fix(web): preserve focus and prioritize picker shortcuts (#9795)


7 files changed, 52 insertions(+), 34 deletions(-)

apps/web/src/components/CommandPalette.tsx+7-4
...@@ -508,7 +508,10 @@ export function CommandPalette({ children }: { children: ReactNode }) {...@@ -508,7 +508,10 @@ export function CommandPalette({ children }: { children: ReactNode }) {
508 setOpen(open);508 setOpen(open);
509 }}509 }}
510 >510 >
511 {children}511 {/* Block background focus calls for the entire time the palette is open. */}
512 <div className="contents" inert={state.open}>
513 {children}
514 </div>
512 <CommandPaletteDialog515 <CommandPaletteDialog
513 mode={state.mode}516 mode={state.mode}
514 openIntent={state.openIntent}517 openIntent={state.openIntent}
...@@ -2289,15 +2292,15 @@ function OpenCommandPaletteDialog(props: {...@@ -2289,15 +2292,15 @@ function OpenCommandPaletteDialog(props: {
2289 context: { modelPickerOpen: false },2292 context: { modelPickerOpen: false },
2290 });2293 });
2291 if (threadJumpIndexFromCommand(command ?? "") !== null) {2294 if (threadJumpIndexFromCommand(command ?? "") !== null) {
2295 event.preventDefault();
2296 event.stopPropagation();
2292 const matchingItem = displayedGroups2297 const matchingItem = displayedGroups
2293 .flatMap((group) => group.items)2298 .flatMap((group) => group.items)
2294 .find((item) => item.shortcutCommand === command);2299 .find((item) => item.shortcutCommand === command);
2295 if (matchingItem) {2300 if (matchingItem) {
2296 event.preventDefault();
2297 event.stopPropagation();
2298 executeItem(matchingItem);2301 executeItem(matchingItem);
2299 return;
2300 }2302 }
2303 return;
2301 }2304 }
2302 if (command === "thread.copyReference" && activeThreadReferenceCopyTarget !== null) {2305 if (command === "thread.copyReference" && activeThreadReferenceCopyTarget !== null) {
2303 event.preventDefault();2306 event.preventDefault();
apps/web/src/components/CommandPaletteContent.tsx+11-2
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import { ArrowDownIcon, ArrowUpIcon } from "lucide-react";1import { ArrowDownIcon, ArrowUpIcon } from "lucide-react";
2import type { ComponentProps, ReactNode } from "react";2import { type ComponentProps, type ReactNode, useLayoutEffect, useRef } from "react";
33
4import { Command, CommandFooter, CommandInput, CommandPanel } from "./ui/command";4import { Command, CommandFooter, CommandInput, CommandPanel } from "./ui/command";
5import { Kbd, KbdGroup } from "./ui/kbd";5import { Kbd, KbdGroup } from "./ui/kbd";
...@@ -33,11 +33,20 @@ export function CommandPaletteContent({...@@ -33,11 +33,20 @@ export function CommandPaletteContent({
33 testId,33 testId,
34 ...commandProps34 ...commandProps
35}: CommandPaletteContentProps) {35}: CommandPaletteContentProps) {
36 const inputRef = useRef<HTMLInputElement>(null);
37
38 // Direct-open flows replace the initial palette view after the dialog has
39 // already moved focus. Reclaim it when the replacement input mounts so
40 // typing cannot continue in the composer behind the modal.
41 useLayoutEffect(() => {
42 inputRef.current?.focus();
43 }, []);
44
36 return (45 return (
37 <div className="contents" data-testid={testId}>46 <div className="contents" data-testid={testId}>
38 <Command {...commandProps}>47 <Command {...commandProps}>
39 <div className="relative">48 <div className="relative">
40 <CommandInput {...inputProps} />49 <CommandInput {...inputProps} ref={inputRef} />
41 {inputAccessory}50 {inputAccessory}
42 </div>51 </div>
43 <CommandPanel className={panelClassName}>{children}</CommandPanel>52 <CommandPanel className={panelClassName}>{children}</CommandPanel>
apps/web/src/components/LegacySidebar.tsx+2-2
...@@ -172,7 +172,7 @@ import {...@@ -172,7 +172,7 @@ import {
172 useSidebar,172 useSidebar,
173} from "./ui/sidebar";173} from "./ui/sidebar";
174import { useThreadSelectionStore } from "../threadSelectionStore";174import { useThreadSelectionStore } from "../threadSelectionStore";
175import { openCommandPalette } from "../commandPaletteBus";175import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus";
176import {176import {
177 archiveSelectedThreadEntries,177 archiveSelectedThreadEntries,
178 buildMultiSelectThreadContextMenuItems,178 buildMultiSelectThreadContextMenuItems,
...@@ -3510,7 +3510,7 @@ export default function LegacySidebar() {...@@ -3510,7 +3510,7 @@ export default function LegacySidebar() {
3510 const onWindowKeyDown = (event: globalThis.KeyboardEvent) => {3510 const onWindowKeyDown = (event: globalThis.KeyboardEvent) => {
3511 const shortcutContext = getCurrentSidebarShortcutContext();3511 const shortcutContext = getCurrentSidebarShortcutContext();
35123512
3513 if (event.defaultPrevented || event.repeat) {3513 if (event.defaultPrevented || event.repeat || isCommandPaletteOpen() || isModelPickerOpen()) {
3514 return;3514 return;
3515 }3515 }
35163516
apps/web/src/components/Sidebar.tsx+4-2
...@@ -104,7 +104,7 @@ import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"...@@ -104,7 +104,7 @@ import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"
104import { useThreadSelectionStore } from "../threadSelectionStore";104import { useThreadSelectionStore } from "../threadSelectionStore";
105import { useThreadActions } from "../hooks/useThreadActions";105import { useThreadActions } from "../hooks/useThreadActions";
106import { useHandleNewThread } from "../hooks/useHandleNewThread";106import { useHandleNewThread } from "../hooks/useHandleNewThread";
107import { openCommandPalette } from "../commandPaletteBus";107import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus";
108import { startNewThreadFromContext } from "../lib/chatThreadActions";108import { startNewThreadFromContext } from "../lib/chatThreadActions";
109import { useClientSettings } from "../hooks/useSettings";109import { useClientSettings } from "../hooks/useSettings";
110import { useCopyToClipboard } from "../hooks/useCopyToClipboard";110import { useCopyToClipboard } from "../hooks/useCopyToClipboard";
...@@ -3505,7 +3505,9 @@ export default function Sidebar() {...@@ -3505,7 +3505,9 @@ export default function Sidebar() {
3505 );3505 );
3506 useEffect(() => {3506 useEffect(() => {
3507 const onWindowKeyDown = (event: KeyboardEvent) => {3507 const onWindowKeyDown = (event: KeyboardEvent) => {
3508 if (event.defaultPrevented || event.repeat) return;3508 if (event.defaultPrevented || event.repeat || isCommandPaletteOpen() || isModelPickerOpen()) {
3509 return;
3510 }
3509 const command = resolveShortcutCommand(event, keybindings, {3511 const command = resolveShortcutCommand(event, keybindings, {
3510 platform: navigator.platform,3512 platform: navigator.platform,
3511 context: {3513 context: {
apps/web/src/components/ThreadTerminalDrawer.tsx+11-17
...@@ -516,7 +516,10 @@ export function TerminalViewport({...@@ -516,7 +516,10 @@ export function TerminalViewport({
516 // never started, so only "exited" triggers the message — as with xterm.)516 // never started, so only "exited" triggers the message — as with xterm.)
517 synchronizedStatusRef.current = "closed";517 synchronizedStatusRef.current = "closed";
518 synchronizeTerminalStatus(terminal, latestSession.status);518 synchronizeTerminalStatus(terminal, latestSession.status);
519 if (autoFocus && visibleRef.current) window.requestAnimationFrame(() => terminal.focus());519 // Startup may finish after the user has returned to the composer.
520 if (visibleRef.current && mount.contains(document.activeElement)) {
521 terminal.focus();
522 }
520523
521 const dismissSelectionAction = (supersede = false) => {524 const dismissSelectionAction = (supersede = false) => {
522 const ownsMenu =525 const ownsMenu =
...@@ -870,10 +873,10 @@ export function TerminalViewport({...@@ -870,10 +873,10 @@ export function TerminalViewport({
870873
871 return () => {874 return () => {
872 cancelled = true;875 cancelled = true;
876 const hadFocus = mount.contains(document.activeElement);
873 teardown?.();877 teardown?.();
878 if (hadFocus && mount.isConnected) mount.focus({ preventScroll: true });
874 };879 };
875 // autoFocus is intentionally omitted;
876 // it is only read at mount time and must not trigger terminal teardown/recreation.
877 }, [cwd, environmentId, runtimeEnvKey, terminalId, threadId, worktreePath]);880 }, [cwd, environmentId, runtimeEnvKey, terminalId, threadId, worktreePath]);
878881
879 useEffect(() => {882 useEffect(() => {
...@@ -904,24 +907,14 @@ export function TerminalViewport({...@@ -904,24 +907,14 @@ export function TerminalViewport({
904 writeSystemMessage(terminal, current.error);907 writeSystemMessage(terminal, current.error);
905 }908 }
906909
907 if (previous.version === 0 && autoFocus && visibleRef.current) {
908 window.requestAnimationFrame(() => {
909 terminal.focus();
910 });
911 }
912 previousSessionRef.current = current;910 previousSessionRef.current = current;
913 }, [autoFocus, terminalOutput, terminalError, terminalStatus, terminalVersion]);911 }, [terminalOutput, terminalError, terminalStatus, terminalVersion]);
914912
915 useEffect(() => {913 useEffect(() => {
916 if (!autoFocus || !visible) return;914 if (!autoFocus || !visible) return;
917 const terminal = terminalRef.current;915 // Claim focus when requested, then hand it to the terminal once ready only
918 if (!terminal) return;916 // if the user has not focused something else in the meantime.
919 const frame = window.requestAnimationFrame(() => {917 (terminalRef.current ?? containerRef.current)?.focus();
920 terminal.focus();
921 });
922 return () => {
923 window.cancelAnimationFrame(frame);
924 };
925 }, [autoFocus, focusRequestId, visible]);918 }, [autoFocus, focusRequestId, visible]);
926919
927 useEffect(() => {920 useEffect(() => {
...@@ -944,6 +937,7 @@ export function TerminalViewport({...@@ -944,6 +937,7 @@ export function TerminalViewport({
944 return (937 return (
945 <div938 <div
946 ref={containerRef}939 ref={containerRef}
940 tabIndex={-1}
947 className="relative h-full w-full overflow-hidden bg-[var(--terminal-background)]"941 className="relative h-full w-full overflow-hidden bg-[var(--terminal-background)]"
948 />942 />
949 );943 );
apps/web/src/components/chat/ModelPickerContent.tsx+8-7
...@@ -5,6 +5,7 @@ import {...@@ -5,6 +5,7 @@ import {
5 type ResolvedKeybindingsConfig,5 type ResolvedKeybindingsConfig,
6} from "@t3tools/contracts";6} from "@t3tools/contracts";
7import { resolveSelectableModel } from "@t3tools/shared/model";7import { resolveSelectableModel } from "@t3tools/shared/model";
8import { useAtomValue } from "@effect/atom-react";
8import { LegendList, type LegendListRef } from "@legendapp/list/react";9import { LegendList, type LegendListRef } from "@legendapp/list/react";
9import { memo, useMemo, useState, useCallback, useEffect, useLayoutEffect, useRef } from "react";10import { memo, useMemo, useState, useCallback, useEffect, useLayoutEffect, useRef } from "react";
10import { ChevronRightIcon, SearchIcon } from "lucide-react";11import { ChevronRightIcon, SearchIcon } from "lucide-react";
...@@ -26,6 +27,8 @@ import {...@@ -26,6 +27,8 @@ import {
26 ComboboxListVirtualized,27 ComboboxListVirtualized,
27} from "../ui/combobox";28} from "../ui/combobox";
28import { ModelEsque } from "./providerIconUtils";29import { ModelEsque } from "./providerIconUtils";
30import { isCommandPaletteOpen } from "../../commandPaletteBus";
31import { primaryServerKeybindingsAtom } from "../../state/server";
29import {32import {
30 modelPickerJumpCommandForIndex,33 modelPickerJumpCommandForIndex,
31 modelPickerJumpIndexFromCommand,34 modelPickerJumpIndexFromCommand,
...@@ -217,10 +220,8 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {...@@ -217,10 +220,8 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
217 : [],220 : [],
218 ),221 ),
219 );222 );
220 const keybindings = useMemo<ResolvedKeybindingsConfig>(223 const serverKeybindings = useAtomValue(primaryServerKeybindingsAtom);
221 () => providedKeybindings ?? [],224 const keybindings = providedKeybindings ?? serverKeybindings;
222 [providedKeybindings],
223 );
224 const updateSettings = useUpdateClientSettings();225 const updateSettings = useUpdateClientSettings();
225226
226 const focusSearchInput = useCallback(() => {227 const focusSearchInput = useCallback(() => {
...@@ -678,7 +679,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {...@@ -678,7 +679,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
678679
679 useEffect(() => {680 useEffect(() => {
680 const onWindowKeyDown = (event: globalThis.KeyboardEvent) => {681 const onWindowKeyDown = (event: globalThis.KeyboardEvent) => {
681 if (event.defaultPrevented || event.repeat) {682 if (event.defaultPrevented || event.repeat || isCommandPaletteOpen()) {
682 return;683 return;
683 }684 }
684685
...@@ -690,6 +691,8 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {...@@ -690,6 +691,8 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
690 if (jumpIndex === null) {691 if (jumpIndex === null) {
691 return;692 return;
692 }693 }
694 event.preventDefault();
695 event.stopPropagation();
693696
694 const targetModelKey = modelJumpModelKeys[jumpIndex];697 const targetModelKey = modelJumpModelKeys[jumpIndex];
695 if (!targetModelKey) {698 if (!targetModelKey) {
...@@ -699,8 +702,6 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {...@@ -699,8 +702,6 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
699 if (!model) {702 if (!model) {
700 return;703 return;
701 }704 }
702 event.preventDefault();
703 event.stopPropagation();
704 handleModelSelect(model.slug, model.instanceId);705 handleModelSelect(model.slug, model.instanceId);
705 };706 };
706707
docs/user/keyboard-focus.md created+9
...@@ -0,0 +1,9 @@
1# Keyboard focus
2
3The command palette keeps focus while open. Closing it returns focus to the composer.
4While the palette or model picker is open, number shortcuts select its entries instead of
5switching threads. Model shortcuts work in Settings as well as the composer.
6See [Keybindings](./keybindings.md) to customize these shortcuts.
7
8If you return to typing while a terminal is starting, the composer keeps focus when the terminal
9becomes ready. Opening or switching to a terminal explicitly still focuses it.