| author | |
| committer | |
| log | ecf3716fd190005b1552957b3c7880ab849a0a65 |
| tree | 20c5e341cfd34defb9f5b8c162ff7d6c6eadaa86 |
| parent | 7e03dcfe5d405626a94037e19a18f9950162b3ea |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 42 | getStartedThreadModelChangeBlockReason, |
| 43 | 43 | hasEnvironmentReconnectWarningGraceElapsed, |
| 44 | 44 | hasServerAcknowledgedLocalDispatch, |
| 45 | shouldRefocusComposerOnWindowFocus, | |
| 45 | 46 | isBranchMismatchDismissedForSession, |
| 46 | 47 | reconcileMountedTerminalThreadIds, |
| 47 | 48 | reconcileRetainedMountedThreadIds, |
| ... | ... | @@ -1861,3 +1862,45 @@ describe("hasServerAcknowledgedLocalDispatch", () => { |
| 1861 | 1862 | ).toBe(true); |
| 1862 | 1863 | }); |
| 1863 | 1864 | }); |
| 1865 | ||
| 1866 | describe("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 | 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. | |
| 1035 | export 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 | 404 | codexArtifactTemplatePromptToAppend, |
| 405 | 405 | toolGroupConsumesUpwardNavigation, |
| 406 | 406 | waitForStartedServerThread, |
| 407 | shouldRefocusComposerOnWindowFocus, | |
| 407 | 408 | } from "./ChatView.logic"; |
| 408 | 409 | import type { ThreadSyncPhase } from "../threadSync"; |
| 409 | 410 | import { useLocalStorage } from "~/hooks/useLocalStorage"; |
| ... | ... | @@ -1625,6 +1626,7 @@ export default function ChatView(props: ChatViewProps) { |
| 1625 | 1626 | const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] = |
| 1626 | 1627 | useState<Record<string, number>>({}); |
| 1627 | 1628 | const shouldUseRightPanelSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY); |
| 1629 | const isMobileViewport = useMediaQuery("max-sm"); | |
| 1628 | 1630 | const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); |
| 1629 | 1631 | const [pullRequestDialogState, setPullRequestDialogState] = |
| 1630 | 1632 | useState<PullRequestDialogState | null>(null); |
| ... | ... | @@ -4988,6 +4990,33 @@ export default function ChatView(props: ChatViewProps) { |
| 4988 | 4990 | }; |
| 4989 | 4991 | }, [activeThread?.id, focusComposer, terminalUiState.terminalOpen]); |
| 4990 | 4992 | |
| 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 | 5020 | useEffect(() => { |
| 4992 | 5021 | if (!activeThread?.id) return; |
| 4993 | 5022 | if (activeThread.messages.length === 0) { |