diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx
index 74e8736f04e742fbd70d6376f1fc00ebe28facac..2a5fe2ba0588310469829cae281ee4c3328a60e2 100644
--- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx
+++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx
@@ -29,6 +29,7 @@ import {
useMemo,
useRef,
useState,
+ type KeyboardEvent as ReactKeyboardEvent,
} from "react";
import { EditorContent, useEditor } from "@tiptap/react";
@@ -371,6 +372,16 @@ function ComposerCitationNodeView({ node, editor, getPos }: NodeViewProps) {
.run();
}, [editor, nodePos]);
+ // Put the caret right after the chip so Enter sends and typing continues the prompt.
+ const onRestoreFocus = useCallback(() => {
+ if (!editor.isEditable) return;
+ const pos = nodePos();
+ if (pos === null) return;
+ const current = editor.state.doc.nodeAt(pos);
+ if (!current || current.type.name !== "composer-citation") return;
+ editor.commands.focus(pos + current.nodeSize);
+ }, [editor, nodePos]);
+
return (
) => {
+ // Tab from the comment button returns to the caret after the chip.
+ if (
+ !editor.isEditable ||
+ event.key !== "Tab" ||
+ event.shiftKey ||
+ event.altKey ||
+ event.metaKey ||
+ event.ctrlKey ||
+ !(event.target instanceof HTMLElement) ||
+ event.target.dataset.citationCommentTrigger === undefined
+ ) {
+ return;
+ }
+ event.preventDefault();
+ onRestoreFocus();
+ }}
>
@@ -856,6 +885,32 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
return true;
}
}
+ // Shift+Tab from just after a citation reaches its comment button, which
+ // native tab order skips because the chip lives inside the editor.
+ if (
+ event.key === "Tab" &&
+ event.shiftKey &&
+ !event.altKey &&
+ !event.metaKey &&
+ !event.ctrlKey &&
+ view.state.selection.empty
+ ) {
+ const { $from } = view.state.selection;
+ const citation = $from.nodeBefore;
+ if (citation?.type.name === "composer-citation") {
+ const chip = view.nodeDOM($from.pos - citation.nodeSize);
+ const commentButton =
+ chip instanceof HTMLElement
+ ? chip.querySelector("[data-citation-comment-trigger]")
+ : null;
+ if (commentButton) {
+ event.preventDefault();
+ event.stopPropagation();
+ commentButton.focus();
+ return true;
+ }
+ }
+ }
if (event.key === "Enter" && (event.isComposing || event.keyCode === 229)) {
event.stopPropagation();
return true;
diff --git a/apps/web/src/components/chat/AssistantCitationChip.tsx b/apps/web/src/components/chat/AssistantCitationChip.tsx
index 6baf14b1888790b027bb524f562e8708560135b0..e03872553a88f7e600761d7a3fb172390c29aa01 100644
--- a/apps/web/src/components/chat/AssistantCitationChip.tsx
+++ b/apps/web/src/components/chat/AssistantCitationChip.tsx
@@ -39,10 +39,13 @@ export function AssistantCitationChip({
onCancel?: () => void;
onSave: (comment: string) => boolean;
onSaveAndSend?: (comment: string) => boolean;
+ /** Returns focus to the host editor when the popover closes instead of to the pencil trigger. */
+ onRestoreFocus?: () => void;
};
}) {
const navigate = useNavigate();
const commentInputRef = useRef(null);
+ const commentPopupRef = useRef(null);
const draftCommentRef = useRef(null);
const [unavailableSourceAnchor, setUnavailableSourceAnchor] =
useState(null);
@@ -155,6 +158,7 @@ export function AssistantCitationChip({
>
}
>
@@ -169,6 +173,22 @@ export function AssistantCitationChip({
commentInputRef.current?.focus({ preventScroll: true });
return false;
}}
+ finalFocus={
+ commentEditor.onRestoreFocus
+ ? () => {
+ // Leave focus alone when the user closed the popover by moving to another control.
+ const activeElement = document.activeElement;
+ if (
+ activeElement === document.body ||
+ (activeElement !== null && commentPopupRef.current?.contains(activeElement))
+ ) {
+ commentEditor.onRestoreFocus?.();
+ }
+ return false;
+ }
+ : undefined
+ }
+ ref={commentPopupRef}
aria-label="Edit citation comment"
width="md"
padding="compact"