- {/* Greeting positioned above the composer */}
{greeting}
- {/* Composer - top edge fixed, expands downward only */}
@@ -372,7 +351,6 @@ const ClipboardChip: FC<{ text: string; onDismiss: () => void }> = ({ text, onDi
};
const Composer: FC = () => {
- // Document mention state (atoms persist across component remounts)
const [mentionedDocuments, setMentionedDocuments] = useAtom(mentionedDocumentsAtom);
const [showDocumentPopover, setShowDocumentPopover] = useState(false);
const [showPromptPicker, setShowPromptPicker] = useState(false);
@@ -384,7 +362,9 @@ const Composer: FC = () => {
const promptPickerRef = useRef
(null);
const { search_space_id, chat_id } = useParams();
const aui = useAui();
- const hasAutoFocusedRef = useRef(false);
+ // Desktop-only auto-focus; on mobile, programmatic focus would
+ // summon the soft keyboard on every picker close / thread switch.
+ const isDesktop = useMediaQuery("(min-width: 640px)");
const electronAPI = useElectronAPI();
const [clipboardInitialText, setClipboardInitialText] = useState();
@@ -404,7 +384,6 @@ const Composer: FC = () => {
const currentPlaceholder = COMPOSER_PLACEHOLDER;
- // Live collaboration state
const { data: currentUser } = useAtomValue(currentUserAtom);
const { data: members } = useAtomValue(membersAtom);
const threadId = useMemo(() => {
@@ -418,13 +397,11 @@ const Composer: FC = () => {
const respondingToUserId = sessionState?.respondingToUserId ?? null;
const isBlockedByOtherUser = isAiResponding && respondingToUserId !== currentUser?.id;
- // Sync comments for the entire thread via Zero (one subscription per thread)
+ // One Zero subscription per thread for comment sync.
useCommentsSync(threadId);
- // Batch-prefetch comments for all assistant messages so individual useComments
- // hooks never fire their own network requests (eliminates N+1 API calls).
- // Return a primitive string from the selector so useSyncExternalStore can
- // compare snapshots by value and avoid infinite re-render loops.
+ // Batch-prefetch assistant message comments to avoid N+1 fetches.
+ // Returns a primitive string so useSyncExternalStore can compare by value.
const assistantIdsKey = useAuiState(({ thread }) =>
thread.messages
.filter((m) => m.role === "assistant" && m.id?.startsWith("msg-"))
@@ -437,18 +414,17 @@ const Composer: FC = () => {
);
useBatchCommentsPreload(assistantDbMessageIds);
- // Auto-focus editor on new chat page after mount
+ // Always-focused composer: refocus whenever no picker has taken
+ // over input. ``threadId`` is in the deps so the effect re-fires
+ // on thread switch (Composer instance is reused).
useEffect(() => {
- if (isThreadEmpty && !hasAutoFocusedRef.current && editorRef.current) {
- const timeoutId = setTimeout(() => {
- editorRef.current?.focus();
- hasAutoFocusedRef.current = true;
- }, 100);
- return () => clearTimeout(timeoutId);
- }
- }, [isThreadEmpty]);
+ if (!isDesktop) return;
+ if (showDocumentPopover || showPromptPicker) return;
+ void threadId;
+ editorRef.current?.focus();
+ }, [isDesktop, showDocumentPopover, showPromptPicker, threadId]);
- // Close document picker when a slide-out panel (inbox, shared/private chats) opens
+ // Close document picker when a slide-out panel (inbox, etc.) opens.
useEffect(() => {
const handler = () => {
setShowDocumentPopover(false);
@@ -458,21 +434,41 @@ const Composer: FC = () => {
return () => window.removeEventListener(SLIDEOUT_PANEL_OPENED_EVENT, handler);
}, []);
- // Sync editor text with assistant-ui composer runtime
+ // Sync editor text into assistant-ui's composer and mirror the chip
+ // atom from the editor's reported ``docs``. The editor is the
+ // single source of truth, so this catches every Plate deletion path
+ // (Backspace, X button, Cmd+Backspace, range-delete, cut,
+ // paste-over) without per-keybinding plumbing. The ``prev``
+ // short-circuit keeps pure-text keystrokes from churning the atom.
const handleEditorChange = useCallback(
- (text: string) => {
+ (text: string, docs: MentionedDocument[]) => {
aui.composer().setText(text);
+ setMentionedDocuments((prev) => {
+ if (prev.length === docs.length) {
+ const editorKeys = new Set(docs.map((d) => getMentionDocKey(d)));
+ if (prev.every((d) => editorKeys.has(getMentionDocKey(d)))) {
+ return prev;
+ }
+ }
+ return docs.map((d) => ({
+ id: d.id,
+ title: d.title,
+ // Atom requires a string; ``"UNKNOWN"`` matches the
+ // sentinel ``getMentionDocKey`` and the editor's
+ // match predicates use.
+ document_type: d.document_type ?? "UNKNOWN",
+ kind: d.kind,
+ }));
+ });
},
- [aui]
+ [aui, setMentionedDocuments]
);
- // Open document picker when @ mention is triggered
const handleMentionTrigger = useCallback((query: string) => {
setShowDocumentPopover(true);
setMentionQuery(query);
}, []);
- // Close document picker and reset query
const handleMentionClose = useCallback(() => {
if (showDocumentPopover) {
setShowDocumentPopover(false);
@@ -480,13 +476,11 @@ const Composer: FC = () => {
}
}, [showDocumentPopover]);
- // Open action picker when / is triggered
const handleActionTrigger = useCallback((query: string) => {
setShowPromptPicker(true);
setActionQuery(query);
}, []);
- // Close action picker and reset query
const handleActionClose = useCallback(() => {
if (showPromptPicker) {
setShowPromptPicker(false);
@@ -530,7 +524,7 @@ const Composer: FC = () => {
[clipboardInitialText, electronAPI, aui]
);
- // Keyboard navigation for document/action picker (arrow keys, Enter, Escape)
+ // Arrow / Enter / Escape navigation for the active picker.
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (showPromptPicker) {
@@ -611,7 +605,7 @@ const Composer: FC = () => {
(docId: number, docType?: string) => {
setMentionedDocuments((prev) => {
if (!docType) {
- // Defensive fallback: keep UI in sync even when chip type is unavailable.
+ // Fallback when chip type is unavailable.
return prev.filter((doc) => doc.id !== docId);
}
const removedKey = getMentionDocKey({ id: docId, document_type: docType });
@@ -621,27 +615,22 @@ const Composer: FC = () => {
[setMentionedDocuments]
);
- const handleDocumentsMention = useCallback(
- (mentions: MentionedDocumentInfo[]) => {
- const editorMentionedDocs = editorRef.current?.getMentionedDocuments() ?? [];
- const editorDocKeys = new Set(editorMentionedDocs.map((doc) => getMentionDocKey(doc)));
+ const handleDocumentsMention = useCallback((mentions: MentionedDocumentInfo[]) => {
+ const editorMentionedDocs = editorRef.current?.getMentionedDocuments() ?? [];
+ const editorDocKeys = new Set(editorMentionedDocs.map((doc) => getMentionDocKey(doc)));
- for (const mention of mentions) {
- const key = getMentionDocKey(mention);
- if (editorDocKeys.has(key)) continue;
- editorRef.current?.insertMentionChip(mention);
- }
+ for (const mention of mentions) {
+ const key = getMentionDocKey(mention);
+ if (editorDocKeys.has(key)) continue;
+ editorRef.current?.insertMentionChip(mention);
+ // Track within the loop so a duplicate-in-batch can't double-insert.
+ editorDocKeys.add(key);
+ }
- setMentionedDocuments((prev) => {
- const existingKeySet = new Set(prev.map((d) => getMentionDocKey(d)));
- const uniqueNew = mentions.filter((m) => !existingKeySet.has(getMentionDocKey(m)));
- return [...prev, ...uniqueNew];
- });
-
- setMentionQuery("");
- },
- [setMentionedDocuments]
- );
+ // Atom is reconciled by ``handleEditorChange`` via the editor's
+ // onChange — no second write path here.
+ setMentionQuery("");
+ }, []);
useEffect(() => {
const editor = editorRef.current;
@@ -1212,17 +1201,19 @@ const ComposerAction: FC = ({ isBlockedByOtherUser = false
)}