mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 22:13:59 +01:00
Use Enter to submit task replies and new tasks, with Shift+Enter for a new line. Add an experimental preference to require Command/Ctrl+Enter. Preserve autocomplete selection and IME confirmation, and retain the modifier shortcut until saved settings load. Co-Authored-By: Paperclip <noreply@paperclip.ing>
1698 lines
64 KiB
TypeScript
1698 lines
64 KiB
TypeScript
import { useCurrentAccountGuard } from "@/hooks/useAccountBoundMutation";
|
|
import { ComposerFileDropTarget } from "./ComposerFileDropTarget";
|
|
import { AgentAvatar } from "../AgentAvatar";
|
|
import {
|
|
useEffect,
|
|
useId,
|
|
useRef,
|
|
useState,
|
|
type ChangeEvent,
|
|
type ClipboardEvent as ReactClipboardEvent,
|
|
type ReactNode,
|
|
} from "react";
|
|
import { cn } from "@/lib/utils";
|
|
import { taskChatComposerSurfaceClasses } from "./composer-surface";
|
|
import { useComposerStop } from "@/hooks/useComposerStop";
|
|
import { useComposerSubmitKey } from "@/hooks/useComposerSubmitKey";
|
|
import { useStreamlinedTaskChatPresentation } from "./presentation-mode";
|
|
import {
|
|
DRAFT_DEBOUNCE_MS,
|
|
clearDraft,
|
|
loadDraft,
|
|
loadDraftAttachments,
|
|
saveDraft,
|
|
saveDraftAttachments,
|
|
loadDraftSubmission,
|
|
replayableRequest,
|
|
saveDraftSubmission,
|
|
clearDraftSubmission,
|
|
settleDraftSubmission,
|
|
type ComposerDraftSubmission,
|
|
type ComposerSendRequest,
|
|
} from "@/lib/composer-draft";
|
|
import { classifySendFailure, type SendOutcome } from "@/lib/pending-send";
|
|
import { describeError } from "@/api/errors";
|
|
import { useAutoResend } from "@/hooks/useDurableSubmit";
|
|
import { useDraftOwner } from "@/hooks/useDraftOwner";
|
|
import { useRequestOwnerBinding } from "@/hooks/useRequestOwnerBinding";
|
|
import { PendingSendNotice } from "@/components/PendingSendNotice";
|
|
import {
|
|
ArrowUp,
|
|
RotateCw,
|
|
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, isImageAttachment } from "./task-chat-attachments";
|
|
import {
|
|
MarkdownEditor,
|
|
type MarkdownEditorRef,
|
|
} from "@/components/MarkdownEditor";
|
|
import { nextWorkMode } from "@/lib/work-mode-meta";
|
|
import { trackRecentAssignee, trackRecentAssigneeUser } from "@/lib/recent-assignees";
|
|
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 { ComposerImageAttachments, type ComposerImageAttachment } from "./ComposerImageAttachments";
|
|
import { ComposerRunSettingsPicker } from "./ComposerRunSettingsPicker";
|
|
import { TaskChatComposerBar } from "./TaskChatComposerBar";
|
|
import { ComposerAddMenu, ComposerModeChip, ComposerPrivacyChip } from "./ComposerAddMenu";
|
|
import { supportsComposerModel, type ComposerRunSettings } from "./composer-run-settings";
|
|
import { useCoordinatorPresentation } from "@/components/coordinator/CoordinatorPresentation";
|
|
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> | 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> | void;
|
|
/** New tasks share the editor and controls, but retain their draft until creation succeeds. */
|
|
creation?: {
|
|
value: string;
|
|
onChange: (value: string) => void;
|
|
onSubmit: (body: string, mode: IssueWorkMode, settings: ComposerRunSettings | null) => Promise<void>;
|
|
submitLabel: string;
|
|
canSubmitWithoutBody?: boolean;
|
|
header?: ReactNode;
|
|
details?: ReactNode;
|
|
contextBar?: ReactNode;
|
|
submitDisabled?: boolean;
|
|
/** Lock the editor, e.g. while a submitted create waits to resend. */
|
|
readOnly?: boolean;
|
|
privacy?: { private: boolean; inherited?: string; onChange: (value: boolean) => void };
|
|
onSelectFiles: (files: File[]) => void;
|
|
images?: ComposerImageAttachment[];
|
|
onRemoveImage?: (id: string) => void;
|
|
runSettings: ComposerRunSettings | null;
|
|
onRunSettingsChange: (settings: ComposerRunSettings | null) => void;
|
|
};
|
|
confirmedSubmissionIds?: ReadonlySet<string>;
|
|
onStop?: () => Promise<void>;
|
|
confirmedStoppedRunIds?: ReadonlySet<string>;
|
|
stopPending?: boolean;
|
|
stopScope?: "leaf" | "subtree";
|
|
workMode: IssueWorkMode;
|
|
onWorkModeChange?: (mode: IssueWorkMode) => Promise<void> | void;
|
|
disabled?: boolean;
|
|
disabledReason?: string | null;
|
|
placeholder?: string;
|
|
/** Preferred upload path: attaches the file to the task (mirrors legacy). */
|
|
onAttachImage?: (file: File) => Promise<IssueAttachment | void>;
|
|
onImageUpload?: (file: File) => Promise<string>;
|
|
/** Mentionable entities for the editor's @-autocomplete. */
|
|
mentions?: MentionOption[];
|
|
enableReassign?: boolean;
|
|
conversationMode?: boolean;
|
|
reassignOptions?: InlineEntityOption[];
|
|
agentMap?: ReadonlyMap<string, import("../AgentAvatar").AvatarAgent & { icon?: string | null }>;
|
|
modelAgents?: ReadonlyMap<string, Agent>;
|
|
companyId?: string | null;
|
|
assigneeAdapterOverrides?: IssueAssigneeAdapterOverrides | null;
|
|
userProfileMap?: ReadonlyMap<
|
|
string,
|
|
{ label: string; image: string | null }
|
|
> | null;
|
|
currentAssigneeValue?: string;
|
|
suggestedAssigneeValue?: 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;
|
|
/** When set, the main composer temporarily edits this queued message. */
|
|
queuedEdit?: { commentId: string; body: string; stale?: boolean } | null;
|
|
onSaveQueuedEdit?: (commentId: string, body: string) => Promise<void>;
|
|
onCancelQueuedEdit?: () => void;
|
|
pause?: TaskComposerPause | null;
|
|
takeover?: TaskChatComposerTakeover | null;
|
|
pendingTakeover?: {
|
|
count: number;
|
|
label?: string;
|
|
onOpen: () => void;
|
|
} | null;
|
|
runnerGoalCapability?: RunnerGoalCapability | null;
|
|
onRunnerGoalCommand?: (
|
|
command: RunnerGoalComposerCommand,
|
|
) => Promise<void> | void;
|
|
onRunnerGoalReassign?: (
|
|
reassignment: CommentReassignment,
|
|
) => Promise<void> | 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<string, import("../AgentAvatar").AvatarAgent & { icon?: string | null }> | undefined;
|
|
userProfileMap:
|
|
| ReadonlyMap<string, { label: string; image: string | null }>
|
|
| null
|
|
| undefined;
|
|
placement: "trigger" | "option";
|
|
}) {
|
|
if (assigneeValue.startsWith("agent:")) {
|
|
const agentId = assigneeValue.slice("agent:".length);
|
|
const icon = agentMap?.get(agentId)?.icon ?? "bot";
|
|
return (
|
|
<span
|
|
className="shrink-0"
|
|
data-assignee-identity={assigneeValue}
|
|
data-assignee-trigger-icon={placement === "trigger" ? icon : undefined}
|
|
data-assignee-option-icon={
|
|
placement === "option" ? assigneeValue : undefined
|
|
}
|
|
>
|
|
<AgentAvatar agent={agentMap?.get(agentId) ?? { id: agentId }} size={16} />
|
|
</span>
|
|
);
|
|
}
|
|
|
|
if (assigneeValue.startsWith("user:")) {
|
|
const userId = assigneeValue.slice("user:".length);
|
|
const profile = userProfileMap?.get(userId);
|
|
const resolvedLabel = profile?.label ?? label;
|
|
return (
|
|
<Avatar
|
|
size="xs"
|
|
className="shrink-0"
|
|
data-assignee-identity={assigneeValue}
|
|
data-assignee-trigger-avatar={
|
|
placement === "trigger" ? userId : undefined
|
|
}
|
|
data-assignee-option-avatar={
|
|
placement === "option" ? assigneeValue : undefined
|
|
}
|
|
>
|
|
{profile?.image ? <AvatarImage src={profile.image} alt="" /> : null}
|
|
<AvatarFallback>{identityInitials(resolvedLabel)}</AvatarFallback>
|
|
</Avatar>
|
|
);
|
|
}
|
|
|
|
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;
|
|
contentType?: string;
|
|
name: string;
|
|
size?: number;
|
|
status: "uploading" | "attached" | "error";
|
|
error?: string;
|
|
/** Set once uploaded; the submit path appends `[name](contentPath)` lines. */
|
|
contentPath?: string;
|
|
/**
|
|
* The picked file, kept after a failed upload so the chip can offer Retry.
|
|
* Uploads are not deduped by the server, so they never resend on their own.
|
|
*/
|
|
file?: File;
|
|
};
|
|
|
|
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, "\\$&");
|
|
}
|
|
|
|
export function TaskChatComposer({
|
|
onAdd,
|
|
creation,
|
|
confirmedSubmissionIds,
|
|
onStop,
|
|
confirmedStoppedRunIds,
|
|
stopPending = false,
|
|
workMode,
|
|
onWorkModeChange,
|
|
disabled = false,
|
|
disabledReason,
|
|
placeholder,
|
|
onAttachImage,
|
|
onImageUpload,
|
|
mentions,
|
|
enableReassign = false,
|
|
conversationMode = false,
|
|
reassignOptions,
|
|
agentMap,
|
|
modelAgents,
|
|
companyId,
|
|
assigneeAdapterOverrides,
|
|
userProfileMap,
|
|
currentAssigneeValue = "",
|
|
suggestedAssigneeValue,
|
|
onPendingAssigneeChange,
|
|
issueStatus,
|
|
mobile = false,
|
|
draftKey,
|
|
queuedEdit = null,
|
|
onSaveQueuedEdit,
|
|
onCancelQueuedEdit,
|
|
pause = null,
|
|
takeover = null,
|
|
pendingTakeover = null,
|
|
runnerGoalCapability = null,
|
|
onRunnerGoalCommand,
|
|
onRunnerGoalReassign,
|
|
}: TaskChatComposerProps) {
|
|
const streamlined = useStreamlinedTaskChatPresentation();
|
|
const submitKey = useComposerSubmitKey();
|
|
const stopControl = useComposerStop(onStop, stopPending, confirmedStoppedRunIds);
|
|
const [localBody, setBody] = useState(() => (draftKey ? loadDraft(draftKey) : ""));
|
|
const body = creation ? creation.value : localBody;
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [uncertainSubmission, setUncertainSubmission] =
|
|
useState<ComposerDraftSubmission | null>(() =>
|
|
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<string | null>(null);
|
|
const [takeoverHeaderClaimed, setTakeoverHeaderClaimed] = useState(false);
|
|
const [takeoverHeaderSlot, setTakeoverHeaderSlot] =
|
|
useState<HTMLElement | null>(null);
|
|
const [takeoverControlsSlot, setTakeoverControlsSlot] =
|
|
useState<HTMLElement | null>(null);
|
|
const [localPendingMode, setPendingMode] = useState<IssueWorkMode>(workMode);
|
|
const pendingMode = creation ? workMode : localPendingMode;
|
|
const [pendingAssignee, setPendingAssignee] = useState<string | null>(null);
|
|
const coordinator = useCoordinatorPresentation();
|
|
const [localRunSettings, setLocalRunSettings] = useState<ComposerRunSettings | null>(null);
|
|
const runSettings = creation ? creation.runSettings : localRunSettings;
|
|
const setRunSettings = creation ? creation.onRunSettingsChange : setLocalRunSettings;
|
|
useEffect(() => setLocalRunSettings(null), [draftKey, currentAssigneeValue]);
|
|
const [actionError, setActionError] = useState<string | null>(null);
|
|
const [attachments, setAttachmentState] = useState<ComposerAttachment[]>(
|
|
() =>
|
|
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<HTMLInputElement | null>(null);
|
|
const editorRef = useRef<MarkdownEditorRef>(null);
|
|
const bodyRef = useRef(body);
|
|
bodyRef.current = body;
|
|
// Only the account that made a request may replay it.
|
|
const draftOwner = useDraftOwner();
|
|
const isCurrentAccount = useCurrentAccountGuard();
|
|
const pendingDraftRef = useRef<{
|
|
draftKey: string;
|
|
attemptId: string;
|
|
submittedBody: string;
|
|
submittedAttachmentIds: string[];
|
|
request: ComposerSendRequest;
|
|
requestOwner?: string;
|
|
} | null>(null);
|
|
const markRequestUnbound = useRequestOwnerBinding({
|
|
draftKey, owner: draftOwner, uncertainSubmission, setUncertainSubmission, pendingDraftRef,
|
|
});
|
|
function changeBody(value: string) {
|
|
bodyRef.current = value;
|
|
if (creation) {
|
|
creation.onChange(value);
|
|
return;
|
|
}
|
|
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,
|
|
request: pending.request,
|
|
requestOwner: pending.requestOwner,
|
|
});
|
|
}
|
|
const draftTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const queuedEditRef = useRef(queuedEdit);
|
|
queuedEditRef.current = queuedEdit;
|
|
const queuedEditBackupRef = useRef<QueuedEditBackup | null>(null);
|
|
const previousQueuedEditIdRef = useRef<string | null>(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,
|
|
contentType: item.contentType,
|
|
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(creation || onAttachImage || onImageUpload);
|
|
const showAssignee = Boolean(
|
|
enableReassign && reassignOptions && reassignOptions.length > 0,
|
|
);
|
|
const assigneeValue = creation || conversationMode ? currentAssigneeValue : pendingAssignee ?? suggestedAssigneeValue ?? currentAssigneeValue;
|
|
const showConversationModel = coordinator && conversationMode && supportsComposerModel(modelAgents?.get(assigneeValue.slice(6)));
|
|
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) {
|
|
if (!creation && value && companyId) {
|
|
const selection = parseAssigneeValue(value);
|
|
if (selection?.assigneeAgentId) trackRecentAssignee(selection.assigneeAgentId, companyId);
|
|
if (selection?.assigneeUserId) trackRecentAssigneeUser(selection.assigneeUserId, companyId);
|
|
}
|
|
setPendingAssignee(value);
|
|
onPendingAssigneeChange?.(value);
|
|
}
|
|
|
|
function changeMode(mode: IssueWorkMode) {
|
|
setPendingMode(mode);
|
|
if (creation) void onWorkModeChange?.(mode);
|
|
}
|
|
|
|
async function uploadImage(file: File): Promise<string> {
|
|
const id = crypto.randomUUID();
|
|
setAttachments((prev) => [
|
|
...prev,
|
|
{
|
|
id,
|
|
name: file.name,
|
|
size: file.size,
|
|
contentType: file.type,
|
|
file,
|
|
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: describeError(err, { action: "upload the file" }).body,
|
|
file,
|
|
}
|
|
: 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, contentType: file.type, 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: describeError(err, { action: "upload the file" }).body,
|
|
file,
|
|
}
|
|
: item,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/** Retry a failed upload from its kept file; only ever on an explicit click. */
|
|
function retryUpload(attachment: ComposerAttachment) {
|
|
const file = attachment.file;
|
|
if (!file) return;
|
|
setAttachments((prev) => prev.filter((item) => item.id !== attachment.id));
|
|
void attachPickedFile(file);
|
|
}
|
|
|
|
async function attachPickedFile(file: File) {
|
|
if (!file.type.startsWith("image/")) {
|
|
await attachNonImageFile(file);
|
|
return;
|
|
}
|
|
await uploadImage(file).catch(() => undefined);
|
|
}
|
|
|
|
function handleFileInputChange(evt: ChangeEvent<HTMLInputElement>) {
|
|
const files = evt.target.files;
|
|
if (files && files.length > 0) {
|
|
if (creation) {
|
|
creation.onSelectFiles(Array.from(files));
|
|
evt.target.value = "";
|
|
return;
|
|
}
|
|
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());
|
|
}
|
|
|
|
function handlePasteCapture(evt: ReactClipboardEvent<HTMLDivElement>) {
|
|
if (!canAcceptFiles || disabled || creation?.readOnly) return;
|
|
const files = Array.from(evt.clipboardData?.files ?? []);
|
|
if (files.length === 0) return;
|
|
evt.preventDefault();
|
|
evt.stopPropagation();
|
|
if (creation) {
|
|
creation.onSelectFiles(files);
|
|
return;
|
|
}
|
|
for (const file of files) void attachPickedFile(file);
|
|
}
|
|
|
|
function isImage(item: ComposerAttachment) {
|
|
return isImageAttachment({ name: item.name, url: item.contentPath ?? "", contentType: item.contentType });
|
|
}
|
|
|
|
const attachedRefs = attachments.filter(
|
|
(item) => item.status === "attached" && item.contentPath && !item.inline,
|
|
);
|
|
const imageAttachments = attachments.filter(item => isImage(item) && !item.inline);
|
|
const visibleAttachments = attachments.filter(
|
|
(item) => (!item.inline || item.status !== "attached") && !isImage(item),
|
|
);
|
|
// 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;
|
|
if (creation) {
|
|
if (submittingRef.current || uploadPending || uploadFailed || creation.submitDisabled || (!bodyRef.current.trim() && !creation.canSubmitWithoutBody)) return;
|
|
submittingRef.current = true;
|
|
setSubmitting(true);
|
|
setActionError(null);
|
|
try {
|
|
await creation.onSubmit(bodyRef.current, pendingMode, runSettings);
|
|
} catch (error) {
|
|
setActionError(describeError(error, { action: "create the task" }).body);
|
|
} finally {
|
|
submittingRef.current = false;
|
|
setSubmitting(false);
|
|
}
|
|
return;
|
|
}
|
|
if (!onAdd) 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) => `${isImage(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;
|
|
let request: ComposerSendRequest | null = null;
|
|
const requestOwner = draftOwner;
|
|
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 (requestOwner === undefined) markRequestUnbound(attemptId);
|
|
// 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!),
|
|
),
|
|
];
|
|
// The exact request is stored before it is sent, so a lost response or
|
|
// an outage can resend it with the same ID instead of losing it.
|
|
request = {
|
|
body: fullBody,
|
|
...(reopen === undefined ? {} : { reopen }),
|
|
...(reassignment ? { reassignment } : {}),
|
|
...(attachmentIds.length ? { attachmentIds } : {}),
|
|
...(runSettings ? { runSettings: runSettings as unknown as Record<string, unknown> } : {}),
|
|
};
|
|
if (draftKey) {
|
|
saveDraft(draftKey, submittedBody);
|
|
saveDraftSubmission(draftKey, { attemptId, reviewed: false, request, requestOwner });
|
|
pendingDraftRef.current = { draftKey, attemptId, submittedBody, submittedAttachmentIds: attachmentIds, request, requestOwner };
|
|
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;
|
|
// Not delivered, or not known to be: keep it and resend with the same
|
|
// ID when the connection allows. The server dedupes by that ID.
|
|
if (attemptId && request && classifySendFailure(error) === "pending") {
|
|
const uncertain = {
|
|
attemptId, reviewed: false,
|
|
nextDraftOffset: submittedBody.length + (nextDraft ? 2 : 0),
|
|
submittedAttachmentIds: submittedAttachments.flatMap(item => item.attachmentId ? [item.attachmentId] : []),
|
|
request,
|
|
requestOwner,
|
|
};
|
|
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);
|
|
}
|
|
}
|
|
|
|
/** A confirmed receipt for the retained send: drop its text, keep any next draft. */
|
|
const settledSubmissionIds = useRef(new Set<string>());
|
|
function settleUncertainSubmission(submission: ComposerDraftSubmission) {
|
|
const settlementKey = JSON.stringify([draftKey, submission.attemptId]);
|
|
if (settledSubmissionIds.current.has(settlementKey)) return;
|
|
settledSubmissionIds.current.add(settlementKey);
|
|
const nextDraft = submission.nextDraftOffset === undefined
|
|
? "" : bodyRef.current.slice(submission.nextDraftOffset);
|
|
if (draftKey) settleDraftSubmission(draftKey, submission.attemptId, nextDraft);
|
|
setUncertainSubmission(null);
|
|
setActionError(null);
|
|
bodyRef.current = nextDraft;
|
|
setBody(nextDraft);
|
|
const submittedIds = submission.submittedAttachmentIds;
|
|
setAttachments(current => submittedIds
|
|
? current.filter(item => !item.attachmentId || !submittedIds.includes(item.attachmentId))
|
|
: []);
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (!uncertainSubmission || !confirmedSubmissionIds?.has(uncertainSubmission.attemptId)) return;
|
|
settleUncertainSubmission(uncertainSubmission);
|
|
}, [confirmedSubmissionIds, draftKey, uncertainSubmission]);
|
|
|
|
async function resendUncertainSubmission(): Promise<SendOutcome> {
|
|
if (!isCurrentAccount()) return "pending";
|
|
const submission = uncertainSubmission;
|
|
const request = replayableRequest(submission, draftOwner);
|
|
if (!submission || !request || !onAdd) return "rejected";
|
|
const taskKey = draftKey;
|
|
const args = [
|
|
request.body,
|
|
request.reopen,
|
|
request.reassignment,
|
|
request.attachmentIds,
|
|
submission.attemptId,
|
|
] as const;
|
|
try {
|
|
if (request.runSettings) await onAdd(...args, request.runSettings as unknown as ComposerRunSettings);
|
|
else await onAdd(...args);
|
|
} catch (error) {
|
|
if (!isCurrentAccount()) return "pending";
|
|
const failure = classifySendFailure(error);
|
|
if (settledSubmissionIds.current.has(JSON.stringify([taskKey, submission.attemptId]))) return failure;
|
|
if (failure === "rejected") {
|
|
// A definitive answer: the text is already back in the editor.
|
|
if (taskKey) clearDraftSubmission(taskKey, submission.attemptId);
|
|
if (mountedTaskKey.current === taskKey) {
|
|
setUncertainSubmission(null);
|
|
setActionError(describeError(error, { action: "send this message" }).body);
|
|
}
|
|
}
|
|
return failure;
|
|
}
|
|
if (mountedTaskKey.current === taskKey) settleUncertainSubmission(submission);
|
|
else if (taskKey) settleDraftSubmission(taskKey, submission.attemptId);
|
|
return "sent";
|
|
}
|
|
|
|
const resendSourceId = useId();
|
|
const autoResend = useAutoResend({
|
|
sourceId: `task-chat-composer:${resendSourceId}`,
|
|
active: Boolean(replayableRequest(uncertainSubmission, draftOwner)) && !submitting,
|
|
resend: resendUncertainSubmission,
|
|
});
|
|
|
|
/** Stop resending; the text stays in the editor to edit or send again. */
|
|
function cancelUncertainSubmission() {
|
|
if (!uncertainSubmission) return;
|
|
settledSubmissionIds.current.add(JSON.stringify([draftKey, uncertainSubmission.attemptId]));
|
|
if (draftKey) clearDraftSubmission(draftKey, uncertainSubmission.attemptId);
|
|
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 ? (
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="outline"
|
|
disabled={takeoverBusy}
|
|
onClick={skipTakeover}
|
|
>
|
|
{takeoverBusy ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
|
|
) : null}
|
|
Skip
|
|
</Button>
|
|
) : null;
|
|
|
|
if (pause && (!conversationMode || queuedEdit)) {
|
|
return <TaskChatPausedTakeover {...pause} hasDraft={Boolean(body.trim() || attachments.length)} />;
|
|
}
|
|
|
|
return (
|
|
<div className={cn("flex min-w-0 flex-col", creation?.contextBar ? "gap-0" : "gap-2")}>
|
|
<ComposerFileDropTarget
|
|
enabled={canAcceptFiles && !disabled && !submitting && !creation?.readOnly}
|
|
newTask={Boolean(creation)}
|
|
onFiles={(files) => {
|
|
if (creation) creation.onSelectFiles(files);
|
|
else for (const file of files) void attachPickedFile(file);
|
|
}}
|
|
/>
|
|
{takeoverVisible && takeover ? (
|
|
<section
|
|
className={cn(
|
|
"relative rounded-xl border border-border bg-card p-(--sz-18px) shadow-sm",
|
|
mobile ? "overflow-visible" : "max-h-(--tc-interaction-card-max-h) overflow-y-auto scrollbar-auto-hide",
|
|
)}
|
|
aria-label={takeover.label}
|
|
data-testid="task-chat-composer-takeover"
|
|
>
|
|
<div
|
|
className={cn(
|
|
"flex min-w-0 items-center gap-2",
|
|
takeover.hideLabel && takeover.pendingCount === 1
|
|
? "absolute right-0 top-0 z-10"
|
|
: "mb-3",
|
|
)}
|
|
data-testid="task-chat-composer-takeover-header"
|
|
>
|
|
<div className="min-w-0 flex-1">
|
|
{!takeoverHeaderClaimed && !takeover.hideLabel ? (
|
|
<strong className="block truncate text-sm font-medium text-foreground">
|
|
{takeover.label}
|
|
</strong>
|
|
) : null}
|
|
<div
|
|
ref={setTakeoverHeaderSlot}
|
|
className="flex min-w-0 items-center"
|
|
data-testid="task-chat-composer-takeover-title-slot"
|
|
/>
|
|
</div>
|
|
<div className="ml-auto flex shrink-0 items-center gap-1">
|
|
{takeover.pendingCount > 1 ? (
|
|
<Button type="button" size="sm" variant="ghost" className="h-7 px-2" onClick={takeover.onShowNext}>
|
|
{takeover.pendingCount} pending
|
|
</Button>
|
|
) : null}
|
|
<div ref={setTakeoverControlsSlot} className="flex shrink-0 items-center" data-testid="task-chat-composer-takeover-controls-slot" />
|
|
<Button type="button" size="icon-xs" variant="ghost" className="text-muted-foreground hover:text-foreground"
|
|
aria-label={`Dismiss ${takeover.label}`} disabled={takeoverBusy} onClick={takeover.onDismiss}>
|
|
<X aria-hidden />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<div className={takeover.hideLabel && takeover.pendingCount === 1 ? "pr-8" : "pr-1"}
|
|
data-testid="task-chat-composer-takeover-body">
|
|
<TaskChatComposerTakeoverActionsContext.Provider
|
|
value={{
|
|
skipButton: takeover.inlineSkip && !takeover.hideSkip && takeoverSkipButton ? takeoverSkipButton : null,
|
|
dismiss: takeover.onDismiss,
|
|
headerSlot: takeoverHeaderSlot,
|
|
controlsSlot: takeoverControlsSlot,
|
|
setHeaderClaimed: setTakeoverHeaderClaimed,
|
|
}}
|
|
>
|
|
{takeover.content}
|
|
</TaskChatComposerTakeoverActionsContext.Provider>
|
|
</div>
|
|
{takeoverError ? <p className="mt-2 text-sm text-destructive" role="alert">{takeoverError}</p> : null}
|
|
{!takeover.inlineSkip && !takeover.hideSkip ? (
|
|
<div className="mt-3 flex items-center justify-end gap-2">{takeoverSkipButton}</div>
|
|
) : null}
|
|
</section>
|
|
) : null}
|
|
{creation?.contextBar ? (
|
|
<TaskChatComposerBar
|
|
className="flex h-11 min-w-0 items-center gap-2 px-2.5"
|
|
data-testid="task-chat-composer-context"
|
|
role="group"
|
|
aria-label="Task project and worktrees"
|
|
>
|
|
{creation.contextBar}
|
|
</TaskChatComposerBar>
|
|
) : null}
|
|
<div
|
|
className={taskChatComposerSurfaceClasses(mobile, streamlined)}
|
|
onKeyDownCapture={(e) => {
|
|
// 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();
|
|
changeMode(nextWorkMode(pendingMode));
|
|
}
|
|
}}
|
|
onPasteCapture={handlePasteCapture}
|
|
>
|
|
{creation?.header}
|
|
{uncertainSubmission ? (
|
|
<PendingSendNotice
|
|
className="mb-3"
|
|
noun="message"
|
|
persisted={Boolean(draftKey && loadDraftSubmission(draftKey)?.attemptId === uncertainSubmission.attemptId && replayableRequest(loadDraftSubmission(draftKey), draftOwner))}
|
|
resending={autoResend.resending}
|
|
stalled={autoResend.stalled}
|
|
onResendNow={replayableRequest(uncertainSubmission, draftOwner) ? autoResend.resendNow : undefined}
|
|
onCancel={cancelUncertainSubmission}
|
|
/>
|
|
) : null}
|
|
{!takeoverVisible && (pendingTakeover || takeover) ? (
|
|
<button
|
|
type="button"
|
|
className="mb-2 flex w-full items-center gap-2 rounded-md bg-muted/50 px-2.5 py-2 text-left text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
|
onClick={pendingTakeover?.onOpen}
|
|
data-testid="task-chat-pending-input-indicator"
|
|
>
|
|
<CircleHelp className="h-4 w-4 shrink-0" aria-hidden />
|
|
<span className="min-w-0 flex-1 truncate">
|
|
{pendingTakeover?.label ?? takeover?.label ?? "Pending input"}
|
|
</span>
|
|
<span className="shrink-0 font-medium">
|
|
{pendingTakeover?.count ?? takeover?.pendingCount ?? 1} pending
|
|
</span>
|
|
</button>
|
|
) : null}
|
|
{pause && conversationMode ? (
|
|
<div className="space-y-2">
|
|
<TaskChatPausedTakeover {...pause} hasDraft={Boolean(body.trim() || attachments.length)} />
|
|
<p className="text-xs text-muted-foreground">Send /new to start a fresh session and resume this conversation.</p>
|
|
</div>
|
|
) : null}
|
|
<ComposerImageAttachments
|
|
images={creation?.images ?? imageAttachments}
|
|
disabled={disabled || !!uncertainSubmission || submitting || creation?.readOnly}
|
|
onRemove={(id) => {
|
|
if (creation) creation.onRemoveImage?.(id);
|
|
else setAttachments(current => current.filter(item => item.id !== id));
|
|
}}
|
|
onRetry={creation ? undefined : (id) => {
|
|
const image = attachments.find(item => item.id === id);
|
|
if (image) retryUpload(image);
|
|
}}
|
|
/>
|
|
<div data-testid="task-chat-composer-input">
|
|
<MarkdownEditor
|
|
ref={editorRef}
|
|
value={body}
|
|
onChange={changeBody}
|
|
placeholder={
|
|
disabled
|
|
? (disabledReason ?? "Composer disabled")
|
|
: effectivePlaceholder
|
|
}
|
|
readOnly={disabled || !!uncertainSubmission || Boolean(creation && (submitting || creation.readOnly))}
|
|
mentions={mentions}
|
|
actionCommands={creation ? [] : conversationMode ? [{
|
|
id: "action:new", kind: "action", command: "new", name: "New session",
|
|
description: "Start fresh context here, preserving conversation history.", aliases: ["new"],
|
|
disabled,
|
|
}] : [goalCommandOption]}
|
|
onSubmit={() => void submit()}
|
|
submitKey={submitKey}
|
|
fileDropTarget="parent"
|
|
bordered={false}
|
|
className={cn(disabled && "opacity-60")}
|
|
contentClassName={
|
|
mobile
|
|
? "max-h-(--tc-composer-editor-max-height) 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"
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
{actionError ? (
|
|
<p
|
|
className="px-1 text-xs text-destructive"
|
|
role="alert"
|
|
data-testid={creation ? "task-chat-create-error" : "task-chat-goal-error"}
|
|
>
|
|
{actionError}
|
|
</p>
|
|
) : null}
|
|
|
|
{visibleAttachments.length > 0 ? (
|
|
<AttachmentGroup
|
|
className="mb-1 px-1"
|
|
data-testid="task-chat-composer-attachments"
|
|
>
|
|
{visibleAttachments.map((attachment) => {
|
|
const kind = fileKindForName(attachment.name);
|
|
const KindIcon = kind.icon;
|
|
const sizeLabel = formatFileSize(attachment.size);
|
|
return (
|
|
<Attachment
|
|
key={attachment.id}
|
|
size="sm"
|
|
state={
|
|
attachment.status === "uploading"
|
|
? "uploading"
|
|
: attachment.status === "error"
|
|
? "error"
|
|
: "done"
|
|
}
|
|
>
|
|
<AttachmentMedia>
|
|
{attachment.status === "uploading" ? (
|
|
<Loader2 className="animate-spin" aria-hidden />
|
|
) : (
|
|
<KindIcon aria-hidden />
|
|
)}
|
|
</AttachmentMedia>
|
|
<AttachmentContent>
|
|
<AttachmentTitle className="max-w-48">
|
|
{attachment.name}
|
|
</AttachmentTitle>
|
|
<AttachmentDescription className="max-w-48">
|
|
{attachment.status === "uploading"
|
|
? "Uploading…"
|
|
: attachment.status === "error"
|
|
? (attachment.error ?? "Upload failed")
|
|
: [kind.label, sizeLabel]
|
|
.filter(Boolean)
|
|
.join(" · ")}
|
|
</AttachmentDescription>
|
|
</AttachmentContent>
|
|
<AttachmentActions>
|
|
{attachment.status === "error" && attachment.file ? (
|
|
<AttachmentAction
|
|
aria-label={`Retry ${attachment.name}`}
|
|
disabled={!!uncertainSubmission}
|
|
onClick={() => retryUpload(attachment)}
|
|
>
|
|
<RotateCw aria-hidden />
|
|
</AttachmentAction>
|
|
) : null}
|
|
<AttachmentAction
|
|
aria-label={`Remove ${attachment.name}`}
|
|
disabled={!!uncertainSubmission}
|
|
onClick={() =>
|
|
setAttachments((prev) =>
|
|
prev.filter((item) => item.id !== attachment.id),
|
|
)
|
|
}
|
|
>
|
|
<X aria-hidden />
|
|
</AttachmentAction>
|
|
</AttachmentActions>
|
|
</Attachment>
|
|
);
|
|
})}
|
|
</AttachmentGroup>
|
|
) : null}
|
|
|
|
{creation?.details}
|
|
|
|
<div
|
|
className={cn("mt-2 flex items-center gap-x-2 gap-y-3", mobile && !queuedEdit ? "flex-nowrap" : "flex-wrap")}
|
|
data-testid="task-chat-composer-actions"
|
|
>
|
|
<div className="flex min-w-0 max-w-full items-center gap-2">
|
|
{canAcceptFiles ? (
|
|
<input ref={fileInputRef} type="file" className="hidden" multiple onChange={handleFileInputChange} />
|
|
) : null}
|
|
<ComposerAddMenu
|
|
privacy={creation?.privacy}
|
|
mode={pendingMode}
|
|
onModeChange={!queuedEdit && onWorkModeChange ? changeMode : undefined}
|
|
onAttachFile={canAcceptFiles ? () => fileInputRef.current?.click() : undefined}
|
|
onGoal={!queuedEdit && !conversationMode && attachments.length === 0 &&
|
|
runnerGoalCapability?.availability === "available" && onRunnerGoalCommand
|
|
? prepareGoal : undefined}
|
|
disabled={disabled || !!uncertainSubmission || Boolean(creation?.readOnly)}
|
|
mobile={mobile}
|
|
triggerTestId="task-chat-composer-add"
|
|
menuTestId="task-chat-composer-add-menu"
|
|
/>
|
|
{creation?.privacy?.private ? <ComposerPrivacyChip inherited={creation.privacy.inherited} onRemove={() => creation.privacy!.onChange(false)} disabled={disabled} /> : null}
|
|
{queuedEdit ? (
|
|
<span className="px-1 text-xs font-medium text-muted-foreground">
|
|
{queuedEdit.stale
|
|
? "Queued message changed"
|
|
: "Editing queued message"}
|
|
</span>
|
|
) : (
|
|
<ComposerModeChip mode={pendingMode} onRemove={onWorkModeChange ? () => changeMode("standard") : undefined}
|
|
disabled={disabled || !!uncertainSubmission} testId="task-chat-composer-mode" mobile={mobile} />
|
|
)}
|
|
</div>
|
|
|
|
<div className={cn("ml-auto flex min-w-0 max-w-full items-center gap-2", mobile && !queuedEdit && "flex-1 justify-end")}>
|
|
|
|
{(showAssignee || showConversationModel) && !queuedEdit && companyId && modelAgents ? (
|
|
<ComposerRunSettingsPicker
|
|
companyId={companyId}
|
|
assigneeValue={assigneeValue}
|
|
currentAssigneeValue={currentAssigneeValue}
|
|
options={reassignOptions ?? []}
|
|
agents={modelAgents}
|
|
overrides={assigneeAdapterOverrides}
|
|
settings={runSettings}
|
|
onSettingsChange={setRunSettings}
|
|
onAssigneeChange={updatePendingAssignee}
|
|
allowAssigneeChange={showAssignee && !conversationMode}
|
|
renderAssigneeIdentity={(value, label, placement) => (
|
|
<AssigneeIdentityAvatar
|
|
assigneeValue={value}
|
|
label={label}
|
|
agentMap={agentMap}
|
|
userProfileMap={userProfileMap}
|
|
placement={placement}
|
|
/>
|
|
)}
|
|
disabled={disabled}
|
|
mobile={mobile}
|
|
/>
|
|
) : showAssignee && !queuedEdit ? (
|
|
<InlineEntitySelector
|
|
value={assigneeValue}
|
|
options={reassignOptions ?? []}
|
|
placeholder="Assignee"
|
|
mobileTitle="Select assignee"
|
|
noneLabel="No assignee"
|
|
searchPlaceholder="Search assignees…"
|
|
emptyMessage="No matches."
|
|
onChange={updatePendingAssignee}
|
|
disabled={disabled}
|
|
triggerTestId="task-chat-composer-assignee"
|
|
className="h-8 gap-1.5 border-0 bg-transparent px-2.5 text-xs shadow-none hover:bg-accent focus-visible:bg-accent focus-visible:ring-0"
|
|
renderTriggerValue={(option) => {
|
|
return (
|
|
<>
|
|
<AssigneeIdentityAvatar
|
|
assigneeValue={option?.id ?? ""}
|
|
label={assigneeLabel}
|
|
agentMap={agentMap}
|
|
userProfileMap={userProfileMap}
|
|
placement="trigger"
|
|
/>
|
|
<span className="max-w-40 truncate">{assigneeLabel}</span>
|
|
<ChevronDown
|
|
className="h-3 w-3 shrink-0 text-muted-foreground"
|
|
aria-hidden
|
|
/>
|
|
</>
|
|
);
|
|
}}
|
|
renderOption={(option) => {
|
|
return (
|
|
<>
|
|
<AssigneeIdentityAvatar
|
|
assigneeValue={option.id}
|
|
label={option.label}
|
|
agentMap={agentMap}
|
|
userProfileMap={userProfileMap}
|
|
placement="option"
|
|
/>
|
|
<span className="truncate">{option.label}</span>
|
|
</>
|
|
);
|
|
}}
|
|
/>
|
|
) : null}
|
|
|
|
{queuedEdit ? (
|
|
<button
|
|
type="button"
|
|
onClick={onCancelQueuedEdit}
|
|
disabled={submitting}
|
|
className="h-8 shrink-0 rounded-md px-2.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-50"
|
|
>
|
|
Cancel
|
|
</button>
|
|
) : null}
|
|
<button
|
|
type="button"
|
|
onClick={() => void (showStop ? stopControl.stop() : submit())}
|
|
disabled={
|
|
showStop
|
|
? disabled || stopControl.stopping
|
|
: disabled ||
|
|
(Boolean(pause) && !canResetPausedConversation) ||
|
|
submitting ||
|
|
!!uncertainSubmission ||
|
|
uploadPending ||
|
|
uploadFailed ||
|
|
Boolean(creation?.submitDisabled) ||
|
|
(body.trim().length === 0 && attachedRefs.length === 0 && !creation?.canSubmitWithoutBody)
|
|
}
|
|
title={
|
|
showStop
|
|
? stopControl.stopping
|
|
? "Stopping…"
|
|
: "Stop response"
|
|
: queuedEdit
|
|
? queuedEdit.stale
|
|
? "Queue as new message"
|
|
: "Save queued message"
|
|
: uploadPending
|
|
? "Waiting for upload to finish"
|
|
: uploadFailed
|
|
? "Remove the failed attachment to send"
|
|
: `${creation?.submitLabel ?? "Send"} (${submitKey === "mod-enter" ? "⌘/Ctrl+Enter" : "Enter"})`
|
|
}
|
|
aria-label={
|
|
showStop
|
|
? stopControl.stopping
|
|
? "Stopping…"
|
|
: "Stop"
|
|
: queuedEdit
|
|
? queuedEdit.stale
|
|
? "Queue as new message"
|
|
: "Save queued message"
|
|
: creation?.submitLabel ?? "Send"
|
|
}
|
|
aria-busy={submitting}
|
|
className={cn(
|
|
"flex size-8 min-h-8 min-w-8 shrink-0 aspect-square items-center justify-center rounded-full transition-transform hover:scale-105 disabled:scale-100",
|
|
streamlined
|
|
? "bg-foreground text-background disabled:bg-foreground disabled:text-background disabled:opacity-100"
|
|
: "bg-primary text-primary-foreground disabled:bg-muted disabled:text-muted-foreground",
|
|
)}
|
|
data-testid={
|
|
showStop ? "task-chat-composer-stop" : "task-chat-composer-send"
|
|
}
|
|
data-slot="icon-button"
|
|
>
|
|
{submitting || (showStop && stopControl.stopping) ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
|
|
) : showStop ? (
|
|
<Square className="h-4 w-4 fill-current" aria-hidden />
|
|
) : (
|
|
<ArrowUp className="h-4 w-4" aria-hidden />
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{stopControl.error ? (
|
|
<p role="alert" className="text-xs text-destructive">
|
|
{stopControl.error}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|