refactor(assistant-ui): streamline docstrings and comments

This commit is contained in:
Anish Sarkar 2026-05-12 23:24:01 +05:30
parent 2437716752
commit 32ff864fd3
2 changed files with 57 additions and 168 deletions

View file

@ -36,13 +36,9 @@ export interface MentionedDocument {
} }
/** /**
* Input shape for inserting a chip. ``kind`` defaults to ``"doc"`` * Input shape for inserting a chip. ``kind`` defaults to ``"doc"``.
* when omitted so legacy callers don't have to thread the * Folder chips default ``document_type`` to ``FOLDER_MENTION_DOCUMENT_TYPE``
* discriminator. Folder callers pass ``kind: "folder"`` and the * so the dedup key never collides with a doc chip sharing the same id.
* folder ``id`` and ``title``; ``document_type`` defaults to
* ``FOLDER_MENTION_DOCUMENT_TYPE`` inside ``insertMentionChip`` so the
* dedup key (`kind:document_type:id`) never collides with a doc chip
* that happens to share an id.
*/ */
export type MentionChipInput = { export type MentionChipInput = {
id: number; id: number;
@ -58,10 +54,7 @@ export interface InlineMentionEditorRef {
getText: () => string; getText: () => string;
getMentionedDocuments: () => MentionedDocument[]; getMentionedDocuments: () => MentionedDocument[];
insertMentionChip: (mention: MentionChipInput, options?: { removeTriggerText?: boolean }) => void; insertMentionChip: (mention: MentionChipInput, options?: { removeTriggerText?: boolean }) => void;
/** /** @deprecated Use ``insertMentionChip``. */
* @deprecated Use ``insertMentionChip``. Kept for one transition
* cycle so we don't break ad-hoc callers; prefer the new name.
*/
insertDocumentChip: ( insertDocumentChip: (
doc: Pick<Document, "id" | "title" | "document_type">, doc: Pick<Document, "id" | "title" | "document_type">,
options?: { removeTriggerText?: boolean } options?: { removeTriggerText?: boolean }
@ -97,12 +90,7 @@ type MentionElementNode = {
id: number; id: number;
title: string; title: string;
document_type?: string; document_type?: string;
/** /** Discriminator; defaults to ``"doc"`` for legacy nodes. */
* Discriminator added so a folder chip and a doc chip with the
* same id round-trip cleanly through ``getMentionedDocuments``
* and the persisted ``mentioned-documents`` content part.
* Defaults to ``"doc"`` for nodes that predate this field.
*/
kind?: MentionKind; kind?: MentionKind;
statusLabel?: string | null; statusLabel?: string | null;
statusKind?: MentionStatusKind; statusKind?: MentionStatusKind;
@ -122,11 +110,8 @@ const COMPOSER_TEXT_METRICS_CLASSNAME = "text-sm leading-6";
const EMPTY_VALUE: ComposerValue = [{ type: "p", children: [{ text: "" }] }]; const EMPTY_VALUE: ComposerValue = [{ type: "p", children: [{ text: "" }] }];
/** /**
* Internal seam that lets ``MentionElement`` (a Plate render component * Lets ``MentionElement`` reach the editor's chip-removal helper so
* with no React props beyond ``element``) reach the editor's chip-removal * the X button and Backspace go through the same call site.
* function. Mirrors the Backspace path in ``handleKeyDown`` so the X
* button delegates to the exact same combined call site no extra
* state, no atom coupling leaking into the chip.
*/ */
type MentionEditorContextValue = { type MentionEditorContextValue = {
removeChip: (docId: number, docType: string | undefined) => void; removeChip: (docId: number, docType: string | undefined) => void;
@ -336,13 +321,8 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
value: initialText ? toValueFromText(initialText) : EMPTY_VALUE, value: initialText ? toValueFromText(initialText) : EMPTY_VALUE,
}); });
// Move the caret to the end of the document and focus the editor. // Move the caret to end-of-doc and focus the editor. Falls back
// Routes through Plate's transforms so ``editor.selection`` and // to DOM focus if Plate's API throws (transient unmount race).
// the DOM selection stay in sync — bypassing Plate (via raw
// ``window.getSelection``) was the prior implementation and is
// what made the caret disappear after every ``setValue``-based
// mutation. Falls back to DOM focus if Plate's API throws (e.g.
// during a transient unmount race).
const focusAtEnd = useCallback(() => { const focusAtEnd = useCallback(() => {
try { try {
editor.tf.select(editor.api.end([])); editor.tf.select(editor.api.end([]));
@ -398,22 +378,13 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
[editor, emitState] [editor, emitState]
); );
// Insert a mention chip at the current caret. Uses Plate // Insert chip + trailing space as a single ``insertNodes`` call.
// transforms so Slate keeps the editor selection valid through // The chip is a void inline; ``select: true`` on it alone would
// the edit. // land the caret inside its empty children (an unrenderable
// // point). With the space as the last inserted node, the caret
// Critical detail: the chip is a void inline element. Inserting // resolves to that text node and stays visible. The
// it on its own with ``{ select: true }`` would land the caret // ``withoutNormalizing`` wrapper batches the optional trigger
// inside the void's empty ``children: [{ text: "" }]`` — a point // delete + insert into a single undo step.
// the browser can't render a caret on, which is what made the
// cursor disappear or jump to the wrong side of the chip after
// insertion. Inserting ``[mentionNode, { text: " " }]`` as a
// single array means the *last* inserted node is a text node, so
// ``{ select: true }`` resolves to that text node's end (offset
// 1, after the trailing space) — a real, renderable text point.
// The whole sequence stays inside ``withoutNormalizing`` so the
// optional trigger-text delete and the chip insert show up as a
// single undo step.
const insertMentionChip = useCallback( const insertMentionChip = useCallback(
(mention: MentionChipInput, options?: { removeTriggerText?: boolean }) => { (mention: MentionChipInput, options?: { removeTriggerText?: boolean }) => {
if (typeof mention.id !== "number" || typeof mention.title !== "string") return; if (typeof mention.id !== "number" || typeof mention.title !== "string") return;
@ -434,16 +405,12 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
editor.tf.withoutNormalizing(() => { editor.tf.withoutNormalizing(() => {
const selection = editor.selection; const selection = editor.selection;
// No active editor selection — typically because focus // No active selection (focus moved to a picker) — snap
// moved to a picker/dropdown. Snap the caret to the end // to end-of-doc so the chip appends cleanly.
// of the document so the chip appends cleanly instead
// of disappearing into a dead range.
if (!selection) { if (!selection) {
editor.tf.select(editor.api.end([])); editor.tf.select(editor.api.end([]));
} else if (removeTriggerText) { } else if (removeTriggerText) {
// Delete the in-progress "@query" text so the chip // Delete the in-progress "@query" so the chip stands in for it.
// stands in for it. Mirrors the old splice but lets
// Slate keep selection sane through the edit.
const cursorCtx = getCursorTextContext(getCurrentValue(), selection); const cursorCtx = getCursorTextContext(getCurrentValue(), selection);
if (cursorCtx) { if (cursorCtx) {
const text = cursorCtx.text; const text = cursorCtx.text;
@ -476,9 +443,7 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
[editor, getCurrentValue] [editor, getCurrentValue]
); );
// Backwards-compatible shim — pre-folder callers pass a doc-only // Doc-only shim that routes through ``insertMentionChip``.
// payload; we route them through ``insertMentionChip`` with
// ``kind: "doc"``.
const insertDocumentChip = useCallback( const insertDocumentChip = useCallback(
( (
doc: Pick<Document, "id" | "title" | "document_type">, doc: Pick<Document, "id" | "title" | "document_type">,
@ -489,15 +454,10 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
[insertMentionChip] [insertMentionChip]
); );
// Remove the chip(s) matching the given (id, document_type) pair. // Remove chip(s) matching (id, document_type). Iterates in
// Goes through ``tf.removeNodes`` so Slate keeps the surrounding // descending path order so removing one entry can't invalidate
// selection valid — the previous ``setValue``-based filter wiped // later paths. Chips are deduped today, so this typically runs
// selection on every removal, which is why the caret vanished // at most once.
// when the X button was clicked. Iterates descending so removing
// one entry doesn't invalidate the path of subsequent matches.
// In practice chips are deduped by ``getMentionDocKey`` so this
// loop runs at most once; the descending iteration is defense
// against any future divergence.
const removeDocumentChip = useCallback( const removeDocumentChip = useCallback(
(docId: number, docType?: string) => { (docId: number, docType?: string) => {
const match = (n: unknown) => { const match = (n: unknown) => {
@ -519,10 +479,8 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
[editor] [editor]
); );
// Combined "remove chip end-to-end" used by both the Backspace // Single removal call site for Backspace and the X button so the
// keybinding and the in-chip X button. Keeping these two surfaces // two can never diverge (e.g. one forgetting to notify the parent).
// pinned to a single helper guarantees they can never diverge —
// e.g. one path forgetting to notify the parent atom.
const removeChip = useCallback( const removeChip = useCallback(
(docId: number, docType: string | undefined) => { (docId: number, docType: string | undefined) => {
removeDocumentChip(docId, docType); removeDocumentChip(docId, docType);
@ -531,11 +489,8 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
[onDocumentRemove, removeDocumentChip] [onDocumentRemove, removeDocumentChip]
); );
// Update the streaming status on a chip in place. ``tf.setNodes`` // Update chip status in place via ``tf.setNodes`` so the user's
// merges the partial props onto every node matching the // selection survives backend status events arriving mid-typing.
// predicate without rebuilding the document, so the user's
// selection stays put — important because status transitions
// arrive as backend events while the user may be mid-typing.
const setDocumentChipStatus = useCallback( const setDocumentChipStatus = useCallback(
( (
docId: number, docId: number,
@ -564,10 +519,8 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
const clear = useCallback(() => { const clear = useCallback(() => {
setValue(EMPTY_VALUE); setValue(EMPTY_VALUE);
// ``tf.setValue`` (inside ``setValue``) wipes the editor's // ``tf.setValue`` wipes the selection — refocus so the caret
// selection — without this, after the user presses Enter to // returns after Enter-to-submit.
// submit, the composer is left with no caret and they would
// have to click before typing again.
requestAnimationFrame(focusAtEnd); requestAnimationFrame(focusAtEnd);
}, [focusAtEnd, setValue]); }, [focusAtEnd, setValue]);
@ -588,12 +541,8 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
useImperativeHandle( useImperativeHandle(
ref, ref,
() => ({ () => ({
// If we already have a Plate selection (user was typing // Preserve existing selection if any; otherwise seed one
// before focus left), preserve it — just refocus. If we // at end-of-doc so the contentEditable shows a caret.
// don't (first mount, or focus was lost without a
// surviving selection), seed a selection at end-of-doc
// so the contentEditable shows a caret instead of an
// invisible focus ring.
focus: () => { focus: () => {
try { try {
if (!editor.selection) { if (!editor.selection) {

View file

@ -172,36 +172,24 @@ const PremiumQuotaPinnedAlert: FC = () => {
const getTimeBasedGreeting = (user?: { display_name?: string | null; email?: string }): string => { const getTimeBasedGreeting = (user?: { display_name?: string | null; email?: string }): string => {
const hour = new Date().getHours(); const hour = new Date().getHours();
// Extract first name: prefer display_name, fall back to email extraction
let firstName: string | null = null; let firstName: string | null = null;
if (user?.display_name?.trim()) { if (user?.display_name?.trim()) {
// Use display_name if available and not empty
// Extract first name from display_name (take first word)
const nameParts = user.display_name.trim().split(/\s+/); const nameParts = user.display_name.trim().split(/\s+/);
firstName = nameParts[0].charAt(0).toUpperCase() + nameParts[0].slice(1).toLowerCase(); firstName = nameParts[0].charAt(0).toUpperCase() + nameParts[0].slice(1).toLowerCase();
} else if (user?.email) { } else if (user?.email) {
// Fall back to email extraction if display_name is not available
firstName = firstName =
user.email.split("@")[0].split(".")[0].charAt(0).toUpperCase() + user.email.split("@")[0].split(".")[0].charAt(0).toUpperCase() +
user.email.split("@")[0].split(".")[0].slice(1); user.email.split("@")[0].split(".")[0].slice(1);
} }
// Array of greeting variations for each time period
const morningGreetings = ["Good morning", "Fresh start today", "Morning", "Hey there"]; const morningGreetings = ["Good morning", "Fresh start today", "Morning", "Hey there"];
const afternoonGreetings = ["Good afternoon", "Afternoon", "Hey there", "Hi there"]; const afternoonGreetings = ["Good afternoon", "Afternoon", "Hey there", "Hi there"];
const eveningGreetings = ["Good evening", "Evening", "Hey there", "Hi there"]; const eveningGreetings = ["Good evening", "Evening", "Hey there", "Hi there"];
const nightGreetings = ["Good night", "Evening", "Hey there", "Winding down"]; const nightGreetings = ["Good night", "Evening", "Hey there", "Winding down"];
const lateNightGreetings = ["Still up", "Night owl mode", "Up past bedtime", "Hi there"]; const lateNightGreetings = ["Still up", "Night owl mode", "Up past bedtime", "Hi there"];
// Select a random greeting based on time
let greeting: string; let greeting: string;
if (hour < 5) { if (hour < 5) {
// Late night: midnight to 5 AM
greeting = lateNightGreetings[Math.floor(Math.random() * lateNightGreetings.length)]; greeting = lateNightGreetings[Math.floor(Math.random() * lateNightGreetings.length)];
} else if (hour < 12) { } else if (hour < 12) {
greeting = morningGreetings[Math.floor(Math.random() * morningGreetings.length)]; greeting = morningGreetings[Math.floor(Math.random() * morningGreetings.length)];
@ -210,33 +198,23 @@ const getTimeBasedGreeting = (user?: { display_name?: string | null; email?: str
} else if (hour < 22) { } else if (hour < 22) {
greeting = eveningGreetings[Math.floor(Math.random() * eveningGreetings.length)]; greeting = eveningGreetings[Math.floor(Math.random() * eveningGreetings.length)];
} else { } else {
// Night: 10 PM to midnight
greeting = nightGreetings[Math.floor(Math.random() * nightGreetings.length)]; greeting = nightGreetings[Math.floor(Math.random() * nightGreetings.length)];
} }
// Add personalization with first name if available return firstName ? `${greeting}, ${firstName}!` : `${greeting}!`;
if (firstName) {
return `${greeting}, ${firstName}!`;
}
return `${greeting}!`;
}; };
const ThreadWelcome: FC = () => { const ThreadWelcome: FC = () => {
const { data: user } = useAtomValue(currentUserAtom); const { data: user } = useAtomValue(currentUserAtom);
// Memoize greeting so it doesn't change on re-renders (only on user change)
const greeting = useMemo(() => getTimeBasedGreeting(user), [user]); const greeting = useMemo(() => getTimeBasedGreeting(user), [user]);
return ( return (
<div className="aui-thread-welcome-root mx-auto flex w-full max-w-(--thread-max-width) grow flex-col items-center px-4 relative"> <div className="aui-thread-welcome-root mx-auto flex w-full max-w-(--thread-max-width) grow flex-col items-center px-4 relative">
{/* Greeting positioned above the composer */}
<div className="aui-thread-welcome-message absolute bottom-[calc(50%+5rem)] left-0 right-0 flex flex-col items-center text-center"> <div className="aui-thread-welcome-message absolute bottom-[calc(50%+5rem)] left-0 right-0 flex flex-col items-center text-center">
<h1 className="aui-thread-welcome-message-inner text-3xl md:text-5xl select-none"> <h1 className="aui-thread-welcome-message-inner text-3xl md:text-5xl select-none">
{greeting} {greeting}
</h1> </h1>
</div> </div>
{/* Composer - top edge fixed, expands downward only */}
<div className="w-full flex items-start justify-center absolute top-[calc(50%-3.5rem)] left-0 right-0"> <div className="w-full flex items-start justify-center absolute top-[calc(50%-3.5rem)] left-0 right-0">
<Composer /> <Composer />
</div> </div>
@ -373,7 +351,6 @@ const ClipboardChip: FC<{ text: string; onDismiss: () => void }> = ({ text, onDi
}; };
const Composer: FC = () => { const Composer: FC = () => {
// Document mention state (atoms persist across component remounts)
const [mentionedDocuments, setMentionedDocuments] = useAtom(mentionedDocumentsAtom); const [mentionedDocuments, setMentionedDocuments] = useAtom(mentionedDocumentsAtom);
const [showDocumentPopover, setShowDocumentPopover] = useState(false); const [showDocumentPopover, setShowDocumentPopover] = useState(false);
const [showPromptPicker, setShowPromptPicker] = useState(false); const [showPromptPicker, setShowPromptPicker] = useState(false);
@ -385,9 +362,8 @@ const Composer: FC = () => {
const promptPickerRef = useRef<PromptPickerRef>(null); const promptPickerRef = useRef<PromptPickerRef>(null);
const { search_space_id, chat_id } = useParams(); const { search_space_id, chat_id } = useParams();
const aui = useAui(); const aui = useAui();
// Gate the always-focused composer behaviour to desktop. On mobile, // Desktop-only auto-focus; on mobile, programmatic focus would
// programmatic focus pops the soft keyboard, which would be jarring // summon the soft keyboard on every picker close / thread switch.
// whenever a picker closes or the user navigates between threads.
const isDesktop = useMediaQuery("(min-width: 640px)"); const isDesktop = useMediaQuery("(min-width: 640px)");
const electronAPI = useElectronAPI(); const electronAPI = useElectronAPI();
@ -408,7 +384,6 @@ const Composer: FC = () => {
const currentPlaceholder = COMPOSER_PLACEHOLDER; const currentPlaceholder = COMPOSER_PLACEHOLDER;
// Live collaboration state
const { data: currentUser } = useAtomValue(currentUserAtom); const { data: currentUser } = useAtomValue(currentUserAtom);
const { data: members } = useAtomValue(membersAtom); const { data: members } = useAtomValue(membersAtom);
const threadId = useMemo(() => { const threadId = useMemo(() => {
@ -422,13 +397,11 @@ const Composer: FC = () => {
const respondingToUserId = sessionState?.respondingToUserId ?? null; const respondingToUserId = sessionState?.respondingToUserId ?? null;
const isBlockedByOtherUser = isAiResponding && respondingToUserId !== currentUser?.id; 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); useCommentsSync(threadId);
// Batch-prefetch comments for all assistant messages so individual useComments // Batch-prefetch assistant message comments to avoid N+1 fetches.
// hooks never fire their own network requests (eliminates N+1 API calls). // Returns a primitive string so useSyncExternalStore can compare by value.
// Return a primitive string from the selector so useSyncExternalStore can
// compare snapshots by value and avoid infinite re-render loops.
const assistantIdsKey = useAuiState(({ thread }) => const assistantIdsKey = useAuiState(({ thread }) =>
thread.messages thread.messages
.filter((m) => m.role === "assistant" && m.id?.startsWith("msg-")) .filter((m) => m.role === "assistant" && m.id?.startsWith("msg-"))
@ -441,18 +414,9 @@ const Composer: FC = () => {
); );
useBatchCommentsPreload(assistantDbMessageIds); useBatchCommentsPreload(assistantDbMessageIds);
// Always-focused composer (Claude-style). Runs as a reactive // Always-focused composer: refocus whenever no picker has taken
// invariant: whenever the composer is mounted and no transient // over input. ``threadId`` is in the deps so the effect re-fires
// picker has taken over keyboard input, the editor should be the // on thread switch (Composer instance is reused).
// focused element. This naturally restores focus after pickers
// close, after the user switches threads, and on first mount —
// replacing the previous one-shot ``hasAutoFocusedRef`` gate that
// only worked on the welcome screen.
//
// Gated on ``isDesktop`` so we don't repeatedly summon the mobile
// soft keyboard whenever any of the deps change. ``threadId`` is
// read so the effect re-fires when the user switches between two
// non-empty threads (where the Composer instance is reused).
useEffect(() => { useEffect(() => {
if (!isDesktop) return; if (!isDesktop) return;
if (showDocumentPopover || showPromptPicker) return; if (showDocumentPopover || showPromptPicker) return;
@ -460,7 +424,7 @@ const Composer: FC = () => {
editorRef.current?.focus(); editorRef.current?.focus();
}, [isDesktop, showDocumentPopover, showPromptPicker, threadId]); }, [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(() => { useEffect(() => {
const handler = () => { const handler = () => {
setShowDocumentPopover(false); setShowDocumentPopover(false);
@ -470,21 +434,12 @@ const Composer: FC = () => {
return () => window.removeEventListener(SLIDEOUT_PANEL_OPENED_EVENT, handler); return () => window.removeEventListener(SLIDEOUT_PANEL_OPENED_EVENT, handler);
}, []); }, []);
// Sync editor text with the assistant-ui composer runtime and // Sync editor text into assistant-ui's composer and mirror the chip
// reconcile the chip atom from the editor's reported docs. // atom from the editor's reported ``docs``. The editor is the
// // single source of truth, so this catches every Plate deletion path
// The editor is the source of truth for which chips exist on // (Backspace, X button, Cmd+Backspace, range-delete, cut,
// screen. Reconciling here covers every deletion path Plate can // paste-over) without per-keybinding plumbing. The ``prev``
// produce (the explicit Backspace handler, the X-button, // short-circuit keeps pure-text keystrokes from churning the atom.
// Cmd+Backspace, range-select+Delete, cut, paste-over) without
// needing per-keybinding plumbing. Without this, paths that bypass
// ``onDocumentRemove`` left the atom carrying stale entries that
// the picker would re-emit via ``initialSelectedDocuments`` and
// resurface as chips on the next selection.
//
// The setter returns ``prev`` when the chip set is unchanged so
// pure-text keystrokes don't churn the atom (Jotai compares by
// reference for store change notifications).
const handleEditorChange = useCallback( const handleEditorChange = useCallback(
(text: string, docs: MentionedDocument[]) => { (text: string, docs: MentionedDocument[]) => {
aui.composer().setText(text); aui.composer().setText(text);
@ -498,11 +453,9 @@ const Composer: FC = () => {
return docs.map<MentionedDocumentInfo>((d) => ({ return docs.map<MentionedDocumentInfo>((d) => ({
id: d.id, id: d.id,
title: d.title, title: d.title,
// ``MentionedDocument.document_type`` is optional but // Atom requires a string; ``"UNKNOWN"`` matches the
// the atom shape requires a string. ``"UNKNOWN"`` is // sentinel ``getMentionDocKey`` and the editor's
// the same sentinel ``getMentionDocKey`` and the // match predicates use.
// editor's match predicates already use, so the key
// is stable across the round trip.
document_type: d.document_type ?? "UNKNOWN", document_type: d.document_type ?? "UNKNOWN",
kind: d.kind, kind: d.kind,
})); }));
@ -511,13 +464,11 @@ const Composer: FC = () => {
[aui, setMentionedDocuments] [aui, setMentionedDocuments]
); );
// Open document picker when @ mention is triggered
const handleMentionTrigger = useCallback((query: string) => { const handleMentionTrigger = useCallback((query: string) => {
setShowDocumentPopover(true); setShowDocumentPopover(true);
setMentionQuery(query); setMentionQuery(query);
}, []); }, []);
// Close document picker and reset query
const handleMentionClose = useCallback(() => { const handleMentionClose = useCallback(() => {
if (showDocumentPopover) { if (showDocumentPopover) {
setShowDocumentPopover(false); setShowDocumentPopover(false);
@ -525,13 +476,11 @@ const Composer: FC = () => {
} }
}, [showDocumentPopover]); }, [showDocumentPopover]);
// Open action picker when / is triggered
const handleActionTrigger = useCallback((query: string) => { const handleActionTrigger = useCallback((query: string) => {
setShowPromptPicker(true); setShowPromptPicker(true);
setActionQuery(query); setActionQuery(query);
}, []); }, []);
// Close action picker and reset query
const handleActionClose = useCallback(() => { const handleActionClose = useCallback(() => {
if (showPromptPicker) { if (showPromptPicker) {
setShowPromptPicker(false); setShowPromptPicker(false);
@ -575,7 +524,7 @@ const Composer: FC = () => {
[clipboardInitialText, electronAPI, aui] [clipboardInitialText, electronAPI, aui]
); );
// Keyboard navigation for document/action picker (arrow keys, Enter, Escape) // Arrow / Enter / Escape navigation for the active picker.
const handleKeyDown = useCallback( const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => { (e: React.KeyboardEvent) => {
if (showPromptPicker) { if (showPromptPicker) {
@ -656,7 +605,7 @@ const Composer: FC = () => {
(docId: number, docType?: string) => { (docId: number, docType?: string) => {
setMentionedDocuments((prev) => { setMentionedDocuments((prev) => {
if (!docType) { 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); return prev.filter((doc) => doc.id !== docId);
} }
const removedKey = getMentionDocKey({ id: docId, document_type: docType }); const removedKey = getMentionDocKey({ id: docId, document_type: docType });
@ -674,16 +623,12 @@ const Composer: FC = () => {
const key = getMentionDocKey(mention); const key = getMentionDocKey(mention);
if (editorDocKeys.has(key)) continue; if (editorDocKeys.has(key)) continue;
editorRef.current?.insertMentionChip(mention); editorRef.current?.insertMentionChip(mention);
// Track within the loop so duplicates in the same batch // Track within the loop so a duplicate-in-batch can't double-insert.
// (defensive — the picker shouldn't produce them today)
// can't slip through as double-inserted chips.
editorDocKeys.add(key); editorDocKeys.add(key);
} }
// Atom is reconciled by the editor's ``onChange`` after each // Atom is reconciled by ``handleEditorChange`` via the editor's
// ``insertMentionChip`` (see ``handleEditorChange``); writing // onChange — no second write path here.
// here would be a second, divergent write path — exactly the
// shape that let stale entries resurface in the past.
setMentionQuery(""); setMentionQuery("");
}, []); }, []);
@ -1315,12 +1260,7 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
); );
}; };
/** /** Friendly tool name (delegates to ``getToolDisplayName``). */
* Friendly tool name for display in the chat UI. Delegates to the
* shared map in ``contracts/enums/toolIcons`` so unix-style identifiers
* (``rm``, ``ls``, ``grep`` ) and snake_cased function names render as
* plain English (e.g. "Delete file", "List files", "Search in files").
*/
function formatToolName(name: string): string { function formatToolName(name: string): string {
return getToolDisplayName(name); return getToolDisplayName(name);
} }