import { AgentAvatar } from "../AgentAvatar"; import { useEffect, useRef, useState, type ChangeEvent, type ClipboardEvent as ReactClipboardEvent, type ReactNode, } from "react"; import { cn } from "@/lib/utils"; import { useComposerStop } from "@/hooks/useComposerStop"; import { useStreamlinedTaskChatPresentation } from "./presentation-mode"; import { DRAFT_DEBOUNCE_MS, clearDraft, loadDraft, loadDraftAttachments, saveDraft, saveDraftAttachments, loadDraftSubmission, saveDraftSubmission, clearDraftSubmission, settleDraftSubmission, type ComposerDraftSubmission, } from "@/lib/composer-draft"; import { CommentSubmissionUnknownError } from "@/lib/comment-submit-result"; import { ArrowUp, Square, ChevronDown, CircleHelp, Loader2, X, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Attachment, AttachmentAction, AttachmentActions, AttachmentContent, AttachmentDescription, AttachmentGroup, AttachmentMedia, AttachmentTitle, } from "@/components/ui/attachment"; import { fileKindForName, formatFileSize } from "./task-chat-attachments"; import { MarkdownEditor, type MarkdownEditorRef, } from "@/components/MarkdownEditor"; import { nextWorkMode } from "@/lib/work-mode-meta"; import { InlineEntitySelector, type InlineEntityOption, } from "@/components/InlineEntitySelector"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import type { MentionOption } from "@/components/MarkdownEditor"; import type { IssueAttachment, IssueWorkMode } from "@paperclipai/shared"; import type { RunnerGoalCapability } from "@paperclipai/shared"; import type { ActionCommandOption } from "@/context/EditorAutocompleteContext"; import { TaskChatComposerTakeoverActionsContext } from "./TaskChatComposerTakeoverContext"; import { TaskChatPausedTakeover, type TaskComposerPause } from "./TaskChatPausedTakeover"; import { ComposerRunSettingsPicker } from "./ComposerRunSettingsPicker"; import { ComposerAddMenu, ComposerModeChip } from "./ComposerAddMenu"; import type { ComposerRunSettings } from "./composer-run-settings"; import type { Agent, IssueAssigneeAdapterOverrides } from "@paperclipai/shared"; /** Structurally identical to IssueChatThread's module-private CommentReassignment. */ export interface CommentReassignment { assigneeAgentId: string | null; assigneeUserId: string | null; } export interface TaskChatComposerTakeover { id: string; label: string; pendingCount: number; content: ReactNode; /** Hides the takeover without resolving it; the pending indicator can reopen it. */ onDismiss: () => void; onSkip: () => Promise | void; onShowNext?: () => void; /** Places Skip inside a structured question form's action row. */ inlineSkip?: boolean; hideLabel?: boolean; /** Some decision surfaces already provide a non-accept path of their own. */ hideSkip?: boolean; } interface TaskChatComposerProps { onAdd: ( body: string, reopen?: boolean, reassignment?: CommentReassignment, attachmentIds?: string[], clientRequestId?: string, runSettings?: ComposerRunSettings, ) => Promise | void; confirmedSubmissionIds?: ReadonlySet; onStop?: () => Promise; stopPending?: boolean; stopScope?: "leaf" | "subtree"; workMode: IssueWorkMode; onWorkModeChange?: (mode: IssueWorkMode) => Promise | void; disabled?: boolean; disabledReason?: string | null; placeholder?: string; /** Preferred upload path: attaches the file to the task (mirrors legacy). */ onAttachImage?: (file: File) => Promise; /** Fallback upload path: returns a URL for inline image markdown. */ onImageUpload?: (file: File) => Promise; /** Mentionable entities for the editor's @-autocomplete. */ mentions?: MentionOption[]; enableReassign?: boolean; conversationMode?: boolean; reassignOptions?: InlineEntityOption[]; agentMap?: ReadonlyMap; modelAgents?: ReadonlyMap; companyId?: string | null; assigneeAdapterOverrides?: IssueAssigneeAdapterOverrides | null; userProfileMap?: ReadonlyMap< string, { label: string; image: string | null } > | null; currentAssigneeValue?: string; onPendingAssigneeChange?: (value: string | null) => void; issueStatus?: string; /** Mobile document-flow host: 16px editor text so iOS doesn't zoom on focus. */ mobile?: boolean; /** Storage key used to restore, persist, and clear this task's text draft. */ draftKey?: string; onReviewConversation?: () => Promise; /** When set, the main composer temporarily edits this queued message. */ queuedEdit?: { commentId: string; body: string; stale?: boolean } | null; onSaveQueuedEdit?: (commentId: string, body: string) => Promise; onCancelQueuedEdit?: () => void; pause?: TaskComposerPause | null; takeover?: TaskChatComposerTakeover | null; pendingTakeover?: { count: number; label?: string; onOpen: () => void; } | null; runnerGoalCapability?: RunnerGoalCapability | null; onRunnerGoalCommand?: ( command: RunnerGoalComposerCommand, ) => Promise | void; onRunnerGoalReassign?: ( reassignment: CommentReassignment, ) => Promise | void; } export type RunnerGoalComposerCommand = | { action: "focus" } | { action: "create"; objective: string } | { action: "edit" } | { action: "pause" } | { action: "resume" } | { action: "clear" }; export type ParsedRunnerGoalCommand = | { matched: false } | { matched: true; command: RunnerGoalComposerCommand } | { matched: true; error: string }; function normalizeRunnerGoalCommandText(value: string): string { const trimmed = value.trim(); // MDXEditor's link extension can reinterpret a selected action command plus // its subsequently typed argument as one relative autolink. Accept only the // exact whole-document shape it generates so the action still cannot fall // through as a comment. Ordinary Markdown links remain ordinary comments. const relativeAutolink = trimmed.match( /^\[\/(?:go(?:al)?)?[ \t\u00a0]*\]\(<(\/goal(?:[ \t\u00a0].*)?)>\)$/s, ); if (relativeAutolink) return relativeAutolink[1]!.replaceAll("\u00a0", " "); const encodedAutolink = trimmed.match( /^\[\/(?:go(?:al)?)?[ \t\u00a0]*\]\((\/goal(?:%20|%C2%A0).*)\)$/s, ); if (encodedAutolink) { try { return decodeURIComponent(encodedAutolink[1]!).replaceAll("\u00a0", " "); } catch { return trimmed; } } return trimmed.replaceAll("\u00a0", " "); } export function parseRunnerGoalCommand(value: string): ParsedRunnerGoalCommand { const trimmed = normalizeRunnerGoalCommandText(value); if (!trimmed) return { matched: false }; const firstWhitespace = trimmed.search(/\s/); const firstToken = firstWhitespace === -1 ? trimmed : trimmed.slice(0, firstWhitespace); if (firstToken !== "/goal") return { matched: false }; const remainder = firstWhitespace === -1 ? "" : trimmed.slice(firstWhitespace).trim(); if (!remainder) return { matched: true, command: { action: "focus" } }; const [subcommand, ...extra] = remainder.split(/\s+/); if (["edit", "pause", "resume", "clear"].includes(subcommand)) { if (extra.length > 0) { return { matched: true, error: `/goal ${subcommand} does not accept extra arguments.`, }; } return { matched: true, command: { action: subcommand as "edit" | "pause" | "resume" | "clear" }, }; } return { matched: true, command: { action: "create", objective: remainder } }; } function identityInitials(label: string): string { const parts = label.trim().split(/\s+/).filter(Boolean); if (parts.length === 0) return "?"; if (parts.length === 1) return parts[0]!.slice(0, 2).toUpperCase(); return `${parts[0]![0]}${parts[parts.length - 1]![0]}`.toUpperCase(); } function AssigneeIdentityAvatar({ assigneeValue, label, agentMap, userProfileMap, placement, }: { assigneeValue: string; label: string; agentMap: ReadonlyMap | undefined; userProfileMap: | ReadonlyMap | null | undefined; placement: "trigger" | "option"; }) { if (assigneeValue.startsWith("agent:")) { const agentId = assigneeValue.slice("agent:".length); const icon = agentMap?.get(agentId)?.icon ?? "bot"; return ( ); } if (assigneeValue.startsWith("user:")) { const userId = assigneeValue.slice("user:".length); const profile = userProfileMap?.get(userId); const resolvedLabel = profile?.label ?? label; return ( {profile?.image ? : null} {identityInitials(resolvedLabel)} ); } return null; } /** v7 per-mode placeholder copy; `{agent}` is the pending assignee's name. */ function modePlaceholder(mode: IssueWorkMode, agentName: string, mobile: boolean): string { if (mobile) { if (mode === "planning") return `Plan with ${agentName}…`; if (mode === "ask") return `Ask ${agentName}…`; return `Message ${agentName}…`; } switch (mode) { case "planning": return `Plan with ${agentName} — shapes the plan doc, no code changes…`; case "ask": return `Ask ${agentName} a question — read-only, nothing runs…`; default: return `Message ${agentName} — describe what you want done…`; } } type ComposerAttachment = { id: string; attachmentId?: string; inline?: boolean; name: string; size?: number; status: "uploading" | "attached" | "error"; error?: string; /** Set once uploaded; the submit path appends `[name](contentPath)` lines. */ contentPath?: string; }; type QueuedEditBackup = { body: string; attachments: ComposerAttachment[]; pendingMode: IssueWorkMode; pendingAssignee: string | null; }; /** Local duplicate of IssueChatThread's module-private helper (same rule). */ function shouldImplicitlyReopenComment( issueStatus: string | undefined, assigneeValue: string, ) { const resumesToTodo = issueStatus === "done" || issueStatus === "cancelled" || issueStatus === "blocked"; return resumesToTodo && assigneeValue.startsWith("agent:"); } function parseAssigneeValue(value: string): CommentReassignment | undefined { if (!value) return { assigneeAgentId: null, assigneeUserId: null }; if (value.startsWith("agent:")) { const id = value.slice("agent:".length); return id ? { assigneeAgentId: id, assigneeUserId: null } : undefined; } if (value.startsWith("user:")) { const id = value.slice("user:".length); return id ? { assigneeAgentId: null, assigneeUserId: id } : undefined; } return undefined; } function escapeMarkdownLabel(name: string): string { return name.replace(/[[\]]/g, "\\$&"); } /** * Composer for the redesigned thread (v7 spec): the shared MarkdownEditor * (rich lists, @-mentions, /-commands, inline pasted images) over a 32px * comp-bar of [add] [optional mode chip] … [assignee] [send]. The add menu * offers files, supported goals, Plan, and Ask. Cmd/Ctrl+. and Shift+Tab cycle modes (captured before * Lexical); Cmd/Ctrl+Enter posts via the editor's native onSubmit; plain Enter * stays a newline / next list item. Pasted or dropped images upload through * `onAttachImage` (or the `onImageUpload` fallback) and land inline at the * caret via the editor's image plugin; non-image files render as shadcn * base/attachment chips (kind icon · name · size, remove ×, uploading/error * states) between the editor and the comp-bar. */ export function TaskChatComposer({ onAdd, confirmedSubmissionIds, onStop, stopPending = false, workMode, onWorkModeChange, disabled = false, disabledReason, placeholder, onAttachImage, onImageUpload, mentions, enableReassign = false, conversationMode = false, reassignOptions, agentMap, modelAgents, companyId, assigneeAdapterOverrides, userProfileMap, currentAssigneeValue = "", onPendingAssigneeChange, issueStatus, mobile = false, draftKey, onReviewConversation, queuedEdit = null, onSaveQueuedEdit, onCancelQueuedEdit, pause = null, takeover = null, pendingTakeover = null, runnerGoalCapability = null, onRunnerGoalCommand, onRunnerGoalReassign, }: TaskChatComposerProps) { const streamlined = useStreamlinedTaskChatPresentation(); const stopControl = useComposerStop(onStop, stopPending); const [body, setBody] = useState(() => (draftKey ? loadDraft(draftKey) : "")); const [submitting, setSubmitting] = useState(false); const [reviewError, setReviewError] = useState(false); const [uncertainSubmission, setUncertainSubmission] = useState(() => draftKey ? loadDraftSubmission(draftKey) : null, ); const mountedTaskKey = useRef(draftKey); useEffect(() => { mountedTaskKey.current = draftKey; setUncertainSubmission(draftKey ? loadDraftSubmission(draftKey) : null); return () => { mountedTaskKey.current = undefined; }; }, [draftKey]); const [takeoverBusy, setTakeoverBusy] = useState(false); const [takeoverError, setTakeoverError] = useState(null); const [takeoverHeaderClaimed, setTakeoverHeaderClaimed] = useState(false); const [takeoverHeaderSlot, setTakeoverHeaderSlot] = useState(null); const [takeoverControlsSlot, setTakeoverControlsSlot] = useState(null); const [pendingMode, setPendingMode] = useState(workMode); const [pendingAssignee, setPendingAssignee] = useState(null); const [runSettings, setRunSettings] = useState(null); useEffect(() => setRunSettings(null), [draftKey, currentAssigneeValue]); const [actionError, setActionError] = useState(null); const [attachments, setAttachmentState] = useState( () => draftKey ? loadDraftAttachments(draftKey).map((item) => ({ ...item, id: `receipt:${item.attachmentId}`, status: "attached", })) : [], ); const attachmentsRef = useRef(attachments); function setAttachments( update: | ComposerAttachment[] | ((previous: ComposerAttachment[]) => ComposerAttachment[]), ) { const next = typeof update === "function" ? update(attachmentsRef.current) : update; attachmentsRef.current = next; setAttachmentState(next); const pending = pendingDraftRef.current; if (pending && pending.draftKey === draftKey) { saveDraftAttachments(pending.draftKey, next .filter(item => item.status === "attached" && item.attachmentId) .map(item => ({ ...item, inline: item.inline === true })), pending.attemptId); } } const submittingRef = useRef(submitting); submittingRef.current = submitting; const pendingAssigneeRef = useRef(pendingAssignee); pendingAssigneeRef.current = pendingAssignee; const fileInputRef = useRef(null); const editorRef = useRef(null); const bodyRef = useRef(body); bodyRef.current = body; const pendingDraftRef = useRef<{ draftKey: string; attemptId: string; submittedBody: string; submittedAttachmentIds: string[]; } | null>(null); function changeBody(value: string) { bodyRef.current = value; setBody(value); const pending = pendingDraftRef.current; if (!pending || pending.draftKey !== draftKey || loadDraftSubmission(pending.draftKey)?.attemptId !== pending.attemptId) return; // Keep text typed during delivery durable too. Navigation may happen before // either the request promise or the matching live server receipt arrives. saveDraft(pending.draftKey, value ? `${pending.submittedBody}\n\n${value}` : pending.submittedBody, pending.attemptId); saveDraftSubmission(pending.draftKey, { attemptId: pending.attemptId, reviewed: false, nextDraftOffset: pending.submittedBody.length + (value ? 2 : 0), submittedAttachmentIds: pending.submittedAttachmentIds, }); } const draftTimer = useRef | null>(null); const queuedEditRef = useRef(queuedEdit); queuedEditRef.current = queuedEdit; const queuedEditBackupRef = useRef(null); const previousQueuedEditIdRef = useRef(null); useEffect(() => { setTakeoverBusy(false); setTakeoverError(null); }, [takeover?.id]); useEffect(() => { const previousId = previousQueuedEditIdRef.current; const nextEdit = queuedEdit; const nextId = nextEdit?.commentId ?? null; if (nextEdit && previousId !== nextId) { if (!queuedEditBackupRef.current) { queuedEditBackupRef.current = { body: bodyRef.current, attachments: attachmentsRef.current, pendingMode, pendingAssignee: pendingAssigneeRef.current, }; if (draftKey) saveDraft(draftKey, bodyRef.current); } if (draftTimer.current) { clearTimeout(draftTimer.current); draftTimer.current = null; } bodyRef.current = nextEdit.body; setBody(nextEdit.body); setAttachments([]); requestAnimationFrame(() => editorRef.current?.focus()); } else if (!nextId && previousId) { const backup = queuedEditBackupRef.current; if (backup) { bodyRef.current = backup.body; setBody(backup.body); setAttachments(backup.attachments); setPendingMode(backup.pendingMode); setPendingAssignee(backup.pendingAssignee); if (draftKey) saveDraft(draftKey, backup.body); } queuedEditBackupRef.current = null; requestAnimationFrame(() => editorRef.current?.focus()); } previousQueuedEditIdRef.current = nextId; }, [draftKey, pendingMode, queuedEdit]); useEffect(() => { if (!draftKey || queuedEdit) return; bodyRef.current = loadDraft(draftKey); setBody(bodyRef.current); }, [draftKey, queuedEdit]); useEffect(() => { if (!draftKey) return; setAttachments( loadDraftAttachments(draftKey).map((item) => ({ ...item, id: `receipt:${item.attachmentId}`, status: "attached", })), ); }, [draftKey]); useEffect(() => { if ( !draftKey || queuedEdit || submitting || uncertainSubmission || attachments !== attachmentsRef.current ) return; saveDraftAttachments( draftKey, attachments .filter((item) => item.status === "attached" && item.attachmentId) .map((item) => ({ attachmentId: item.attachmentId, name: item.name, size: item.size, inline: item.inline === true, contentPath: item.contentPath, })), ); }, [attachments, draftKey, queuedEdit, submitting]); useEffect(() => { if (!draftKey || queuedEdit || submitting) { if (draftTimer.current) { clearTimeout(draftTimer.current); draftTimer.current = null; } return; } if (draftTimer.current) clearTimeout(draftTimer.current); draftTimer.current = setTimeout(() => { saveDraft(draftKey, body); }, DRAFT_DEBOUNCE_MS); }, [body, draftKey, queuedEdit, submitting]); useEffect(() => { return () => { if (draftTimer.current) clearTimeout(draftTimer.current); if (draftKey && !queuedEditRef.current && !submittingRef.current) saveDraft(draftKey, bodyRef.current); }; }, [draftKey]); useEffect(() => { if (!draftKey) return; const flushDraft = () => { if (!queuedEditRef.current && !submittingRef.current) saveDraft(draftKey, bodyRef.current); }; window.addEventListener("beforeunload", flushDraft); return () => window.removeEventListener("beforeunload", flushDraft); }, [draftKey]); const canAcceptFiles = !pause && !queuedEdit && !uncertainSubmission && Boolean(onAttachImage || onImageUpload); const showAssignee = Boolean( enableReassign && reassignOptions && reassignOptions.length > 0, ); const assigneeValue = pendingAssignee ?? currentAssigneeValue; const assigneeLabel = reassignOptions?.find((o) => o.id === assigneeValue)?.label ?? "Unassigned"; const assigneeName = assigneeLabel === "Unassigned" ? "the agent" : assigneeLabel; const effectivePlaceholder = queuedEdit ? "Edit queued message…" : (placeholder ?? modePlaceholder(pendingMode, assigneeName, mobile)); const goalUnavailable = runnerGoalCapability?.availability !== "available"; const goalCommandOption: ActionCommandOption = { id: "action:goal", kind: "action", command: "goal", name: "Goal", description: !runnerGoalCapability || runnerGoalCapability.verified === false ? "Support will be verified when the session starts." : "Pursue work across turns.", aliases: ["goal", "pursue", "continue"], disabled: goalUnavailable && runnerGoalCapability !== null, disabledReason: runnerGoalCapability?.reason ?? "Session goals are unsupported by this agent.", }; function updatePendingAssignee(value: string | null) { setPendingAssignee(value); onPendingAssigneeChange?.(value); } /** Upload an image and return its URL for inline `![](src)` markdown. */ async function uploadInlineImage(file: File): Promise { const id = crypto.randomUUID(); setAttachments((prev) => [ ...prev, { id, name: file.name, size: file.size, inline: true, status: "uploading", }, ]); try { const attachment = onAttachImage ? await onAttachImage(file) : undefined; const url = onAttachImage ? attachment?.contentPath : await onImageUpload?.(file); if (!url) throw new Error("Upload did not return a file URL"); if (!attachmentsRef.current.some((item) => item.id === id)) throw new Error("Attachment was removed"); setAttachments((prev) => prev.map((item) => item.id === id ? { ...item, attachmentId: attachment?.id, contentPath: url, status: "attached", } : item, ), ); return url; } catch (err) { setAttachments((prev) => prev.map((item) => item.id === id ? { ...item, status: "error", error: err instanceof Error ? err.message : "Upload failed", } : item, ), ); throw err; } } /** Non-image files: attach to the task and track in the chip row. */ async function attachNonImageFile(file: File) { const id = `${file.name}:${file.size}:${file.lastModified}:${Math.random().toString(36).slice(2)}`; setAttachments((prev) => [ ...prev, { id, name: file.name, size: file.size, status: "uploading" }, ]); try { if (!onAttachImage) { setAttachments((prev) => prev.map((item) => item.id === id ? { ...item, status: "error", error: "This file type cannot be attached here", } : item, ), ); return; } const attachment = await onAttachImage(file); if (!attachment?.contentPath) throw new Error("Upload did not return a file URL"); const name = attachment?.originalFilename ?? file.name; setAttachments((prev) => prev.map((item) => item.id === id ? { ...item, name, attachmentId: attachment?.id, status: "attached", contentPath: attachment?.contentPath, } : item, ), ); } catch (err) { setAttachments((prev) => prev.map((item) => item.id === id ? { ...item, status: "error", error: err instanceof Error ? err.message : "Upload failed", } : item, ), ); } } /** Images picked from the + button go inline at the caret, like paste/drop. */ async function attachPickedFile(file: File) { if (!file.type.startsWith("image/")) { await attachNonImageFile(file); return; } try { const url = await uploadInlineImage(file); editorRef.current?.insertMarkdown( `![${escapeMarkdownLabel(file.name)}](${url})`, ); } catch { // uploadInlineImage retains the failed receipt in the removable chip row. } } function handleFileInputChange(evt: ChangeEvent) { const files = evt.target.files; if (files && files.length > 0) { void (async () => { for (const file of Array.from(files)) await attachPickedFile(file); })(); } evt.target.value = ""; } function prepareGoal() { const current = bodyRef.current.trim(); changeBody(/^\/goal(?:\s|$)/.test(current) ? current : `/goal ${current}`); setActionError(null); requestAnimationFrame(() => editorRef.current?.focus()); } /** * Pasted image files fall through to the editor's image plugin (inline at * the caret); non-image files are attached to the task here. Only swallow * the paste when it carries no images the plugin should handle. */ function handlePasteCapture(evt: ReactClipboardEvent) { if (pause || !canAcceptFiles) return; const files = Array.from(evt.clipboardData?.files ?? []); if (files.length === 0) return; const nonImages = files.filter((file) => !file.type.startsWith("image/")); if (nonImages.length === 0) return; if (nonImages.length === files.length) { evt.preventDefault(); evt.stopPropagation(); } void (async () => { for (const file of nonImages) await attachNonImageFile(file); })(); } // Uploaded file references ride along as trailing `[name](contentPath)` // lines — the bubble renderer folds those link-only lines back into chips. const attachedRefs = attachments.filter( (item) => item.status === "attached" && item.contentPath && !item.inline, ); const visibleAttachments = attachments.filter( (item) => !item.inline || item.status !== "attached", ); // Sending mid-upload would silently drop the pending file from the comment; // sending past a failed chip would discard the file the user selected and // clear its error state, so both hold submission until resolved or removed. const showStop = !queuedEdit && !submitting && body.trim().length === 0 && attachments.length === 0 && Boolean(onStop || stopControl.stopping); const uploadPending = attachments.some((item) => item.status === "uploading"); const uploadFailed = attachments.some((item) => item.status === "error"); const takeoverVisible = Boolean(takeover && !pause); const previousTakeoverVisibleRef = useRef(takeoverVisible); useEffect(() => { if (previousTakeoverVisibleRef.current && !takeoverVisible && !queuedEdit) { requestAnimationFrame(() => editorRef.current?.focus()); } previousTakeoverVisibleRef.current = takeoverVisible; }, [queuedEdit, takeoverVisible]); const canResetPausedConversation = conversationMode && !queuedEdit && body.trim() === "/new" && attachments.length === 0; async function submit() { if (disabled || (pause && !canResetPausedConversation)) return; const retained = draftKey && !queuedEdit ? loadDraftSubmission(draftKey) : null; if (retained && !submitting) { setUncertainSubmission(retained); return; } const submittedBody = bodyRef.current; const submittedAttachments = attachmentsRef.current; const submittedAssignee = pendingAssigneeRef.current; const trimmed = submittedBody.trim(); const goalCommand = queuedEdit ? ({ matched: false } as const) : parseRunnerGoalCommand(submittedBody); if (goalCommand.matched && conversationMode) { setActionError("Create a separate task for work that needs an ongoing execution goal."); return; } if (goalCommand.matched) { if ("error" in goalCommand) { setActionError(goalCommand.error); return; } if (attachmentsRef.current.length > 0) { setActionError("Remove attachments before using /goal."); return; } if (!onRunnerGoalCommand) { setActionError( runnerGoalCapability?.reason ?? "Session goals are unsupported by this agent.", ); return; } if ( runnerGoalCapability && runnerGoalCapability.availability !== "available" ) { setActionError( runnerGoalCapability.reason ?? "Session goals are unsupported by this agent.", ); return; } try { const hasReassignment = showAssignee && assigneeValue !== currentAssigneeValue; if (hasReassignment && goalCommand.command.action !== "focus") { const reassignment = parseAssigneeValue(assigneeValue); if (!reassignment?.assigneeAgentId || !onRunnerGoalReassign) { setActionError("Select an agent before starting a session goal."); return; } await onRunnerGoalReassign(reassignment); updatePendingAssignee(null); } await onRunnerGoalCommand(goalCommand.command); setActionError(null); bodyRef.current = ""; if (draftTimer.current) clearTimeout(draftTimer.current); draftTimer.current = null; if (draftKey) clearDraft(draftKey); setBody(""); editorRef.current?.clear(); } catch (error) { setActionError( error instanceof Error ? error.message : "The goal action could not be applied.", ); } return; } if ( (!trimmed && attachedRefs.length === 0) || uploadPending || uploadFailed || submitting || uncertainSubmission || disabled ) return; const refLines = attachedRefs .map((item) => `[${escapeMarkdownLabel(item.name)}](${item.contentPath})`) .join("\n"); // A queued edit must preserve the complete Markdown source. Normal sends // keep the longstanding trimmed-body behavior. const fullBody = queuedEdit ? submittedBody : [trimmed, refLines].filter(Boolean).join("\n\n"); const hasReassignment = showAssignee && assigneeValue !== currentAssigneeValue; const reassignment = hasReassignment ? parseAssigneeValue(assigneeValue) : undefined; const reopen = shouldImplicitlyReopenComment(issueStatus, assigneeValue) ? true : undefined; // The thread renders the outgoing comment optimistically, so remove its // text from the composer at the same time. The editor remains writable for // the next draft while the request is pending. bodyRef.current = ""; if (draftTimer.current) { clearTimeout(draftTimer.current); draftTimer.current = null; } if (draftKey && !queuedEdit) { if ( submittedAttachments.some( (item) => item.status === "attached" && item.attachmentId, ) ) saveDraft(draftKey, submittedBody); else clearDraft(draftKey); } setBody(""); setSubmitting(true); let attemptId: string | null = null; try { if (queuedEdit) { if (!onSaveQueuedEdit) return; await onSaveQueuedEdit(queuedEdit.commentId, fullBody); onCancelQueuedEdit?.(); return; } if (pendingMode !== workMode && onWorkModeChange) { await onWorkModeChange(pendingMode); } const retainedAfterMode = draftKey ? loadDraftSubmission(draftKey) : null; if (retainedAfterMode) { setUncertainSubmission(retainedAfterMode); setBody(submittedBody); return; } attemptId = crypto.randomUUID(); if (draftKey) { saveDraft(draftKey, submittedBody); saveDraftSubmission(draftKey, { attemptId, reviewed: false }); } // IDs come only from this composer's upload receipts, never from parsing // arbitrary Markdown. A removed inline image no longer selects its receipt. const attachmentIds = [ ...new Set( submittedAttachments .filter( (item) => item.status === "attached" && item.attachmentId && (!item.inline || (item.contentPath && submittedBody.includes(item.contentPath))), ) .map((item) => item.attachmentId!), ), ]; if (draftKey) { pendingDraftRef.current = { draftKey, attemptId, submittedBody, submittedAttachmentIds: attachmentIds }; changeBody(bodyRef.current); } if (runSettings) { await onAdd(fullBody, reopen, reassignment, attachmentIds.length ? attachmentIds : undefined, attemptId, runSettings); } else { await onAdd(fullBody, reopen, reassignment, attachmentIds.length ? attachmentIds : undefined, attemptId); } // Navigation does not invalidate the server receipt. Settle the captured // task before checking whether this composer is still on screen. if (draftKey) settleDraftSubmission(draftKey, attemptId, mountedTaskKey.current === draftKey ? bodyRef.current : undefined); if (mountedTaskKey.current !== draftKey) return; const submittedIds = new Set(submittedAttachments.map((item) => item.id)); setAttachments((current) => current.filter((item) => !submittedIds.has(item.id)), ); if (pendingAssigneeRef.current === submittedAssignee) { updatePendingAssignee(null); } setRunSettings(null); } catch (error) { if (mountedTaskKey.current !== draftKey) return; const nextDraft = bodyRef.current; if (attemptId && error instanceof CommentSubmissionUnknownError) { const uncertain = { attemptId, reviewed: false, nextDraftOffset: submittedBody.length + (nextDraft ? 2 : 0), submittedAttachmentIds: submittedAttachments.flatMap(item => item.attachmentId ? [item.attachmentId] : []), }; setUncertainSubmission(uncertain); if (draftKey && loadDraftSubmission(draftKey)?.attemptId === attemptId) saveDraftSubmission(draftKey, uncertain); } else if (draftKey && attemptId) clearDraftSubmission(draftKey, attemptId); // Restore the failed message for retry without discarding a next draft // that was entered while the request was pending. const restoredBody = nextDraft ? `${submittedBody}\n\n${nextDraft}` : submittedBody; bodyRef.current = restoredBody; if (draftTimer.current) { clearTimeout(draftTimer.current); draftTimer.current = null; } if (draftKey) saveDraft(draftKey, restoredBody, attemptId ?? undefined); setBody(restoredBody); } finally { if (pendingDraftRef.current?.attemptId === attemptId) pendingDraftRef.current = null; setSubmitting(false); } } useEffect(() => { if (!uncertainSubmission || !confirmedSubmissionIds?.has(uncertainSubmission.attemptId)) return; const nextDraft = uncertainSubmission.nextDraftOffset === undefined ? "" : bodyRef.current.slice(uncertainSubmission.nextDraftOffset); if (draftKey) settleDraftSubmission(draftKey, uncertainSubmission.attemptId, nextDraft); setUncertainSubmission(null); bodyRef.current = nextDraft; setBody(nextDraft); const submittedIds = uncertainSubmission.submittedAttachmentIds; setAttachments(current => submittedIds ? current.filter(item => !item.attachmentId || !submittedIds.includes(item.attachmentId)) : []); }, [confirmedSubmissionIds, draftKey, uncertainSubmission]); async function reviewUncertainSubmission() { if (!uncertainSubmission) return; setReviewError(false); try { if (!onReviewConversation) throw new Error("Review unavailable"); await onReviewConversation(); if (mountedTaskKey.current !== draftKey) return; const reviewed = { ...uncertainSubmission, reviewed: true }; setUncertainSubmission(reviewed); if ( draftKey && loadDraftSubmission(draftKey)?.attemptId === reviewed.attemptId ) saveDraftSubmission(draftKey, reviewed); } catch { setReviewError(true); } } function discardUncertainDraft() { if (!uncertainSubmission?.reviewed) return; if (draftKey) clearDraft(draftKey, uncertainSubmission.attemptId); bodyRef.current = ""; setBody(""); setAttachments([]); setUncertainSubmission(null); } function skipTakeover() { if (!takeover) return; setTakeoverBusy(true); setTakeoverError(null); Promise.resolve(takeover.onSkip()).catch((cause) => { setTakeoverBusy(false); setTakeoverError( cause instanceof Error ? cause.message : "This request could not be skipped.", ); }); } const takeoverSkipButton = takeover ? ( ) : null; if (pause && (!conversationMode || queuedEdit)) { return ; } return (
{takeoverVisible && takeover ? (
{!takeoverHeaderClaimed && !takeover.hideLabel ? ( {takeover.label} ) : null}
{takeover.pendingCount > 1 ? ( ) : null}
{takeover.content}
{takeoverError ?

{takeoverError}

: null} {!takeover.inlineSkip && !takeover.hideSkip ? (
{takeoverSkipButton}
) : null}
) : null}
{ // Capture mode shortcuts on the wrapper so they work while the rich // editor is focused and win over Lexical/browser bindings. Match the // period by key and code because hardware keyboards on iOS can omit // `code` for Cmd+Period. if (disabled || queuedEdit) return; const isPeriod = e.key === "." || e.code === "Period"; const isModeShortcut = (isPeriod && (e.metaKey || e.ctrlKey)) || (e.key === "Tab" && e.shiftKey); if (isModeShortcut) { e.preventDefault(); e.stopPropagation(); setPendingMode((mode) => nextWorkMode(mode)); } }} onPasteCapture={handlePasteCapture} > {uncertainSubmission ? (

We couldn’t confirm whether this comment was saved. It may already be in the conversation. Review it before starting another draft.

{reviewError ? (

Couldn’t refresh the conversation. Try reviewing it again.

) : null} {uncertainSubmission.reviewed ? ( <>

Discarding this draft does not remove any saved comment or uploaded file.

) : null}
) : null} {!takeoverVisible && (pendingTakeover || takeover) ? ( ) : null} {pause && conversationMode ? (

Send /new to start a fresh session and resume this conversation.

) : null}
void submit()} imageUploadHandler={ canAcceptFiles ? uploadInlineImage : undefined } onDropFile={canAcceptFiles ? attachNonImageFile : undefined} bordered={false} className={cn(disabled && "opacity-60")} contentClassName={ mobile ? "max-h-(--sz-28dvh) min-h-(--sz-48px) overflow-y-auto px-1 py-1 text-base scrollbar-auto-hide" : "max-h-(--sz-28dvh) min-h-(--sz-48px) overflow-y-auto px-1 py-1 text-sm scrollbar-auto-hide" } />
{actionError ? (

{actionError}

) : null} {visibleAttachments.length > 0 ? ( {visibleAttachments.map((attachment) => { const kind = fileKindForName(attachment.name); const KindIcon = kind.icon; const sizeLabel = formatFileSize(attachment.size); return ( {attachment.status === "uploading" ? ( ) : ( )} {attachment.name} {attachment.status === "uploading" ? "Uploading…" : attachment.status === "error" ? (attachment.error ?? "Upload failed") : [kind.label, sizeLabel] .filter(Boolean) .join(" · ")} setAttachments((prev) => prev.filter((item) => item.id !== attachment.id), ) } > ); })} ) : null}
{canAcceptFiles ? ( ) : null} fileInputRef.current?.click() : undefined} onGoal={!queuedEdit && !conversationMode && attachments.length === 0 && runnerGoalCapability?.availability === "available" && onRunnerGoalCommand ? prepareGoal : undefined} disabled={disabled || !!uncertainSubmission} mobile={mobile} triggerTestId="task-chat-composer-add" menuTestId="task-chat-composer-add-menu" /> {queuedEdit ? ( {queuedEdit.stale ? "Queued message changed" : "Editing queued message"} ) : ( setPendingMode("standard") : undefined} disabled={disabled || !!uncertainSubmission} testId="task-chat-composer-mode" mobile={mobile} /> )}
{showAssignee && !queuedEdit && companyId && modelAgents ? ( ( )} disabled={disabled} mobile={mobile} /> ) : showAssignee && !queuedEdit ? ( { return ( <> {assigneeLabel} ); }} renderOption={(option) => { return ( <> {option.label} ); }} /> ) : null} {queuedEdit ? ( ) : null}
{stopControl.error ? (

{stopControl.error}

) : null}
); }