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 }) {
508508 setOpen(open);
509509 }}
510510 >
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>
512515 <CommandPaletteDialog
513516 mode={state.mode}
514517 openIntent={state.openIntent}
......@@ -2289,15 +2292,15 @@ function OpenCommandPaletteDialog(props: {
22892292 context: { modelPickerOpen: false },
22902293 });
22912294 if (threadJumpIndexFromCommand(command ?? "") !== null) {
2295 event.preventDefault();
2296 event.stopPropagation();
22922297 const matchingItem = displayedGroups
22932298 .flatMap((group) => group.items)
22942299 .find((item) => item.shortcutCommand === command);
22952300 if (matchingItem) {
2296 event.preventDefault();
2297 event.stopPropagation();
22982301 executeItem(matchingItem);
2299 return;
23002302 }
2303 return;
23012304 }
23022305 if (command === "thread.copyReference" && activeThreadReferenceCopyTarget !== null) {
23032306 event.preventDefault();
apps/web/src/components/CommandPaletteContent.tsx+11-2
......@@ -1,5 +1,5 @@
11import { ArrowDownIcon, ArrowUpIcon } from "lucide-react";
2import type { ComponentProps, ReactNode } from "react";
2import { type ComponentProps, type ReactNode, useLayoutEffect, useRef } from "react";
33
44import { Command, CommandFooter, CommandInput, CommandPanel } from "./ui/command";
55import { Kbd, KbdGroup } from "./ui/kbd";
......@@ -33,11 +33,20 @@ export function CommandPaletteContent({
3333 testId,
3434 ...commandProps
3535}: 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
3645 return (
3746 <div className="contents" data-testid={testId}>
3847 <Command {...commandProps}>
3948 <div className="relative">
40 <CommandInput {...inputProps} />
49 <CommandInput {...inputProps} ref={inputRef} />
4150 {inputAccessory}
4251 </div>
4352 <CommandPanel className={panelClassName}>{children}</CommandPanel>
apps/web/src/components/LegacySidebar.tsx+2-2
......@@ -172,7 +172,7 @@ import {
172172 useSidebar,
173173} from "./ui/sidebar";
174174import { useThreadSelectionStore } from "../threadSelectionStore";
175import { openCommandPalette } from "../commandPaletteBus";
175import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus";
176176import {
177177 archiveSelectedThreadEntries,
178178 buildMultiSelectThreadContextMenuItems,
......@@ -3510,7 +3510,7 @@ export default function LegacySidebar() {
35103510 const onWindowKeyDown = (event: globalThis.KeyboardEvent) => {
35113511 const shortcutContext = getCurrentSidebarShortcutContext();
35123512
3513 if (event.defaultPrevented || event.repeat) {
3513 if (event.defaultPrevented || event.repeat || isCommandPaletteOpen() || isModelPickerOpen()) {
35143514 return;
35153515 }
35163516
apps/web/src/components/Sidebar.tsx+4-2
......@@ -104,7 +104,7 @@ import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"
104104import { useThreadSelectionStore } from "../threadSelectionStore";
105105import { useThreadActions } from "../hooks/useThreadActions";
106106import { useHandleNewThread } from "../hooks/useHandleNewThread";
107import { openCommandPalette } from "../commandPaletteBus";
107import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus";
108108import { startNewThreadFromContext } from "../lib/chatThreadActions";
109109import { useClientSettings } from "../hooks/useSettings";
110110import { useCopyToClipboard } from "../hooks/useCopyToClipboard";
......@@ -3505,7 +3505,9 @@ export default function Sidebar() {
35053505 );
35063506 useEffect(() => {
35073507 const onWindowKeyDown = (event: KeyboardEvent) => {
3508 if (event.defaultPrevented || event.repeat) return;
3508 if (event.defaultPrevented || event.repeat || isCommandPaletteOpen() || isModelPickerOpen()) {
3509 return;
3510 }
35093511 const command = resolveShortcutCommand(event, keybindings, {
35103512 platform: navigator.platform,
35113513 context: {
apps/web/src/components/ThreadTerminalDrawer.tsx+11-17
......@@ -516,7 +516,10 @@ export function TerminalViewport({
516516 // never started, so only "exited" triggers the message — as with xterm.)
517517 synchronizedStatusRef.current = "closed";
518518 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
521524 const dismissSelectionAction = (supersede = false) => {
522525 const ownsMenu =
......@@ -870,10 +873,10 @@ export function TerminalViewport({
870873
871874 return () => {
872875 cancelled = true;
876 const hadFocus = mount.contains(document.activeElement);
873877 teardown?.();
878 if (hadFocus && mount.isConnected) mount.focus({ preventScroll: true });
874879 };
875 // autoFocus is intentionally omitted;
876 // it is only read at mount time and must not trigger terminal teardown/recreation.
877880 }, [cwd, environmentId, runtimeEnvKey, terminalId, threadId, worktreePath]);
878881
879882 useEffect(() => {
......@@ -904,24 +907,14 @@ export function TerminalViewport({
904907 writeSystemMessage(terminal, current.error);
905908 }
906909
907 if (previous.version === 0 && autoFocus && visibleRef.current) {
908 window.requestAnimationFrame(() => {
909 terminal.focus();
910 });
911 }
912910 previousSessionRef.current = current;
913 }, [autoFocus, terminalOutput, terminalError, terminalStatus, terminalVersion]);
911 }, [terminalOutput, terminalError, terminalStatus, terminalVersion]);
914912
915913 useEffect(() => {
916914 if (!autoFocus || !visible) return;
917 const terminal = terminalRef.current;
918 if (!terminal) return;
919 const frame = window.requestAnimationFrame(() => {
920 terminal.focus();
921 });
922 return () => {
923 window.cancelAnimationFrame(frame);
924 };
915 // Claim focus when requested, then hand it to the terminal once ready only
916 // if the user has not focused something else in the meantime.
917 (terminalRef.current ?? containerRef.current)?.focus();
925918 }, [autoFocus, focusRequestId, visible]);
926919
927920 useEffect(() => {
......@@ -944,6 +937,7 @@ export function TerminalViewport({
944937 return (
945938 <div
946939 ref={containerRef}
940 tabIndex={-1}
947941 className="relative h-full w-full overflow-hidden bg-[var(--terminal-background)]"
948942 />
949943 );
apps/web/src/components/chat/ModelPickerContent.tsx+8-7
......@@ -5,6 +5,7 @@ import {
55 type ResolvedKeybindingsConfig,
66} from "@t3tools/contracts";
77import { resolveSelectableModel } from "@t3tools/shared/model";
8import { useAtomValue } from "@effect/atom-react";
89import { LegendList, type LegendListRef } from "@legendapp/list/react";
910import { memo, useMemo, useState, useCallback, useEffect, useLayoutEffect, useRef } from "react";
1011import { ChevronRightIcon, SearchIcon } from "lucide-react";
......@@ -26,6 +27,8 @@ import {
2627 ComboboxListVirtualized,
2728} from "../ui/combobox";
2829import { ModelEsque } from "./providerIconUtils";
30import { isCommandPaletteOpen } from "../../commandPaletteBus";
31import { primaryServerKeybindingsAtom } from "../../state/server";
2932import {
3033 modelPickerJumpCommandForIndex,
3134 modelPickerJumpIndexFromCommand,
......@@ -217,10 +220,8 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
217220 : [],
218221 ),
219222 );
220 const keybindings = useMemo<ResolvedKeybindingsConfig>(
221 () => providedKeybindings ?? [],
222 [providedKeybindings],
223 );
223 const serverKeybindings = useAtomValue(primaryServerKeybindingsAtom);
224 const keybindings = providedKeybindings ?? serverKeybindings;
224225 const updateSettings = useUpdateClientSettings();
225226
226227 const focusSearchInput = useCallback(() => {
......@@ -678,7 +679,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
678679
679680 useEffect(() => {
680681 const onWindowKeyDown = (event: globalThis.KeyboardEvent) => {
681 if (event.defaultPrevented || event.repeat) {
682 if (event.defaultPrevented || event.repeat || isCommandPaletteOpen()) {
682683 return;
683684 }
684685
......@@ -690,6 +691,8 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
690691 if (jumpIndex === null) {
691692 return;
692693 }
694 event.preventDefault();
695 event.stopPropagation();
693696
694697 const targetModelKey = modelJumpModelKeys[jumpIndex];
695698 if (!targetModelKey) {
......@@ -699,8 +702,6 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
699702 if (!model) {
700703 return;
701704 }
702 event.preventDefault();
703 event.stopPropagation();
704705 handleModelSelect(model.slug, model.instanceId);
705706 };
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.