"use client"; import type { ReactNode } from "react"; import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { TriangleAlert } from "lucide-react"; import { cn } from "@multica/ui/lib/utils"; import { ContentEditor, type ContentEditorRef, useFileDropZone, FileDropOverlay, useUploadGate, } from "../../editor"; import { SubmitButton } from "@multica/ui/components/common/submit-button"; import { ChatAddMenu } from "./chat-add-menu"; import { useChatStore, DRAFT_NEW_SESSION } from "@multica/core/chat"; import { createLogger } from "@multica/core/logger"; import { formatShortcut, useShortcut } from "@multica/core/shortcuts"; import type { UploadResult } from "@multica/core/hooks/use-file-upload"; import type { MentionItem } from "../../editor/extensions/mention-suggestion"; import type { Attachment, Project } from "@multica/core/types"; import { ProjectPicker } from "../../projects/components/project-picker"; import { useT } from "../../i18n"; const logger = createLogger("chat.ui"); const EMPTY_ATTACHMENTS: Attachment[] = []; /** Editor identity for the chat composer — see the editorKey note below. */ const CHAT_COMPOSER_EDITOR_KEY = "chat-composer"; function attachmentReferenceUrls(attachment: Attachment): string[] { const withUploadFields = attachment as Attachment & { markdownLink?: string; link?: string; }; return [ withUploadFields.markdownLink, attachment.markdown_url, attachment.download_url, attachment.url, withUploadFields.link, attachment.id ? `/api/attachments/${attachment.id}/download` : "", ].filter((url): url is string => !!url); } function isAttachmentReferenced(content: string, attachment: Attachment): boolean { return attachmentReferenceUrls(attachment).some((url) => content.includes(url)); } interface ChatInputProps { onSend: ( content: string, attachmentIds: string[] | undefined, commitInput: (options?: { extraDraftKeys?: string[]; clearEditor?: boolean }) => void, draftAttachments: Attachment[], ) => void | boolean | Promise; restoreDraftRequest?: { id: string; content: string; attachments?: Attachment[]; /** * Draft slot this restore targets. When set, the restore only fires while * the user is viewing that session — a fire-and-forget send that later * fails restores into the session it was sent from, not whatever the user * navigated to. Omit to restore into the current draft (legacy behavior). */ sessionId?: string; } | null; /** * Fired when — and only when — the restore's content/attachments were written * into the draft. A restore the composer cannot apply yet (the user has work * in progress) is NOT reported: it stays pending and lands as soon as the * draft is clear. Owners holding a durable server-side restore (#5219) can * therefore treat this as the single terminal transition and consume the row. */ onRestoreDraftApplied?: () => void; /** Receives a File and returns the attachment row (with id + CDN link). * The wrapper owner (ChatWindow) lazy-creates a chat_session if needed * and forwards `chatSessionId` to the upload — chat-input only cares * about the upload result so it can map URL → id for back-fill on send. * When unset, paste/drag/button still type into the editor but no upload * fires (the editor's file-upload extension is a no-op without a handler). */ onUploadFile?: (file: File) => Promise; onStop?: () => void; isRunning?: boolean; disabled?: boolean; /** True when the user has no agent available — disables the editor and * surfaces a distinct placeholder. Kept separate from `disabled` so * archived-session copy stays untouched. */ noAgent?: boolean; /** True when `disabled` is because the bound agent was archived (retired), * as opposed to the session itself being archived — swaps the placeholder * copy so the read-only reason reads accurately. */ agentArchived?: boolean; /** Name of the currently selected agent, used in the placeholder. */ agentName?: string; /** Rendered at the bottom-left of the input bar — typically the agent picker. */ leftAdornment?: ReactNode; /** Chat @ suggestions: current/recent issue/project entries. */ contextItems?: MentionItem[]; /** Optional project context for the draft or current chat session. */ projects?: Project[]; projectId?: string | null; onProjectChange?: (projectId: string | null) => void; isProjectUpdating?: boolean; /** True when the active agent's daemon is too old to inject the project * description into the run brief. Soft signal: selection stays enabled, * the composer only surfaces a warning next to the chip and in the * project submenu. */ projectContextUnsupported?: boolean; /** Monotonic nonce bumped by the owner whenever the compose box should grab * keyboard focus — currently on "new chat" so the user can type right away. * 0 (the initial value) is inert, so a plain deep-link open never steals * focus; only an explicit bump does. */ focusRequest?: number; /** * Optional storage/identity isolation for embedded chat surfaces that use * the shared composer without participating in the global chat selection * store (for example Agent Builder). */ draftKeyOverride?: string; editorKeyOverride?: string; } export function ChatInput({ onSend, restoreDraftRequest, onRestoreDraftApplied, onUploadFile, onStop, isRunning, disabled, noAgent, agentArchived, agentName, leftAdornment, contextItems, projects = [], projectId, onProjectChange, isProjectUpdating, projectContextUnsupported, focusRequest, draftKeyOverride, editorKeyOverride, }: ChatInputProps) { const { t } = useT("chat"); const { t: tEditor } = useT("editor"); const sendShortcut = useShortcut("send"); const editorRef = useRef(null); const activeSessionId = useChatStore((s) => s.activeSessionId); // Two keys with deliberately different concerns: // // `draftKey` — zustand storage key. Scopes the in-progress draft per session // so different sessions don't bleed text into each other. An uncreated chat // uses ONE slot per workspace, deliberately NOT keyed by agent: the composer // is "the chat I have not created yet", and `selectedAgentId` only decides // where the first send goes (MUL-4864). This is a STORAGE key, not a React // identity. // // `editorKey` — React `key` on the ContentEditor, i.e. editor identity. It is // constant for the chat composer, because nothing about switching what you // are composing to should throw away the instance you are typing in: // - Agent switch: same draft slot now, so a remount would only serve to // drop the last <100ms of typing the draft debounce has not persisted. // - Placeholder: ContentEditor's placeholder-sync effect refreshes it live, // so it never needed a remount. // - Draft restore (a cancelled run, a failed send): writes into // `inputDraft`, and the editor's synchronized-value effect pushes it into // the live instance. There is no second copy to drift or resurface. // - Session switch / lazy create: when the user uploads a file in a // brand-new chat, `handleUploadFile` awaits `ensureSession`, which flips // `activeSessionId` from null → uuid mid-upload. A session-keyed editor // would unmount right as the blob preview landed, dropping the image node // before file-upload.ts could swap in the CDN URL — the user would watch // the image flash on and vanish. Stable identity is what makes // first-upload-creates-session behave like every later upload. // Embedded surfaces (Agent Builder) still pass `editorKeyOverride` to isolate // their own composer. const draftKey = draftKeyOverride ?? activeSessionId ?? DRAFT_NEW_SESSION; // Select a primitive — empty-string fallback keeps referential stability. const inputDraft = useChatStore((s) => s.inputDrafts[draftKey] ?? ""); const draftAttachments = useChatStore( (s) => s.inputDraftAttachments[draftKey] ?? EMPTY_ATTACHMENTS, ); const setInputDraft = useChatStore((s) => s.setInputDraft); const setInputDraftAttachments = useChatStore((s) => s.setInputDraftAttachments); const addInputDraftAttachment = useChatStore((s) => s.addInputDraftAttachment); const clearInputDraft = useChatStore((s) => s.clearInputDraft); const [isEmpty, setIsEmpty] = useState(!inputDraft.trim()); const [isSubmitting, setIsSubmitting] = useState(false); // `isEmpty` tracks the LIVE editor, which the persisted draft lags by a // debounce, so the send affordance cannot be derived from `inputDraft` alone. // But `isEmpty` is never re-derived when the composer switches draft slots // either: ChatInput does not remount on a session switch, and ContentEditor's // synchronized-value effect pushes the incoming draft in with `emitUpdate: false`, so // no onUpdate fires. Read BOTH signals — a draft `isEmpty` has not seen yet (a // restored or parked one, or any persisted draft the user typed in another // session) still enables the button. A false enable costs nothing: handleSend // reads the live editor and bails when it is empty. const hasNothingToSend = isEmpty && !inputDraft.trim(); const appliedRestoreIdRef = useRef(null); const editorKey = editorKeyOverride ?? CHAT_COMPOSER_EDITOR_KEY; // The draft whose document the editor instance is currently HOLDING. // // Normally identical to `draftKey`. The two diverge only while an in-flight // upload pins the instance to the source document: ContentEditor's Guard 0 // refuses to `setContent` over an `uploading` node, because wiping that node // strands the upload's finalize and the file silently disappears. Until the // upload settles the composer still shows — and still edits — the source // draft, so every byte the instance produces belongs to THIS key, not to // wherever the user has since navigated. // // `draftKey` answers "what is selected"; this answers "what is loaded". Every // write the editor drives must use the latter. const editorDraftKeyRef = useRef(draftKey); // Write a document into a draft slot: text, plus a prune of the attachments // its body no longer references (deleting an image's markdown drops the // staged upload with it). Shared by the live `onUpdate` and the draft-switch // flush below, so a document can never be filed under one rule by one path // and a different rule by the other. Attachments are read live for `key` // rather than passed in — during a divergence the rendered `draftAttachments` // belongs to the selected draft, not the loaded one. const commitDraft = useCallback( (key: string, markdown: string) => { setInputDraft(key, markdown); const attachments = useChatStore.getState().inputDraftAttachments[key] ?? EMPTY_ATTACHMENTS; if (attachments.length === 0) return; const referenced = attachments.filter((attachment) => isAttachmentReferenced(markdown, attachment), ); if (referenced.length !== attachments.length) { setInputDraftAttachments(key, referenced); } }, [setInputDraft, setInputDraftAttachments], ); // Submit gate. `uploading` disables the SubmitButton the instant an upload // starts; `isBlocked()` is re-read inside handleSend for the paths that skip // the button entirely (Mod+Enter mid-paste, drag-drop racing the keyboard). // Both read the editor document, which is the actual upload queue — this // used to be a local in-flight counter that a manual delete of the pending // image would leave stuck (MUL-4808). const uploadGate = useUploadGate(editorRef); // Move the editor from the draft it holds to the draft that is selected. // // Two hazards make this more than "let the value sync handle it": // // 1. Unflushed keystrokes. `onUpdate` is debounced, and by the time a // debounce armed under draft A fires, `onUpdate` resolves to the latest // render's closure — it would file A's document under B. Flushing takes // those bytes back and commits them to the key they were typed in. // 2. An in-flight upload. Guard 0 pins the document (see editorDraftKeyRef), // so the switch cannot happen yet at all: we leave BOTH the document and // its writes on the source key and retry when the gate clears // (`uploadGate.uploading` is a dep). Blocking navigation instead would be // a worse trade — the user waits on a network round-trip to change tabs. // // Case 2 also has to force the adopt afterwards: ContentEditor's sync effect // CONSUMED the value change while Guard 0 was up (lastSyncedValueRef // advances before the guard), so it will never re-run for that value and the // target draft would never load on its own. // // useLayoutEffect, not useEffect, for two ordering reasons: passive effects // run child-first, so a passive flush here would land AFTER ContentEditor's // sync effect had already skipped on a dirty editor; and a layout effect is // part of the commit, so no pending debounce timer can fire ahead of it. const deferredAdoptRef = useRef(false); useLayoutEffect(() => { const loadedKey = editorDraftKeyRef.current; if (loadedKey === draftKey) return; if (editorRef.current?.hasActiveUploads() === true) { // Pinned. Stay bound to the source draft until the upload settles. deferredAdoptRef.current = true; logger.debug("input.draft switch deferred by in-flight upload", { from: loadedKey, to: draftKey, }); return; } const pending = editorRef.current?.flushPendingUpdate() ?? null; if (pending !== null) { logger.debug("input.draft flush on key change", { from: loadedKey, to: draftKey }); commitDraft(loadedKey, pending); } editorDraftKeyRef.current = draftKey; if (!deferredAdoptRef.current) return; deferredAdoptRef.current = false; const incoming = useChatStore.getState().inputDrafts[draftKey] ?? ""; logger.debug("input.draft adopting after upload settled", { key: draftKey }); editorRef.current?.adoptContent(incoming); setIsEmpty(!incoming.trim()); }, [draftKey, uploadGate.uploading, commitDraft]); // Maps "URL inserted into the editor" → "attachment row id" so that // on send we can ask the server to bind only the attachments still // referenced in the message body. Cleared after every send. Mirrors // the comment-input flow exactly. The map key MUST match what the // editor actually wrote into the markdown — that's `markdownLink` // (the stable per-attachment URL) for normal post-MUL-3130 uploads // and `link` (= att.url) for the no-workspace upload branch where // there's no attachment-row id to address. Storing only `link` here // would cause `content.includes(url)` to miss every new chat upload // because the editor persists `markdownLink` instead, and the // `onSend` call would silently drop `attachment_ids` so the // attachment never binds to the chat message. const uploadMapRef = useRef>(new Map()); // Grab keyboard focus when the owner bumps `focusRequest` (a new chat was // started) so the user can type immediately. The editor's `focus()` latches // through to `onCreate` when it isn't mounted yet, so this works even on the // first render of a freshly-mounted compose box. `0` is inert on purpose. useEffect(() => { if (!focusRequest) return; editorRef.current?.focus(); }, [focusRequest]); useEffect(() => { if (!restoreDraftRequest) { appliedRestoreIdRef.current = null; return; } if (appliedRestoreIdRef.current === restoreDraftRequest.id) return; // Session-scoped restore: if this draft belongs to a specific session, // wait until the user is actually viewing it. A fire-and-forget send that // failed after the user navigated away must not dump its content into the // session they're now looking at — the request stays pending until they // return to the source session (draftKey then matches). if (restoreDraftRequest.sessionId && restoreDraftRequest.sessionId !== draftKey) { return; } // A draft with text OR staged attachments is user work in progress — never // overwrite either with a restore (the attachment write below replaces the // whole staged list). This is a WAIT, not a decision: the request stays // pending and this effect re-runs on every draft change, so the restore // lands as soon as the user sends or clears what they were typing. Marking // it done here would strand it for the rest of this composer's life. if (inputDraft.trim() || draftAttachments.length > 0) { logger.debug("input.restore waiting: draft has content", { draftKey, restoreId: restoreDraftRequest.id, }); return; } appliedRestoreIdRef.current = restoreDraftRequest.id; setInputDraft(draftKey, restoreDraftRequest.content); setInputDraftAttachments(draftKey, restoreDraftRequest.attachments ?? []); setIsEmpty(!restoreDraftRequest.content.trim()); onRestoreDraftApplied?.(); }, [ draftKey, inputDraft, draftAttachments, onRestoreDraftApplied, restoreDraftRequest, setInputDraft, setInputDraftAttachments, ]); const handleUpload = useCallback( async (file: File): Promise => { if (!onUploadFile) return null; const result = await onUploadFile(file); if (result) { const persistedURL = result.markdownLink || result.link; uploadMapRef.current.set(persistedURL, result.id); // Bind to the draft this file is landing IN. The editor inserts the // node into whatever document it currently holds, so while an earlier // upload pins it to the source draft, that — not the selected draft — // is where the body lives. Filing the row anywhere else splits a // message from its own attachment. if (result.id) addInputDraftAttachment(editorDraftKeyRef.current, result); } return result; }, [addInputDraftAttachment, onUploadFile], ); // Drop zone wraps the rounded card so a drop anywhere on the input // surface routes the file through the editor's upload extension (same // handler as the in-editor paste path). const { isDragOver, dropZoneProps } = useFileDropZone({ onDrop: (files) => files.forEach((f) => editorRef.current?.uploadFile(f)), }); const handleSend = async () => { const content = editorRef.current?.getMarkdown()?.replace(/(\n\s*)+$/, "").trim(); if (!content || isRunning || isSubmitting || disabled || noAgent) { logger.debug("input.send skipped", { emptyContent: !content, isRunning, isSubmitting, disabled, noAgent, }); return; } // Block the send while any file is still uploading. If we let it // through the attachment id is not yet in uploadMapRef (the upload // resolves later) and the attachment would only end up bound to the // session, not the message — the agent then can't `multica attachment // download ` the file. The SubmitButton is also disabled in this // state via `uploading`, but Mod+Enter bypasses the button so we // still gate here. if (uploadGate.isBlocked()) { logger.debug("input.send skipped: uploads in flight"); return; } // The editor is still holding a DIFFERENT draft's document than the one // selected — an upload pinned it and the adopt below has not run yet. The // upload gate above covers most of that window, but not the sliver between // the upload's final dispatch (node gone) and the re-render that adopts: // React flushes that state update on a scheduler task, so a click can land // first. Sending here would post the source draft's text into the selected // session and then clear the wrong draft. if (editorDraftKeyRef.current !== draftKey) { logger.debug("input.send skipped: composer still holds another draft", { loaded: editorDraftKeyRef.current, selected: draftKey, }); return; } // Only send attachment IDs for uploads still present in the content. // Edits / deletions that remove the markdown URL also drop the binding. const activeIds: string[] = []; for (const [url, id] of uploadMapRef.current) { if (content.includes(url)) activeIds.push(id); } for (const attachment of draftAttachments) { if (isAttachmentReferenced(content, attachment)) activeIds.push(attachment.id); } const uniqueActiveIds = Array.from(new Set(activeIds)); // Capture draft key BEFORE onSend — creating a new session mutates // activeSessionId synchronously, so reading it after onSend would point // at the new session and leave the old draft orphaned. const keyAtSend = draftKey; let committed = false; const commitInput = (options?: { extraDraftKeys?: string[]; clearEditor?: boolean }) => { if (committed) return; committed = true; // `clearEditor === false` means the owner sent fire-and-forget while the // user had already navigated to another session. The editor instance is // shared across sessions, so it now shows (and the user may be typing // into) a DIFFERENT draft — clearing it or blurring would wipe that // visible input. Only scrub the editor when the user is still on the // session they sent from. if (options?.clearEditor !== false) { editorRef.current?.clearContent(); // Drop focus so the caret doesn't keep blinking under the StatusPill / // streaming reply that's about to take over the user's attention. The // input is also `disabled` once isRunning flips, and a focused-but- // disabled editor reads as a stale cursor. We deliberately don't auto- // refocus on completion — that would interrupt the user if they're // selecting text from the assistant reply; one click to refocus is // a fair price for not stealing focus mid-action. editorRef.current?.blur(); setIsEmpty(true); } // The sent draft's data is cleared regardless — the message is on its // way, so its persisted draft must not resurface. clearInputDraft(keyAtSend); for (const key of options?.extraDraftKeys ?? []) { if (key !== keyAtSend) clearInputDraft(key); } uploadMapRef.current.clear(); setIsSubmitting(false); }; logger.info("input.send", { contentLength: content.length, draftKey: keyAtSend, attachmentCount: uniqueActiveIds.length, }); setIsSubmitting(true); let accepted: void | boolean; try { accepted = await onSend( content, uniqueActiveIds.length > 0 ? uniqueActiveIds : undefined, commitInput, draftAttachments.filter((attachment) => uniqueActiveIds.includes(attachment.id)), ); } catch (err) { logger.warn("input.send failed", err); if (!committed) setIsSubmitting(false); return; } if (accepted === false) { if (!committed) setIsSubmitting(false); return; } if (!committed) commitInput(); }; const placeholder = noAgent ? t(($) => $.input.placeholder_no_agent) : disabled ? agentArchived ? t(($) => $.input.placeholder_archived_agent) : t(($) => $.input.placeholder_archived) : agentName ? t(($) => $.input.placeholder_named, { name: agentName }) : t(($) => $.input.placeholder_default); const uploadEnabled = !!onUploadFile && !disabled && !noAgent; // Lock only while the send request itself is creating/resolving the target // session. Once accepted, its project can still be detached while agent work // continues: changing session metadata does not cancel or move that task. const projectSelectionEnabled = !!onProjectChange && !disabled && !noAgent && !isSubmitting && !isProjectUpdating; const selectedProject = projects.find((project) => project.id === projectId); return (
{selectedProject && (
onProjectChange?.(updates.project_id ?? null)} disabled={!projectSelectionEnabled} triggerRender={
{projectContextUnsupported && ( {t(($) => $.input.project_context_unsupported)} )}
)}
{ setIsEmpty(!md.trim()); // The LOADED key, not the selected one: while an upload pins the // document this fires for the source draft's body — including the // upload's own completion dispatch. commitDraft(editorDraftKeyRef.current, md); }} onSubmit={handleSend} onUploadFile={uploadEnabled ? handleUpload : undefined} onUploadingChange={uploadGate.onUploadingChange} attachments={draftAttachments} debounceMs={100} mentionMode={contextItems ? "context" : "default"} mentionContextItems={contextItems} enableSlashCommands // The bubble menu carries the only affordance that can strip // formatting — "Normal text" (setParagraph) plus the mark/list // toggles. Once a `# ` input rule or a Markdown/HTML paste turns a // line into a heading, chat has no other way to remove it, so // without the bubble menu formatting can be created but never // undone (MUL-5106). showBubbleMenu />
{(uploadEnabled || projectSelectionEnabled || leftAdornment) && (
{(uploadEnabled || projectSelectionEnabled) && ( editorRef.current?.uploadFile(file) : undefined} projects={projects} projectId={projectId} onSelectProject={projectSelectionEnabled ? onProjectChange : undefined} projectContextUnsupported={projectContextUnsupported} /> )} {leftAdornment}
)}
$.upload.in_progress) : sendShortcut ? `${t(($) => $.input.send_tooltip)} · ${formatShortcut(sendShortcut)}` : t(($) => $.input.send_tooltip)} ariaLabel={uploadGate.uploading ? tEditor(($) => $.upload.in_progress) : t(($) => $.input.send_tooltip)} stopTooltip={t(($) => $.input.stop_tooltip)} stopAriaLabel={t(($) => $.input.stop_tooltip)} />
{uploadEnabled && isDragOver && }
); }