Files
multica/packages/views/chat/components/chat-input.tsx
Naiyuan Qing c52ebeaa0f MUL-4864 fix(chat): use a single New Chat draft, keyed by session not agent (#5519)
* fix(chat): use a single New Chat draft, keyed by session not agent (MUL-4864)

An uncreated chat kept one draft per agent (`__new__:<agentId>`), so
switching the agent picker mid-compose swapped the draft out from under
the user and left invisible per-agent drafts behind. That slot shape was
never a product decision: it fell out of 34e452776, which keyed the
editor by agent to remount it and refresh the Tiptap placeholder. The
placeholder now syncs live, so the motive is gone — and mobile already
implements the target contract.

An uncreated chat now has ONE draft slot per workspace. `selectedAgentId`
is the send target, not draft ownership. Created sessions keep their own
per-session slots, so conversations stay isolated.

Three connected changes this needs to actually hold:

- Editor identity no longer tracks the agent. Keying it by agent kept the
  draft slot correct but still remounted on switch, dropping whatever the
  100ms draft debounce had not yet persisted — the last thing typed.
- The post-send "scrub the composer?" rule reads `activeSessionId` alone.
  Moving the picker is no longer navigation, so counting it as such left
  a completed send's text in the composer, primed to be sent a second
  time to the agent just picked. Both send chains (the chat tab's
  controller and the floating ChatWindow) now share one exported rule
  rather than two copies of the predicate.
- Legacy `__new__:<agentId>` slots are folded on load. They carry no
  timestamp, so when several exist none can be shown to be newest: adopt
  the persisted `selectedAgentId`'s slot (the draft the workspace would
  have opened with) and drop the rest — those extras are the invisible
  multi-draft state this removes. Text and attachments migrate together
  so a draft can't end up with another agent's files.

Tests cover the acceptance scenarios: agent switch preserves text,
attachments and the live editor instance; sessions stay isolated; the
first send clears the draft and routes to the agent selected at send
time; a failed create keeps the input; and the migration adopts, prunes,
persists, and is idempotent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(chat): file unflushed keystrokes under the draft they were typed in (MUL-4864)

Review blocker: one editor instance serves every chat draft and its
onUpdate is debounced 100ms. Typing in session A and switching to B
inside that window fires the timer with B's draftKey in scope — because
onUpdate always resolves to the latest render's closure — writing A's
document into B's draft. Worse, ContentEditor's dirty guard suppresses
B's incoming sync while those unflushed bytes are live, so B's own draft
never loads and A's context could be sent to B's agent.

This predates the per-agent draft change: on main `editorKey` is
`selectedAgentId`, so any two sessions of the SAME agent already shared
one editor instance and already cross-wrote. Verified by running the new
regression against main's editor identity with the flush removed — it
fails there too. Unifying the New Chat draft widened the same hazard to
cross-agent switches, so it is fixed here rather than left latent.

Fix at the root: a debounced write must land on the key it was typed
under, not wherever the composer now points.

- ContentEditor gains `flushPendingUpdate()`: cancels the armed debounce
  and hands its markdown back rather than firing it, and advances the
  emit watermark so the editor reads clean and the dirty guard stops
  blocking the incoming sync. Distinct from flushPendingOnUnmount — the
  instance is alive, so it reads the live document.
- ChatInput flushes on draftKey change and commits the result to the
  PREVIOUS key. useLayoutEffect, not useEffect: passive effects run
  child-first, so a passive flush would land after ContentEditor's sync
  had already skipped on a dirty editor; a layout effect is part of the
  commit, so no pending timer can fire ahead of it.
- onUpdate and the flush now share one `commitDraft`, so text and
  attachment pruning cannot diverge between the two paths.

Tests exercise the REAL debounce and REAL dirty guard, not an instant
mock onUpdate:

- chat-input-draft-isolation.test.tsx (new): mounts the real
  ContentEditor with only Tiptap's primitive mocked. Pins A/B isolation
  across a mid-debounce switch, that B's draft actually loads, that a
  lazy session create keeps its bytes in the New Chat slot, and that an
  agent switch still preserves the New Chat draft. Three of four fail
  without the flush.
- content-editor.test.tsx: flushPendingUpdate hands back the pending
  markdown, kills the timer, unblocks the next sync, and leaves the
  guard intact when NOT flushed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(chat): keep an in-flight upload bound to the draft it started in (MUL-4864)

Second review blocker. An upload PINS the editor to its source document:
Guard 0 refuses to setContent over an `uploading` node, because wiping it
strands the upload's finalize and the file silently vanishes. So while an
upload runs, the instance still holds A's document even though the user
has navigated to B — and the upload's own completion dispatch fires
onUpdate on that instance, which resolves to the latest render's closure.
Result: A's body and its attachment URL were written into B's draft, B's
own draft was destroyed, and A lost the upload it was waiting for.

