Files
PaperClipAI/ui/src/components/task-chat/TaskChatComposer.tsx
T
Clément df5a78cf91 fix(ui): keep iPhone task chat stable while typing and streaming (#17232)
## 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
2026-10-26 08:44:39 -07:00

1646 lines
60 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 `![](src)` 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(
`![${escapeMarkdownLabel(file.name)}](${url})`,
);
} 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>
);
}