authorgravatar for smithmackinley@gmail.comMacKinley Smith <smithmackinley@gmail.com> 2026-09-25 13:43:35-06:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-25 12:43:35-07:00
loga4f6078bebcb3283e53e1cea247257136ce82cc8
tree37b7f51ab25a4acf9a7d1de0774ce3f0b381a460
parent7a12aff471ffe2b22b9fee495b04b32c43f45a37
signature Signed by PGP key B5690EEEBB952194

fix(web): return focus to the composer after saving a citation note (#13450)


2 files changed, 75 insertions(+), 0 deletions(-)

apps/web/src/components/ComposerPromptEditorTiptap.tsx+55
...@@ -29,6 +29,7 @@ import {...@@ -29,6 +29,7 @@ import {
29 useMemo,29 useMemo,
30 useRef,30 useRef,
31 useState,31 useState,
32 type KeyboardEvent as ReactKeyboardEvent,
32} from "react";33} from "react";
33import { EditorContent, useEditor } from "@tiptap/react";34import { EditorContent, useEditor } from "@tiptap/react";
3435
...@@ -371,6 +372,16 @@ function ComposerCitationNodeView({ node, editor, getPos }: NodeViewProps) {...@@ -371,6 +372,16 @@ function ComposerCitationNodeView({ node, editor, getPos }: NodeViewProps) {
371 .run();372 .run();
372 }, [editor, nodePos]);373 }, [editor, nodePos]);
373374
375 // Put the caret right after the chip so Enter sends and typing continues the prompt.
376 const onRestoreFocus = useCallback(() => {
377 if (!editor.isEditable) return;
378 const pos = nodePos();
379 if (pos === null) return;
380 const current = editor.state.doc.nodeAt(pos);
381 if (!current || current.type.name !== "composer-citation") return;
382 editor.commands.focus(pos + current.nodeSize);
383 }, [editor, nodePos]);
384
374 return (385 return (
375 <NodeViewWrapper386 <NodeViewWrapper
376 as="span"387 as="span"
...@@ -378,6 +389,23 @@ function ComposerCitationNodeView({ node, editor, getPos }: NodeViewProps) {...@@ -378,6 +389,23 @@ function ComposerCitationNodeView({ node, editor, getPos }: NodeViewProps) {
378 contentEditable={false}389 contentEditable={false}
379 spellCheck={false}390 spellCheck={false}
380 data-composer-citation-chip="true"391 data-composer-citation-chip="true"
392 onKeyDown={(event: ReactKeyboardEvent<HTMLElement>) => {
393 // Tab from the comment button returns to the caret after the chip.
394 if (
395 !editor.isEditable ||
396 event.key !== "Tab" ||
397 event.shiftKey ||
398 event.altKey ||
399 event.metaKey ||
400 event.ctrlKey ||
401 !(event.target instanceof HTMLElement) ||
402 event.target.dataset.citationCommentTrigger === undefined
403 ) {
404 return;
405 }
406 event.preventDefault();
407 onRestoreFocus();
408 }}
381 >409 >
382 <AssistantCitationChip410 <AssistantCitationChip
383 citation={citation}411 citation={citation}
...@@ -396,6 +424,7 @@ function ComposerCitationNodeView({ node, editor, getPos }: NodeViewProps) {...@@ -396,6 +424,7 @@ function ComposerCitationNodeView({ node, editor, getPos }: NodeViewProps) {
396 commentContext.onSubmitAndSend();424 commentContext.onSubmitAndSend();
397 return true;425 return true;
398 },426 },
427 onRestoreFocus,
399 }}428 }}
400 />429 />
401 </NodeViewWrapper>430 </NodeViewWrapper>
...@@ -856,6 +885,32 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {...@@ -856,6 +885,32 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
856 return true;885 return true;
857 }886 }
858 }887 }
888 // Shift+Tab from just after a citation reaches its comment button, which
889 // native tab order skips because the chip lives inside the editor.
890 if (
891 event.key === "Tab" &&
892 event.shiftKey &&
893 !event.altKey &&
894 !event.metaKey &&
895 !event.ctrlKey &&
896 view.state.selection.empty
897 ) {
898 const { $from } = view.state.selection;
899 const citation = $from.nodeBefore;
900 if (citation?.type.name === "composer-citation") {
901 const chip = view.nodeDOM($from.pos - citation.nodeSize);
902 const commentButton =
903 chip instanceof HTMLElement
904 ? chip.querySelector<HTMLElement>("[data-citation-comment-trigger]")
905 : null;
906 if (commentButton) {
907 event.preventDefault();
908 event.stopPropagation();
909 commentButton.focus();
910 return true;
911 }
912 }
913 }
859 if (event.key === "Enter" && (event.isComposing || event.keyCode === 229)) {914 if (event.key === "Enter" && (event.isComposing || event.keyCode === 229)) {
860 event.stopPropagation();915 event.stopPropagation();
861 return true;916 return true;
apps/web/src/components/chat/AssistantCitationChip.tsx+20
...@@ -39,10 +39,13 @@ export function AssistantCitationChip({...@@ -39,10 +39,13 @@ export function AssistantCitationChip({
39 onCancel?: () => void;39 onCancel?: () => void;
40 onSave: (comment: string) => boolean;40 onSave: (comment: string) => boolean;
41 onSaveAndSend?: (comment: string) => boolean;41 onSaveAndSend?: (comment: string) => boolean;
42 /** Returns focus to the host editor when the popover closes instead of to the pencil trigger. */
43 onRestoreFocus?: () => void;
42 };44 };
43}) {45}) {
44 const navigate = useNavigate();46 const navigate = useNavigate();
45 const commentInputRef = useRef<HTMLTextAreaElement>(null);47 const commentInputRef = useRef<HTMLTextAreaElement>(null);
48 const commentPopupRef = useRef<HTMLDivElement>(null);
46 const draftCommentRef = useRef<string | null>(null);49 const draftCommentRef = useRef<string | null>(null);
47 const [unavailableSourceAnchor, setUnavailableSourceAnchor] =50 const [unavailableSourceAnchor, setUnavailableSourceAnchor] =
48 useState<AssistantCitationSourceAnchor | null>(null);51 useState<AssistantCitationSourceAnchor | null>(null);
...@@ -155,6 +158,7 @@ export function AssistantCitationChip({...@@ -155,6 +158,7 @@ export function AssistantCitationChip({
155 >158 >
156 <PopoverTrigger159 <PopoverTrigger
157 aria-label={citation.comment ? "Edit citation comment" : "Add comment to citation"}160 aria-label={citation.comment ? "Edit citation comment" : "Add comment to citation"}
161 data-citation-comment-trigger="true"
158 render={<ContextChipAction />}162 render={<ContextChipAction />}
159 >163 >
160 <PencilIcon aria-hidden="true" />164 <PencilIcon aria-hidden="true" />
...@@ -169,6 +173,22 @@ export function AssistantCitationChip({...@@ -169,6 +173,22 @@ export function AssistantCitationChip({
169 commentInputRef.current?.focus({ preventScroll: true });173 commentInputRef.current?.focus({ preventScroll: true });
170 return false;174 return false;
171 }}175 }}
176 finalFocus={
177 commentEditor.onRestoreFocus
178 ? () => {
179 // Leave focus alone when the user closed the popover by moving to another control.
180 const activeElement = document.activeElement;
181 if (
182 activeElement === document.body ||
183 (activeElement !== null && commentPopupRef.current?.contains(activeElement))
184 ) {
185 commentEditor.onRestoreFocus?.();
186 }
187 return false;
188 }
189 : undefined
190 }
191 ref={commentPopupRef}
172 aria-label="Edit citation comment"192 aria-label="Edit citation comment"
173 width="md"193 width="md"
174 padding="compact"194 padding="compact"