diff --git a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx index da5fe878f..cbd8e8d5a 100644 --- a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx +++ b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx @@ -36,13 +36,9 @@ export interface MentionedDocument { } /** - * Input shape for inserting a chip. ``kind`` defaults to ``"doc"`` - * when omitted so legacy callers don't have to thread the - * discriminator. Folder callers pass ``kind: "folder"`` and the - * 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. + * Input shape for inserting a chip. ``kind`` defaults to ``"doc"``. + * Folder chips default ``document_type`` to ``FOLDER_MENTION_DOCUMENT_TYPE`` + * so the dedup key never collides with a doc chip sharing the same id. */ export type MentionChipInput = { id: number; @@ -58,10 +54,7 @@ export interface InlineMentionEditorRef { getText: () => string; getMentionedDocuments: () => MentionedDocument[]; insertMentionChip: (mention: MentionChipInput, options?: { removeTriggerText?: boolean }) => void; - /** - * @deprecated Use ``insertMentionChip``. Kept for one transition - * cycle so we don't break ad-hoc callers; prefer the new name. - */ + /** @deprecated Use ``insertMentionChip``. */ insertDocumentChip: ( doc: Pick, options?: { removeTriggerText?: boolean } @@ -97,12 +90,7 @@ type MentionElementNode = { id: number; title: string; document_type?: string; - /** - * 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. - */ + /** Discriminator; defaults to ``"doc"`` for legacy nodes. */ kind?: MentionKind; statusLabel?: string | null; statusKind?: MentionStatusKind; @@ -122,11 +110,8 @@ const COMPOSER_TEXT_METRICS_CLASSNAME = "text-sm leading-6"; const EMPTY_VALUE: ComposerValue = [{ type: "p", children: [{ text: "" }] }]; /** - * Internal seam that lets ``MentionElement`` (a Plate render component - * with no React props beyond ``element``) reach the editor's chip-removal - * 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. + * Lets ``MentionElement`` reach the editor's chip-removal helper so + * the X button and Backspace go through the same call site. */ type MentionEditorContextValue = { removeChip: (docId: number, docType: string | undefined) => void; @@ -336,13 +321,8 @@ export const InlineMentionEditor = forwardRef { try { editor.tf.select(editor.api.end([])); @@ -398,22 +378,13 @@ export const InlineMentionEditor = forwardRef { if (typeof mention.id !== "number" || typeof mention.title !== "string") return; @@ -434,16 +405,12 @@ export const InlineMentionEditor = forwardRef { const selection = editor.selection; - // No active editor selection — typically because focus - // moved to a picker/dropdown. Snap the caret to the end - // of the document so the chip appends cleanly instead - // of disappearing into a dead range. + // No active selection (focus moved to a picker) — snap + // to end-of-doc so the chip appends cleanly. if (!selection) { editor.tf.select(editor.api.end([])); } else if (removeTriggerText) { - // Delete the in-progress "@query" text so the chip - // stands in for it. Mirrors the old splice but lets - // Slate keep selection sane through the edit. + // Delete the in-progress "@query" so the chip stands in for it. const cursorCtx = getCursorTextContext(getCurrentValue(), selection); if (cursorCtx) { const text = cursorCtx.text; @@ -476,9 +443,7 @@ export const InlineMentionEditor = forwardRef, @@ -489,15 +454,10 @@ export const InlineMentionEditor = forwardRef { const match = (n: unknown) => { @@ -519,10 +479,8 @@ export const InlineMentionEditor = forwardRef { removeDocumentChip(docId, docType); @@ -531,11 +489,8 @@ export const InlineMentionEditor = forwardRef { setValue(EMPTY_VALUE); - // ``tf.setValue`` (inside ``setValue``) wipes the editor's - // selection — without this, after the user presses Enter to - // submit, the composer is left with no caret and they would - // have to click before typing again. + // ``tf.setValue`` wipes the selection — refocus so the caret + // returns after Enter-to-submit. requestAnimationFrame(focusAtEnd); }, [focusAtEnd, setValue]); @@ -588,12 +541,8 @@ export const InlineMentionEditor = forwardRef ({ - // If we already have a Plate selection (user was typing - // before focus left), preserve it — just refocus. If we - // 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. + // Preserve existing selection if any; otherwise seed one + // at end-of-doc so the contentEditable shows a caret. focus: () => { try { if (!editor.selection) { diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index 64069870b..420f64fb3 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -172,36 +172,24 @@ const PremiumQuotaPinnedAlert: FC = () => { const getTimeBasedGreeting = (user?: { display_name?: string | null; email?: string }): string => { const hour = new Date().getHours(); - // Extract first name: prefer display_name, fall back to email extraction let firstName: string | null = null; - 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+/); firstName = nameParts[0].charAt(0).toUpperCase() + nameParts[0].slice(1).toLowerCase(); } else if (user?.email) { - // Fall back to email extraction if display_name is not available firstName = user.email.split("@")[0].split(".")[0].charAt(0).toUpperCase() + 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 afternoonGreetings = ["Good afternoon", "Afternoon", "Hey there", "Hi there"]; - const eveningGreetings = ["Good evening", "Evening", "Hey there", "Hi there"]; - const nightGreetings = ["Good night", "Evening", "Hey there", "Winding down"]; - const lateNightGreetings = ["Still up", "Night owl mode", "Up past bedtime", "Hi there"]; - // Select a random greeting based on time let greeting: string; if (hour < 5) { - // Late night: midnight to 5 AM greeting = lateNightGreetings[Math.floor(Math.random() * lateNightGreetings.length)]; } else if (hour < 12) { 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) { greeting = eveningGreetings[Math.floor(Math.random() * eveningGreetings.length)]; } else { - // Night: 10 PM to midnight greeting = nightGreetings[Math.floor(Math.random() * nightGreetings.length)]; } - // Add personalization with first name if available - if (firstName) { - return `${greeting}, ${firstName}!`; - } - - return `${greeting}!`; + return firstName ? `${greeting}, ${firstName}!` : `${greeting}!`; }; const ThreadWelcome: FC = () => { 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]); return (
- {/* Greeting positioned above the composer */}

{greeting}

- {/* Composer - top edge fixed, expands downward only */}
@@ -373,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); @@ -385,9 +362,8 @@ const Composer: FC = () => { const promptPickerRef = useRef(null); const { search_space_id, chat_id } = useParams(); const aui = useAui(); - // Gate the always-focused composer behaviour to desktop. On mobile, - // programmatic focus pops the soft keyboard, which would be jarring - // whenever a picker closes or the user navigates between threads. + // 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(); @@ -408,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(() => { @@ -422,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-")) @@ -441,18 +414,9 @@ const Composer: FC = () => { ); useBatchCommentsPreload(assistantDbMessageIds); - // Always-focused composer (Claude-style). Runs as a reactive - // invariant: whenever the composer is mounted and no transient - // picker has taken over keyboard input, the editor should be the - // 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). + // 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 (!isDesktop) return; if (showDocumentPopover || showPromptPicker) return; @@ -460,7 +424,7 @@ const Composer: FC = () => { 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); @@ -470,21 +434,12 @@ const Composer: FC = () => { return () => window.removeEventListener(SLIDEOUT_PANEL_OPENED_EVENT, handler); }, []); - // Sync editor text with the assistant-ui composer runtime and - // reconcile the chip atom from the editor's reported docs. - // - // The editor is the source of truth for which chips exist on - // screen. Reconciling here covers every deletion path Plate can - // produce (the explicit Backspace handler, the X-button, - // 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). + // 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, docs: MentionedDocument[]) => { aui.composer().setText(text); @@ -498,11 +453,9 @@ const Composer: FC = () => { return docs.map((d) => ({ id: d.id, title: d.title, - // ``MentionedDocument.document_type`` is optional but - // the atom shape requires a string. ``"UNKNOWN"`` is - // the same sentinel ``getMentionDocKey`` and the - // editor's match predicates already use, so the key - // is stable across the round trip. + // 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, })); @@ -511,13 +464,11 @@ const Composer: FC = () => { [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); @@ -525,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); @@ -575,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) { @@ -656,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 }); @@ -674,16 +623,12 @@ const Composer: FC = () => { const key = getMentionDocKey(mention); if (editorDocKeys.has(key)) continue; editorRef.current?.insertMentionChip(mention); - // Track within the loop so duplicates in the same batch - // (defensive — the picker shouldn't produce them today) - // can't slip through as double-inserted chips. + // Track within the loop so a duplicate-in-batch can't double-insert. editorDocKeys.add(key); } - // Atom is reconciled by the editor's ``onChange`` after each - // ``insertMentionChip`` (see ``handleEditorChange``); writing - // here would be a second, divergent write path — exactly the - // shape that let stale entries resurface in the past. + // Atom is reconciled by ``handleEditorChange`` via the editor's + // onChange — no second write path here. setMentionQuery(""); }, []); @@ -1315,12 +1260,7 @@ const ComposerAction: FC = ({ isBlockedByOtherUser = false ); }; -/** - * 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"). - */ +/** Friendly tool name (delegates to ``getToolDisplayName``). */ function formatToolName(name: string): string { return getToolDisplayName(name); }