Nothing stopped this: useUploadGate gates submit, not session/agent
navigation. Reproduced as a test before fixing — B's "B's own words" was
replaced by A's body + A's CDN URL.

Root cause is the same shape as the debounce bug, one level up: the
editor's writes were keyed off what is SELECTED, when they belong to what
is LOADED. Those are the same key except while an upload pins the
document.

- chat-input models that explicitly with `editorDraftKeyRef` — the draft
  whose document the instance holds. Every editor-driven write (onUpdate,
  the upload's attachment binding) uses it, not `draftKey`.
- The draft switch defers while `hasActiveUploads()`, leaving both the
  document and its writes on the source key, then completes when the gate
  clears. Blocking navigation instead would make the user wait on a
  network round-trip to change tabs.
- The deferred case must force the adopt: ContentEditor's sync effect
  CONSUMED the defaultValue change while Guard 0 was up
  (lastDefaultValueRef advances before the guard), so it never re-runs and
  B's draft would never load. New `adoptContent()` ref method lands it;
  its body is the sync effect's own apply path, extracted so both agree.
- handleSend refuses while loaded !== selected. The upload gate covers
  most of that window but not the sliver between the upload's final
  dispatch and the adopt re-render — React flushes that on a scheduler
  task, so Mod+Enter can land first.

Tests (real ContentEditor, real Guard 0, real debounce, real upload
lifecycle incl. the transaction that publishes the queue flip):
completing upload's markdown stays in the source draft and never reaches
the target; an attachment dropped while pinned binds to the source; the
target draft loads once the guard clears; send is refused mid-divergence.
Each verified to fail without its fix — the send guard initially passed
vacuously (the disabled SubmitButton swallowed the click) and now drives
the real Mod+Enter path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-16 15:56:55 +08:00

580 lines
26 KiB
TypeScript

"use client";
import type { ReactNode } from "react";
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "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 } from "@multica/core/types";
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<void | boolean>;
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<UploadResult | null>;
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[];
/** 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,
focusRequest,
draftKeyOverride,
editorKeyOverride,
}: ChatInputProps) {
const { t } = useT("chat");
const { t: tEditor } = useT("editor");
const sendShortcut = useShortcut("send");
const editorRef = useRef<ContentEditorRef>(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 defaultValue-sync 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
// defaultValue-sync 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<string | null>(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 defaultValue 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 defaultValue change while Guard 0 was up (lastDefaultValueRef
// 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<Map<string, string>>(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<UploadResult | null> => {
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 <id>` 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;
return (
<div
className={cn(
"px-5 pb-3 pt-0",
// Outer wrapper carries the disabled cursor. Inner card sets
// pointer-events-none, which suppresses hover (and therefore
// any cursor of its own) — splitting the two layers lets hover
// bubble back here so the browser actually reads cursor.
noAgent && "cursor-not-allowed",
)}
>
<div
{...(uploadEnabled ? dropZoneProps : {})}
className={cn(
"relative mx-auto flex min-h-16 max-h-40 w-full max-w-4xl flex-col rounded-lg border border-surface-border bg-surface pb-9 transition-[border-color,box-shadow] focus-within:border-brand focus-within:ring-2 focus-within:ring-ring/20",
// Visual + interaction lock when there's no agent. We don't
// toggle ContentEditor's editable mode (Tiptap can't switch
// cleanly post-mount, and the prop has been removed); instead
// we drop pointer events at the wrapper level so clicks miss
// the editor entirely, and dim the surface so it reads as
// "disabled" rather than "broken".
noAgent && "pointer-events-none opacity-60",
)}
aria-disabled={noAgent || undefined}
>
<div className="flex-1 min-h-0 overflow-y-auto px-3 py-2">
<ContentEditor
// See the editorKey / draftKey split note above — editor identity
// intentionally tracks neither the session nor the agent.
key={editorKey}
ref={editorRef}
defaultValue={inputDraft}
placeholder={placeholder}
onUpdate={(md) => {
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
// Chat is short-form — the floating formatting toolbar is
// more distraction than feature here.
showBubbleMenu={false}
/>
</div>
{(uploadEnabled || leftAdornment) && (
<div className="absolute bottom-1.5 left-1.5 flex items-center gap-1">
{uploadEnabled && (
<ChatAddMenu
onSelectFile={(file) => editorRef.current?.uploadFile(file)}
/>
)}
{leftAdornment}
</div>
)}
<div className="absolute bottom-1 right-1.5 flex items-center gap-1">
<SubmitButton
onClick={handleSend}
disabled={hasNothingToSend || isSubmitting || !!disabled || !!noAgent}
loading={isSubmitting}
busy={uploadGate.uploading}
running={isRunning}
onStop={onStop}
tooltip={uploadGate.uploading
? tEditor(($) => $.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)}
/>
</div>
{uploadEnabled && isDragOver && <FileDropOverlay />}
</div>
</div>
);
}