From 7a997cfb3b00e8d0b838ccdd37de93171afee121 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 4 Sep 2026 21:23:02 +0000 Subject: [PATCH 1/3] fix(web): focus search when opening project picker --- apps/web/src/components/CommandPaletteContent.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) 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} From 00974479f5e30c36e62d9a82f3efbebff5536c98 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 4 Sep 2026 23:25:50 +0000 Subject: [PATCH 2/3] fix(web): preserve focus and prioritize picker shortcuts --- apps/web/src/components/CommandPalette.tsx | 11 +++++--- apps/web/src/components/LegacySidebar.tsx | 4 +-- apps/web/src/components/Sidebar.tsx | 6 ++-- .../src/components/ThreadTerminalDrawer.tsx | 28 ++++++++----------- .../components/chat/ModelPickerContent.tsx | 15 +++++----- docs/user/composer.md | 3 ++ docs/user/keybindings.md | 3 ++ 7 files changed, 38 insertions(+), 32 deletions(-) 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/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/composer.md b/docs/user/composer.md index f158eb8f185e..6e12c82db907 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -4,6 +4,9 @@ Messages can contain up to 120,000 characters. If a draft is longer, T3 Code kee composer and shows how many characters need to be removed. Shorten the draft or split it into multiple messages, then send again in the same thread. +If you return to typing while a terminal is starting, the composer keeps focus when the terminal +becomes ready. + On mobile, an empty composer shows an interrupt button while the agent is working. Adding text or an attachment replaces it with the send button. This applies to both compact and expanded composers. diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index 303d25131935..9e6e32bec6c6 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -75,6 +75,9 @@ section. Message matches show one labeled excerpt while keeping the thread's pro machine context visible. Message search begins after two characters and uses SQLite's ASCII case-insensitive matching. +While the command palette or model picker is open, number shortcuts select its entries instead of +switching threads. The model picker supports these shortcuts in Settings as well as the composer. + The full command list and the current defaults are shown in **Settings** → **Keybindings**, which always matches the build you are running. Use that rather than a copied list. From ba0d0e611bc8b859489cca0fdb393f3e5383ed9b Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 4 Sep 2026 23:30:30 +0000 Subject: [PATCH 3/3] docs: isolate keyboard focus notes from upstream rewrite --- docs/user/composer.md | 3 --- docs/user/keybindings.md | 3 --- docs/user/keyboard-focus.md | 9 +++++++++ 3 files changed, 9 insertions(+), 6 deletions(-) create mode 100644 docs/user/keyboard-focus.md diff --git a/docs/user/composer.md b/docs/user/composer.md index 6e12c82db907..f158eb8f185e 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -4,9 +4,6 @@ Messages can contain up to 120,000 characters. If a draft is longer, T3 Code kee composer and shows how many characters need to be removed. Shorten the draft or split it into multiple messages, then send again in the same thread. -If you return to typing while a terminal is starting, the composer keeps focus when the terminal -becomes ready. - On mobile, an empty composer shows an interrupt button while the agent is working. Adding text or an attachment replaces it with the send button. This applies to both compact and expanded composers. diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index 9e6e32bec6c6..303d25131935 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -75,9 +75,6 @@ section. Message matches show one labeled excerpt while keeping the thread's pro machine context visible. Message search begins after two characters and uses SQLite's ASCII case-insensitive matching. -While the command palette or model picker is open, number shortcuts select its entries instead of -switching threads. The model picker supports these shortcuts in Settings as well as the composer. - The full command list and the current defaults are shown in **Settings** → **Keybindings**, which always matches the build you are running. Use that rather than a copied list. 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.