authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-06 20:09:05-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-07 03:09:05+00:00
logecf3716fd190005b1552957b3c7880ab849a0a65
tree20c5e341cfd34defb9f5b8c162ff7d6c6eadaa86
parent7e03dcfe5d405626a94037e19a18f9950162b3ea
signature Signed by PGP key B5690EEEBB952194

fix(web): composer regains focus when you tab back into T3 Code (#10463)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

3 files changed, 98 insertions(+), 0 deletions(-)

apps/web/src/components/ChatView.logic.test.ts+43
...@@ -42,6 +42,7 @@ import {...@@ -42,6 +42,7 @@ import {
42 getStartedThreadModelChangeBlockReason,42 getStartedThreadModelChangeBlockReason,
43 hasEnvironmentReconnectWarningGraceElapsed,43 hasEnvironmentReconnectWarningGraceElapsed,
44 hasServerAcknowledgedLocalDispatch,44 hasServerAcknowledgedLocalDispatch,
45 shouldRefocusComposerOnWindowFocus,
45 isBranchMismatchDismissedForSession,46 isBranchMismatchDismissedForSession,
46 reconcileMountedTerminalThreadIds,47 reconcileMountedTerminalThreadIds,
47 reconcileRetainedMountedThreadIds,48 reconcileRetainedMountedThreadIds,
...@@ -1861,3 +1862,45 @@ describe("hasServerAcknowledgedLocalDispatch", () => {...@@ -1861,3 +1862,45 @@ describe("hasServerAcknowledgedLocalDispatch", () => {
1861 ).toBe(true);1862 ).toBe(true);
1862 });1863 });
1863});1864});
1865
1866describe("shouldRefocusComposerOnWindowFocus", () => {
1867 function element(
1868 tagName: string,
1869 options?: { editable?: boolean; role?: string; within?: string },
1870 ) {
1871 return {
1872 tagName,
1873 isContentEditable: options?.editable ?? false,
1874 getAttribute: (name: string) => (name === "role" ? (options?.role ?? null) : null),
1875 closest: (selector: string) =>
1876 options?.within !== undefined && selector.includes(options.within) ? ({} as Element) : null,
1877 };
1878 }
1879
1880 it("refocuses when nothing or the body holds focus", () => {
1881 expect(shouldRefocusComposerOnWindowFocus(null)).toBe(true);
1882 expect(shouldRefocusComposerOnWindowFocus(element("BODY"))).toBe(true);
1883 });
1884
1885 it("refocuses away from a plain button, such as a pull request tab", () => {
1886 expect(shouldRefocusComposerOnWindowFocus(element("BUTTON"))).toBe(true);
1887 });
1888
1889 it("leaves other text fields alone", () => {
1890 expect(shouldRefocusComposerOnWindowFocus(element("INPUT"))).toBe(false);
1891 expect(shouldRefocusComposerOnWindowFocus(element("TEXTAREA"))).toBe(false);
1892 expect(shouldRefocusComposerOnWindowFocus(element("DIV", { editable: true }))).toBe(false);
1893 expect(shouldRefocusComposerOnWindowFocus(element("DIV", { role: "textbox" }))).toBe(false);
1894 });
1895
1896 it("leaves a focused terminal alone in the drawer and the right panel", () => {
1897 expect(
1898 shouldRefocusComposerOnWindowFocus(element("BUTTON", { within: "data-terminal-owner" })),
1899 ).toBe(false);
1900 });
1901
1902 it("leaves focus inside a dialog or popup alone", () => {
1903 expect(shouldRefocusComposerOnWindowFocus(element("BUTTON", { within: "dialog" }))).toBe(false);
1904 expect(shouldRefocusComposerOnWindowFocus(element("BUTTON", { within: "-popup" }))).toBe(false);
1905 });
1906});
apps/web/src/components/ChatView.logic.ts+26
...@@ -1027,3 +1027,29 @@ export function hasServerAcknowledgedLocalDispatch(input: {...@@ -1027,3 +1027,29 @@ export function hasServerAcknowledgedLocalDispatch(input: {
1027 input.localDispatch.sessionUpdatedAt !== (session?.updatedAt ?? null)1027 input.localDispatch.sessionUpdatedAt !== (session?.updatedAt ?? null)
1028 );1028 );
1029}1029}
1030
1031// Returning to the window should land the caret in the composer, so the reader can type right
1032// away. The exceptions are places where focus is deliberate: another text field, a terminal in
1033// the drawer or the right panel, or an open dialog or popup. A focused button outside those is
1034// not one of them, so it yields to the composer.
1035export function shouldRefocusComposerOnWindowFocus(
1036 activeElement:
1037 | (Pick<Element, "tagName" | "closest" | "getAttribute"> & { isContentEditable?: boolean })
1038 | null,
1039): boolean {
1040 if (activeElement === null || activeElement.tagName === "BODY") return true;
1041 if (
1042 activeElement.tagName === "INPUT" ||
1043 activeElement.tagName === "TEXTAREA" ||
1044 activeElement.tagName === "SELECT" ||
1045 activeElement.isContentEditable === true ||
1046 activeElement.getAttribute("role") === "textbox"
1047 ) {
1048 return false;
1049 }
1050 return (
1051 activeElement.closest(
1052 '[role="dialog"], [role="alertdialog"], [data-slot$="-popup"], [data-terminal-owner]',
1053 ) === null
1054 );
1055}
apps/web/src/components/ChatView.tsx+29
...@@ -404,6 +404,7 @@ import {...@@ -404,6 +404,7 @@ import {
404 codexArtifactTemplatePromptToAppend,404 codexArtifactTemplatePromptToAppend,
405 toolGroupConsumesUpwardNavigation,405 toolGroupConsumesUpwardNavigation,
406 waitForStartedServerThread,406 waitForStartedServerThread,
407 shouldRefocusComposerOnWindowFocus,
407} from "./ChatView.logic";408} from "./ChatView.logic";
408import type { ThreadSyncPhase } from "../threadSync";409import type { ThreadSyncPhase } from "../threadSync";
409import { useLocalStorage } from "~/hooks/useLocalStorage";410import { useLocalStorage } from "~/hooks/useLocalStorage";
...@@ -1625,6 +1626,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -1625,6 +1626,7 @@ export default function ChatView(props: ChatViewProps) {
1625 const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] =1626 const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] =
1626 useState<Record<string, number>>({});1627 useState<Record<string, number>>({});
1627 const shouldUseRightPanelSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY);1628 const shouldUseRightPanelSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY);
1629 const isMobileViewport = useMediaQuery("max-sm");
1628 const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0);1630 const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0);
1629 const [pullRequestDialogState, setPullRequestDialogState] =1631 const [pullRequestDialogState, setPullRequestDialogState] =
1630 useState<PullRequestDialogState | null>(null);1632 useState<PullRequestDialogState | null>(null);
...@@ -4988,6 +4990,33 @@ export default function ChatView(props: ChatViewProps) {...@@ -4988,6 +4990,33 @@ export default function ChatView(props: ChatViewProps) {
4988 };4990 };
4989 }, [activeThread?.id, focusComposer, terminalUiState.terminalOpen]);4991 }, [activeThread?.id, focusComposer, terminalUiState.terminalOpen]);
49904992
4993 // Tabbing back into the app lands focus wherever it last was, often the right panel or the
4994 // body. Put it in the composer unless something that takes typing already holds it. The
4995 // drawer terminal owns keyboard input while it is open, so it opts out here; a right panel
4996 // terminal is a surface and is recognized by the predicate instead. Mobile is left alone so
4997 // returning to the app does not raise the keyboard.
4998 useEffect(() => {
4999 if (!activeThread?.id || terminalUiState.terminalOpen || isMobileViewport) return;
5000 let frame: number | null = null;
5001 const onWindowFocus = () => {
5002 if (frame !== null) window.cancelAnimationFrame(frame);
5003 // The element that held focus receives it again after the window's own event, and the
5004 // composer ignores that same frame so a restored focus does not lift a scroll-collapsed
5005 // composer. Wait one more frame so this focus counts as a request to expand it.
5006 frame = window.requestAnimationFrame(() => {
5007 frame = window.requestAnimationFrame(() => {
5008 frame = null;
5009 if (shouldRefocusComposerOnWindowFocus(document.activeElement)) focusComposer();
5010 });
5011 });
5012 };
5013 window.addEventListener("focus", onWindowFocus);
5014 return () => {
5015 window.removeEventListener("focus", onWindowFocus);
5016 if (frame !== null) window.cancelAnimationFrame(frame);
5017 };
5018 }, [activeThread?.id, focusComposer, isMobileViewport, terminalUiState.terminalOpen]);
5019
4991 useEffect(() => {5020 useEffect(() => {
4992 if (!activeThread?.id) return;5021 if (!activeThread?.id) return;
4993 if (activeThread.messages.length === 0) {5022 if (activeThread.messages.length === 0) {