mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 04:02:18 +01:00
## Thinking Path > - Paperclip keeps human-agent conversations attached to tasks. > - People need to read and reply comfortably from a phone. > - The mobile task page scrolled the document and positioned its composer with a sticky navigation offset. > - Safari's keyboard resize and later focus pan could move that document again, causing overlap, gaps, and jumps while editing. > - This change gives mobile and desktop the same message scroller and a normal flex footer. > - CSS owns the layout. A small VisualViewport adapter handles the visible height and Safari's stale document focus scroll. > - Streaming no longer measures every historical row while following the bottom. ## Linked Issues or Issue Description Fixes https://github.com/paperclipai/paperclip/issues/16497 Related: https://github.com/paperclipai/paperclip/pull/15245 also addresses keyboard positioning. This change additionally removes mobile document auto-follow and reduces historical-row measurement work. ## What Changed ### One scroll owner and a natural-flow composer Before: ```text Document scroll + mobile auto-follow └─ Conversation + sticky composer + animated navigation offset ``` After: ```text Height-constrained conversation shell ├─ Header ├─ Message scroller — owns follow/reading position └─ Composer — normal flex footer Navigation stays at the layout viewport bottom, behind the keyboard ``` - Reuse TaskMessageScroller on mobile. Delete the separate 136-line useWindowAutoFollow implementation and its superseded tests. - Keep the composer in normal flex flow. Remove its sticky bottom positioning and navigation-offset animation. - Constrain the mobile conversation with CSS. Classic tasks and other pages retain their existing layout. - Add a 47-line useMobileVisualViewport hook. It changes shell height on resize, not on typing or incoming messages. It never translates the shell or hides navigation. - Reject Safari's stale document focus pan only for an unzoomed, keyboard-open conversation. Inner message and editor scrolling stay native. - Keep a saved reading anchor when reading older messages. Skip historical-row measurements while following the bottom. - Update tests and the existing mobile streaming fixture. Keep only two layout-contract sentences in DESIGN.md. **Scope:** 19 files, +367 / -482 lines: 115 fewer lines overall. Production UI/CSS alone is +158 / -190 lines. No API, database, or dependency changes. ## Verification Bottom-follow is also saved explicitly for Back navigation. A new regression covers returning after rotation changes message wrapping, without restoring historical-row scans. ### Physical iPhone acceptance Tested on an iPhone 15 Pro running iOS 26.7.1. The reporter confirmed: - Opening the keyboard positions the composer correctly immediately, before any typing. - The installed web app works. - Long drafts, insertion, deletion, and sending are acceptable. The reporter described long drafts as not perfect but good enough. - Reading older messages during incoming output preserves position; returning to the bottom resumes following. - Rotation, keyboard close/reopen, and attachment/picker transitions work. Remote inspection corroborated the corrected open-keyboard state: visible height 358px, composer bottom 358px, document scrollY 0. Navigation remained display:block at y=631px, behind the keyboard. Earlier candidates failed device testing. The decisive trace showed a correct viewport resize followed about one frame later by a stale 337px native document focus pan. Typing caused Safari to correct it. Tests now cover that event ordering, the reverse ordering, and zoom panning. ### Performance evidence The existing TaskChatPerfHarness supplied 200 historical responses, 4,000 collapsed tools, and 30 updates at 100ms intervals. | Measurement | Before | After | | --- | ---: | ---: | | Historical row geometry reads | 6,000 | 0 | | Renderer main-thread work | 331.2ms | 273.5ms | | Largest task | 27.1ms | 12.3ms | This is one instrumented Chromium development run on the Mac, not a phone benchmark or a statistical speedup claim. The zero-row-measurement result also has a regression test that failed with 6,000 reads before the change. To reproduce, serve ui/tests/task-chat-perf.html with the UI Vite server. Record 30 streaming updates at the bottom. Inspect TaskMessageScroller and row getBoundingClientRect calls. Existing historical-render tests also confirm that unchanged Markdown does not reparse on live-tail updates. ### Automated and full-app checks - [Current-head CI](https://github.com/paperclipai/paperclip/actions/runs/39862524031) is green: 54 successful checks and 2 skipped checks. Greptile rates the current head 5/5, confirms the Back-navigation fix, and has no unresolved findings. - **601 focused tests pass** across the task page, layout, composer, scroller, viewport adapter, and historical-render coverage. - UI typecheck and production build pass. - Token gates, Storybook catalog checks, and catalog/map structural tests pass. - Independent review of the final source tree passed with no findings. - The full source app ran on macOS with an isolated database and saved conversation history. A reply sent through the real UI persisted after reload. Provider execution was not part of this layout test. - **Full local repository checks did not pass.** Build/typecheck stop at missing Rust cargo. The full test run reported 142 failed server tests and 3 failed suites; missing cargo is confirmed in those three suites. The other server failures were not diagnosed in this UI task. Current-head CI now supplies the repository-wide evidence; these local failures remain disclosed. ## Risks - Mobile conversations now share the desktop scroll owner. Navigation, loading, history restoration, and reading-position tests cover that change. - The keyboard-state threshold is a 120px viewport reduction. Accessory-only keyboards retain the normal navigation spacing. - The document-scroll fallback is scoped to rendered conversations with an open keyboard at scale 1. It does not handle inner scrolling or prevent zoom panning. - Long-draft behavior is accepted, with minor polish still possible. - The UI change is reversible and requires no data migration. ## Model Used OpenAI GPT-6 Astra, exact model ID gpt-6-astra, through OpenCode. Capabilities used: reasoning, code editing, tests, Chromium performance tracing, and Safari remote inspection. The harness does not expose the context-window size. An independent general-purpose subagent reviewed the final tree; its serving model ID was not exposed. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with Fixes / Closes / Refs OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links - [x] My branch name describes the change and contains no internal Paperclip ticket id or instance-derived details - [x] I have run focused tests locally and they pass; repository-wide failures are disclosed above - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge
1646 lines
60 KiB
TypeScript
1646 lines
60 KiB
TypeScript
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 { 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 { ComposerRunSettingsPicker } from "./ComposerRunSettingsPicker";
|
||
import { TaskChatComposerBar } from "./TaskChatComposerBar";
|
||
import { ComposerAddMenu, ComposerModeChip, ComposerPrivacyChip } 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> | 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;
|
||
privacy?: { private: boolean; inherited?: string; onChange: (value: boolean) => void };
|
||
onSelectFiles: (files: File[]) => void;
|
||
runSettings: ComposerRunSettings | null;
|
||
onRunSettingsChange: (settings: ComposerRunSettings | null) => void;
|
||
};
|
||
confirmedSubmissionIds?: ReadonlySet<string>;
|
||
onStop?: () => Promise<void>;
|
||
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>;
|
||
/** Fallback upload path: returns a URL for inline image markdown. */
|
||
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;
|
||
onReviewConversation?: () => Promise<void>;
|
||
/** 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;
|
||
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,
|
||
creation,
|
||
confirmedSubmissionIds,
|
||
onStop,
|
||
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,
|
||
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 [localBody, setBody] = useState(() => (draftKey ? loadDraft(draftKey) : ""));
|
||
const body = creation ? creation.value : localBody;
|
||
const [submitting, setSubmitting] = useState(false);
|
||
const [reviewError, setReviewError] = 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 [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;
|
||
const pendingDraftRef = useRef<{
|
||
draftKey: string;
|
||
attemptId: string;
|
||
submittedBody: string;
|
||
submittedAttachmentIds: string[];
|
||
} | null>(null);
|
||
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,
|
||
});
|
||
}
|
||
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,
|
||
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 ? currentAssigneeValue : pendingAssignee ?? suggestedAssigneeValue ?? 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) {
|
||
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);
|
||
}
|
||
|
||
/** Upload an image and return its URL for inline `` markdown. */
|
||
async function uploadInlineImage(file: File): Promise<string> {
|
||
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(
|
||
``,
|
||
);
|
||
} catch {
|
||
// uploadInlineImage retains the failed receipt in the removable chip row.
|
||
}
|
||
}
|
||
|
||
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());
|
||
}
|
||
|
||
/**
|
||
* 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<HTMLDivElement>) {
|
||
if (pause || !canAcceptFiles) return;
|
||
const files = Array.from(evt.clipboardData?.files ?? []);
|
||
if (files.length === 0) return;
|
||
if (creation) {
|
||
if (onImageUpload) {
|
||
const nonImages = files.filter((file) => !file.type.startsWith("image/"));
|
||
if (nonImages.length === 0) return;
|
||
creation.onSelectFiles(nonImages);
|
||
if (nonImages.length !== files.length) return;
|
||
} else {
|
||
creation.onSelectFiles(files);
|
||
}
|
||
evt.preventDefault();
|
||
evt.stopPropagation();
|
||
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;
|
||
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(error instanceof Error ? error.message : "Failed to create task. Try again.");
|
||
} 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) => `[${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 ? (
|
||
<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")}>
|
||
{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={cn(
|
||
streamlined
|
||
? "paperclip-task-chat-composer rounded-(--radius-task-composer) border border-border bg-card p-(--sz-18px) shadow-(--shadow-task-composer) dark:border-0 dark:bg-muted dark:shadow-none"
|
||
: "paperclip-task-chat-composer rounded-xl bg-card p-(--sz-18px)",
|
||
mobile && "p-3",
|
||
)}
|
||
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 ? (
|
||
<div
|
||
role="alert"
|
||
className="mb-3 space-y-2 rounded-md border border-border bg-muted p-3 text-sm"
|
||
>
|
||
<p>
|
||
We couldn’t confirm whether this comment was saved. It may already
|
||
be in the conversation. Review it before starting another draft.
|
||
</p>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={reviewUncertainSubmission}
|
||
>
|
||
Review conversation
|
||
</Button>
|
||
{reviewError ? (
|
||
<p>Couldn’t refresh the conversation. Try reviewing it again.</p>
|
||
) : null}
|
||
{uncertainSubmission.reviewed ? (
|
||
<>
|
||
<p>
|
||
Discarding this draft does not remove any saved comment or
|
||
uploaded file.
|
||
</p>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={discardUncertainDraft}
|
||
>
|
||
Discard draft and start new
|
||
</Button>
|
||
</>
|
||
) : null}
|
||
</div>
|
||
) : 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}
|
||
<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)}
|
||
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()}
|
||
imageUploadHandler={
|
||
canAcceptFiles && (!creation || onImageUpload) ? uploadInlineImage : undefined
|
||
}
|
||
onDropFile={canAcceptFiles && !creation ? attachNonImageFile : undefined}
|
||
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>
|
||
<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={Boolean(creation)} 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}
|
||
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 && !queuedEdit && companyId && modelAgents ? (
|
||
<ComposerRunSettingsPicker
|
||
companyId={companyId}
|
||
assigneeValue={assigneeValue}
|
||
currentAssigneeValue={currentAssigneeValue}
|
||
options={reassignOptions ?? []}
|
||
agents={modelAgents}
|
||
overrides={assigneeAdapterOverrides}
|
||
settings={runSettings}
|
||
onSettingsChange={setRunSettings}
|
||
onAssigneeChange={updatePendingAssignee}
|
||
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 ? `${creation.submitLabel} (⌘+Enter)` : "Send (⌘+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>
|
||
);
|
||
}
|