| 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,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 | |||
| 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,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. | ||
| 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,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"; |
| 408 | import type { ThreadSyncPhase } from "../threadSync"; | 409 | import type { ThreadSyncPhase } from "../threadSync"; |
| 409 | import { useLocalStorage } from "~/hooks/useLocalStorage"; | 410 | import { 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]); |
| 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 | 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) { |