mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-06 23:20:53 +01:00
## Thinking Path > - Paperclip helps people manage AI agents at work. > - Cursor Cloud hosts execution behind its API. > - Product setup still expected a Paperclip Computer. > - Managed-only tenants therefore rejected valid setup. > - This admits the validated API-owned controller. > - Authority, Stop proof, budgets and cleanup remain enforced. ## Linked Issues or Issue Description Refs #18068 and #18069; both dependencies have merged. **What happened?** Managed-only setup still requested a Computer. Live tests exposed missing Cursor Stop proof and duplicate replay. **Expected behavior** Use Cursor’s API controller. Verify provider/controller termination and replay each log identity once. **Steps to reproduce** Enable native Runner/managed-only. Configure Cursor model/repository/key without a Computer, then Test, finish setup, run and Stop. ## What Changed - Derive API-only placement from validated native Cursor configuration; reject placement/command/workspace/home overrides and keep Kubernetes-only execution closed. - Skip Computers/checkout/workspace realization. Keep scoped tools, completion, private staging, budgets, leases and recovery. Persist placement; reject contradictions. - Align Cloud cards, Test, Save and Finish setup; hold unready drafts. Legacy keeps its Computer, credential and policy checks. - Verify Stop from exact API ownership, audited intent, provider-terminal proof, no Computer/process ownership and controller retirement. Recheck immediate/deferred retirement; wake existing recovery on durable evidence. - Clear warnings when the actual stopped target later has authoritative confirmation. Keep missing/other-run negatives and the existing predicate. - Publish safe status refresh after full-proof ACK. Retain bounded native/physical cancellation history; add no poller or weaker proof path. ## Verification Core merged as `3910352c923a`; Product merged as `27d35314be8e`. Public branch `5f86eb66b081` has tree `67344151d303`. Its exact-head Greptile summary is 5/5; the fresh audit found zero unresolved threads and complete pagination. The controlled workspace retry and required summary passed. All 59 current-head contexts are terminal and clean. Prior green-36 evidence keeps its identity. [Current CI](https://github.com/paperclipai/paperclip/actions/runs/41680511731) first failed workspaces-a 1/2: the CLI company-import/export beforeAll 30-second runtime-info/child-PID/HTTP readiness wait expired. All 431 UI files/4,714 tests and 285 other CLI tests passed (one skip). The cause remains UNKNOWN. Child output records startup recovery complete, but buffered CI prefixes do not establish its relation to the waiter deadline; runtime-file/PID/last-health state was not captured. Both unchanged local probes were RED. The second observed dependency preflight still pending at the deadline; observer overhead was not quantified. Neither proves the CI cause. After all functional jobs finished, only the queued metadata summary was cancelled; the other 48 records stayed unchanged. Attempt 1 retains 46 successes, the original failure, one skip and the cancelled summary. One same-head debug retry was admitted at 18:42 UTC on November 3. Its workspace job passed at 19:23 UTC on the same checkout: UI 431 files/4,714 tests and CLI 33 files/286 tests, including company import/export. Test caching was disabled. The new `ci / verify` summary passed; workflow attempt 2 completed SUCCESS. Required `ci / e2e` also passed. The final check at 19:53 UTC on November 3 found all 59 current-head contexts terminal and clean: zero pending and zero failing. The 47 inherited completed/skipped rows are not new executions. No code, assertion or timeout changed; success does not explain the original failure or erase the local REDs. CI checkout is `1ea9d8608ced`/tree `78cb1ec13d59` (parents `2e37c73c05fe`, 5f86); both workflows use 2e37. Its 20 extra paths add low-memory build/Rust task-memory. Scripts cohere; later matrix changes only labels, with no newer debug-cache steps. Static audit found no blocker. Supported API arguments omit the memory flag: no default monitor/instruction changes. Flagged external launchers can change Cursor instructions/digests and remain unqualified. The merge added 41 master-identical paths, retained the green-36 inbox hunk and found UI fixtures matching upstream. Startup/backup, recovery projection, sleep diagnostics and binary backup change production. Static audit found eight Stop/ACK owner blobs and 37 sleep-work SQL templates unchanged, with sleep fences/single-flight accounting intact. Exact precommit tree: 416/416 across eight server files, binary backup 1/1, server types PASS; repeated scopes are not summed. The deferred-retirement P1 reproduced three real-DB/owned-process failures; missing proof stayed unverified. Recovery passed 47 process cases/33 proof negatives. Warning repair binds actual targets to same-run proof. Validation passed 729 scoped UI cases, a separate 359-case thread run, 111 backend cases (84 CAS, 6 assembled, 21 activity/privacy), types/tokens. Proof validators/predicate are unchanged; no new poller or increased timeout. The d8d3 attempt stays FAILED: 45 passes, two workspaces-a failures, derived verify failure and one skip. Both shards shared three failures: two successful mocks omitted the account guard’s required userId. The two-line fixture repair changed no guard, assertion or timeout. Three failures reproduced; 54 access/auth tests then passed, including account-change/denial negatives, plus UI types/tokens. The 69285 attempt stays FAILED: 49 jobs, 46 passed, serialized shard 9 failed, its verify aggregate failed and one selection job skipped. The shard passed 575/576 cases. Its aborted-snapshot case expected nine computes but saw eight: a final HTTP 200 coalesced while seven ordinary reads had not proved the aborted compute settled. Earlier CI and the unmodified local baseline passed with a miss; no local RED is claimed. The repair awaits the snapshot, allows exactly one extra GET if coalesced, then requires a miss and retains nine computes/all HTTP429 assertions. No production code, hook, loop, sleep or timeout changed. Six inbox and seven cache/admission cases passed locally, plus server types; 30 unrelated cases were filtered. Green 36 passed 576/576 without taking the extra-GET branch; local runs also missed that schedule. Current CI event merge 1ea9 now passed 576/576 and naturally exercised the former failure: coalesced (count 2, 1ms), then exactly one fresh miss (count 1, 16ms), with strict nine-compute/HTTP429 assertions intact. No instrumentation or retry was added. Earlier compatibility passed scoped remote/inbox, routine, API-placement and UI tests, plus types/migrations/snapshots/maps/tokens. The 89-path adoption restored fleet routing without workflow overrides or added skips. Each result retains its source identity. Live qualification remains actual `9af1a5de42a2`: unchanged Artifact grader PASSED 17/17 and active-Stop grader PASSED 21/21. All three provider test resources were deleted canonically with GET404; post-delete download returned the exact 27 bytes. Histories stayed stable (Artifact 121 and Stop 67 canonical identities), with 21 company runs, zero active work and no accounting/cancellation/reservation debt or integrity findings. Saved settings reopened; no-prompt Test/Linux admission passed with `claude-sonnet-4-5`. No new Save is claimed. The first Stop sampling case stays FAILED on a 10.373-second gap. Unchanged retry kept all 24 samples: 35.076 seconds quiet, maximum gap 4.584 seconds, plus a separate CANCELLED/artifact-absent read after 180 seconds. Initial grading rejected locator spelling before functional checks; metadata changed only after same-file/inode/byte proof, with raw evidence/oracles unchanged. Original Question/Plan/reconnect and historical deleted-artifact results keep their identities. Git trees verified 11 history-only conflicts and six earlier mainline paths. Hosted 9af to the public BRANCH differs in 222 paths; 13,500 other mode/blob leaves match. Runner runtime implementations and six observer/driver pins match 9af on that branch only; inherited `drivers/acpx/runtime-host.test.ts` differs. No all-Runner/src, CI/latest-master runtime or image equality is claimed. Later static audits through `8ab89f41e499` found no concrete API-only blocker. That base adds completed-history sleep proofs and an MCP cleanup migration; protected Stop/ACK owners are unchanged. These later changes are outside this CI and have no live qualification here. Host duplicate-card and later recovery/UI repairs retain separate proof, not deployed 9af identity. Direct-key coverage requires absent aiConnection; API-only placement skips inherited remote-workspace disk-full paths, and config-read gates match their parent. Other retained failures: stale fixture 132/133; E8 48/135 with 87 reset-hook failures; Mac 17 SQL failures/skips; title/startup/custody/Stop/deletion unknown causes and bounded recoveries; superseded warning/50 duplicate identities; pre-repair ACK/Core CI failures. Fixture RED/GREEN passed 7/7 browser and 121/121 shutdown tests with a failing broken-unsubscribe control; stable WSP wrapper passed 128 after physical teardown failure. No full local-suite pass is inferred. All paid attempts remain recorded; vendor dollar cost is UNKNOWN. ## Risks Experimental: Cursor independently acknowledges neither the effective model nor instruction digest before the first prompt. Explicit values are submitted and behavior checked live; unpriced provider dollar cost stays UNKNOWN. The placement exception applies only to validated native Cursor API execution. Other local work remains denied under managed-only policy. Missing/stale Stop or cleanup proof stays pending; damaged archives remain failed. Explicit Legacy keeps its existing policy owners. ## Model Used OpenAI Codex/GPT-6 family: tools, execution, browser checks, delegated review. API ID/context window unavailable. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used and the limits of available model metadata - [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 linked public issues or described the issue in this PR - [x] I have not referenced internal or instance-local Paperclip issues or links - [x] My branch describes the change and contains no internal ticket ID - [x] I have run the scoped local tests reported above; retained failures are stated separately - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation - [x] I have considered and documented the risks - [x] All required CI gates pass on the current head - [x] Fresh Greptile is 5/5 with no unresolved findings on the current head - [x] I will address all reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
1697 lines
64 KiB
TypeScript
1697 lines
64 KiB
TypeScript
import { useCurrentAccountGuard } from "@/hooks/useAccountBoundMutation";
|
|
import { AgentAvatar } from "../AgentAvatar";
|
|
import {
|
|
useEffect,
|
|
useId,
|
|
useRef,
|
|
useState,
|
|
type ChangeEvent,
|
|
type ClipboardEvent as ReactClipboardEvent,
|
|
type ReactNode,
|
|
} from "react";
|
|
import { cn } from "@/lib/utils";
|
|
import { taskChatComposerSurfaceClasses } from "./composer-surface";
|
|
import { useComposerStop } from "@/hooks/useComposerStop";
|
|
import { useStreamlinedTaskChatPresentation } from "./presentation-mode";
|
|
import {
|
|
DRAFT_DEBOUNCE_MS,
|
|
clearDraft,
|
|
loadDraft,
|
|
loadDraftAttachments,
|
|
saveDraft,
|
|
saveDraftAttachments,
|
|
loadDraftSubmission,
|
|
replayableRequest,
|
|
saveDraftSubmission,
|
|
clearDraftSubmission,
|
|
settleDraftSubmission,
|
|
type ComposerDraftSubmission,
|
|
type ComposerSendRequest,
|
|
} from "@/lib/composer-draft";
|
|
import { classifySendFailure, type SendOutcome } from "@/lib/pending-send";
|
|
import { describeError } from "@/api/errors";
|
|
import { useAutoResend } from "@/hooks/useDurableSubmit";
|
|
import { useDraftOwner } from "@/hooks/useDraftOwner";
|
|
import { useRequestOwnerBinding } from "@/hooks/useRequestOwnerBinding";
|
|
import { PendingSendNotice } from "@/components/PendingSendNotice";
|
|
import {
|
|
ArrowUp,
|
|
RotateCw,
|
|
Square,
|
|
ChevronDown,
|
|
CircleHelp,
|
|
Loader2,
|
|
X,
|
|
} from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Attachment,
|
|
AttachmentAction,
|
|
AttachmentActions,
|
|
AttachmentContent,
|
|
AttachmentDescription,
|
|
AttachmentGroup,
|
|
AttachmentMedia,
|
|
AttachmentTitle,
|
|
} from "@/components/ui/attachment";
|
|
import { fileKindForName, formatFileSize, isImageAttachment } from "./task-chat-attachments";
|
|
import {
|
|
MarkdownEditor,
|
|
type MarkdownEditorRef,
|
|
} from "@/components/MarkdownEditor";
|
|
import { nextWorkMode } from "@/lib/work-mode-meta";
|
|
import { trackRecentAssignee, trackRecentAssigneeUser } from "@/lib/recent-assignees";
|
|
import {
|
|
InlineEntitySelector,
|
|
type InlineEntityOption,
|
|
} from "@/components/InlineEntitySelector";
|
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
|
import type { MentionOption } from "@/components/MarkdownEditor";
|
|
import type { IssueAttachment, IssueWorkMode } from "@paperclipai/shared";
|
|
import type { RunnerGoalCapability } from "@paperclipai/shared";
|
|
import type { ActionCommandOption } from "@/context/EditorAutocompleteContext";
|
|
import { TaskChatComposerTakeoverActionsContext } from "./TaskChatComposerTakeoverContext";
|
|
|
|
import { TaskChatPausedTakeover, type TaskComposerPause } from "./TaskChatPausedTakeover";
|
|
import { ComposerImageAttachments, type ComposerImageAttachment } from "./ComposerImageAttachments";
|
|
import { ComposerRunSettingsPicker } from "./ComposerRunSettingsPicker";
|
|
import { TaskChatComposerBar } from "./TaskChatComposerBar";
|
|
import { ComposerAddMenu, ComposerModeChip, ComposerPrivacyChip } from "./ComposerAddMenu";
|
|
import { supportsComposerModel, type ComposerRunSettings } from "./composer-run-settings";
|
|
import { useCoordinatorPresentation } from "@/components/coordinator/CoordinatorPresentation";
|
|
import type { Agent, IssueAssigneeAdapterOverrides } from "@paperclipai/shared";
|
|
|
|
/** Structurally identical to IssueChatThread's module-private CommentReassignment. */
|
|
export interface CommentReassignment {
|
|
assigneeAgentId: string | null;
|
|
assigneeUserId: string | null;
|
|
}
|
|
|
|
export interface TaskChatComposerTakeover {
|
|
id: string;
|
|
label: string;
|
|
pendingCount: number;
|
|
content: ReactNode;
|
|
/** Hides the takeover without resolving it; the pending indicator can reopen it. */
|
|
onDismiss: () => void;
|
|
onSkip: () => Promise<void> | void;
|
|
onShowNext?: () => void;
|
|
/** Places Skip inside a structured question form's action row. */
|
|
inlineSkip?: boolean;
|
|
hideLabel?: boolean;
|
|
/** Some decision surfaces already provide a non-accept path of their own. */
|
|
hideSkip?: boolean;
|
|
}
|
|
|
|
interface TaskChatComposerProps {
|
|
onAdd?: (
|
|
body: string,
|
|
reopen?: boolean,
|
|
reassignment?: CommentReassignment,
|
|
attachmentIds?: string[],
|
|
clientRequestId?: string,
|
|
runSettings?: ComposerRunSettings,
|
|
) => Promise<void> | void;
|
|
/** New tasks share the editor and controls, but retain their draft until creation succeeds. */
|
|
creation?: {
|
|
value: string;
|
|
onChange: (value: string) => void;
|
|
onSubmit: (body: string, mode: IssueWorkMode, settings: ComposerRunSettings | null) => Promise<void>;
|
|
submitLabel: string;
|
|
canSubmitWithoutBody?: boolean;
|
|
header?: ReactNode;
|
|
details?: ReactNode;
|
|
contextBar?: ReactNode;
|
|
submitDisabled?: boolean;
|
|
/** Lock the editor, e.g. while a submitted create waits to resend. */
|
|
readOnly?: boolean;
|
|
privacy?: { private: boolean; inherited?: string; onChange: (value: boolean) => void };
|
|
onSelectFiles: (files: File[]) => void;
|
|
images?: ComposerImageAttachment[];
|
|
onRemoveImage?: (id: string) => void;
|
|
runSettings: ComposerRunSettings | null;
|
|
onRunSettingsChange: (settings: ComposerRunSettings | null) => void;
|
|
};
|
|
confirmedSubmissionIds?: ReadonlySet<string>;
|
|
onStop?: () => Promise<void>;
|
|
confirmedStoppedRunIds?: ReadonlySet<string>;
|
|
stopPending?: boolean;
|
|
stopScope?: "leaf" | "subtree";
|
|
workMode: IssueWorkMode;
|
|
onWorkModeChange?: (mode: IssueWorkMode) => Promise<void> | void;
|
|
disabled?: boolean;
|
|
disabledReason?: string | null;
|
|
placeholder?: string;
|
|
/** Preferred upload path: attaches the file to the task (mirrors legacy). */
|
|
onAttachImage?: (file: File) => Promise<IssueAttachment | void>;
|
|
onImageUpload?: (file: File) => Promise<string>;
|
|
/** Mentionable entities for the editor's @-autocomplete. */
|
|
mentions?: MentionOption[];
|
|
enableReassign?: boolean;
|
|
conversationMode?: boolean;
|
|
reassignOptions?: InlineEntityOption[];
|
|
agentMap?: ReadonlyMap<string, import("../AgentAvatar").AvatarAgent & { icon?: string | null }>;
|
|
modelAgents?: ReadonlyMap<string, Agent>;
|
|
companyId?: string | null;
|
|
assigneeAdapterOverrides?: IssueAssigneeAdapterOverrides | null;
|
|
userProfileMap?: ReadonlyMap<
|
|
string,
|
|
{ label: string; image: string | null }
|
|
> | null;
|
|
currentAssigneeValue?: string;
|
|
suggestedAssigneeValue?: string;
|
|
onPendingAssigneeChange?: (value: string | null) => void;
|
|
issueStatus?: string;
|
|
/** Mobile document-flow host: 16px editor text so iOS doesn't zoom on focus. */
|
|
mobile?: boolean;
|
|
/** Storage key used to restore, persist, and clear this task's text draft. */
|
|
draftKey?: string;
|
|
/** When set, the main composer temporarily edits this queued message. */
|
|
queuedEdit?: { commentId: string; body: string; stale?: boolean } | null;
|
|
onSaveQueuedEdit?: (commentId: string, body: string) => Promise<void>;
|
|
onCancelQueuedEdit?: () => void;
|
|
pause?: TaskComposerPause | null;
|
|
takeover?: TaskChatComposerTakeover | null;
|
|
pendingTakeover?: {
|
|
count: number;
|
|
label?: string;
|
|
onOpen: () => void;
|
|
} | null;
|
|
runnerGoalCapability?: RunnerGoalCapability | null;
|
|
onRunnerGoalCommand?: (
|
|
command: RunnerGoalComposerCommand,
|
|
) => Promise<void> | void;
|
|
onRunnerGoalReassign?: (
|
|
reassignment: CommentReassignment,
|
|
) => Promise<void> | void;
|
|
}
|
|
|
|
export type RunnerGoalComposerCommand =
|
|
| { action: "focus" }
|
|
| { action: "create"; objective: string }
|
|
| { action: "edit" }
|
|
| { action: "pause" }
|
|
| { action: "resume" }
|
|
| { action: "clear" };
|
|
|
|
export type ParsedRunnerGoalCommand =
|
|
| { matched: false }
|
|
| { matched: true; command: RunnerGoalComposerCommand }
|
|
| { matched: true; error: string };
|
|
|
|
function normalizeRunnerGoalCommandText(value: string): string {
|
|
const trimmed = value.trim();
|
|
// MDXEditor's link extension can reinterpret a selected action command plus
|
|
// its subsequently typed argument as one relative autolink. Accept only the
|
|
// exact whole-document shape it generates so the action still cannot fall
|
|
// through as a comment. Ordinary Markdown links remain ordinary comments.
|
|
const relativeAutolink = trimmed.match(
|
|
/^\[\/(?:go(?:al)?)?[ \t\u00a0]*\]\(<(\/goal(?:[ \t\u00a0].*)?)>\)$/s,
|
|
);
|
|
if (relativeAutolink) return relativeAutolink[1]!.replaceAll("\u00a0", " ");
|
|
|
|
const encodedAutolink = trimmed.match(
|
|
/^\[\/(?:go(?:al)?)?[ \t\u00a0]*\]\((\/goal(?:%20|%C2%A0).*)\)$/s,
|
|
);
|
|
if (encodedAutolink) {
|
|
try {
|
|
return decodeURIComponent(encodedAutolink[1]!).replaceAll("\u00a0", " ");
|
|
} catch {
|
|
return trimmed;
|
|
}
|
|
}
|
|
return trimmed.replaceAll("\u00a0", " ");
|
|
}
|
|
|
|
export function parseRunnerGoalCommand(value: string): ParsedRunnerGoalCommand {
|
|
const trimmed = normalizeRunnerGoalCommandText(value);
|
|
if (!trimmed) return { matched: false };
|
|
const firstWhitespace = trimmed.search(/\s/);
|
|
const firstToken =
|
|
firstWhitespace === -1 ? trimmed : trimmed.slice(0, firstWhitespace);
|
|
if (firstToken !== "/goal") return { matched: false };
|
|
const remainder =
|
|
firstWhitespace === -1 ? "" : trimmed.slice(firstWhitespace).trim();
|
|
if (!remainder) return { matched: true, command: { action: "focus" } };
|
|
const [subcommand, ...extra] = remainder.split(/\s+/);
|
|
if (["edit", "pause", "resume", "clear"].includes(subcommand)) {
|
|
if (extra.length > 0) {
|
|
return {
|
|
matched: true,
|
|
error: `/goal ${subcommand} does not accept extra arguments.`,
|
|
};
|
|
}
|
|
return {
|
|
matched: true,
|
|
command: { action: subcommand as "edit" | "pause" | "resume" | "clear" },
|
|
};
|
|
}
|
|
return { matched: true, command: { action: "create", objective: remainder } };
|
|
}
|
|
|
|
function identityInitials(label: string): string {
|
|
const parts = label.trim().split(/\s+/).filter(Boolean);
|
|
if (parts.length === 0) return "?";
|
|
if (parts.length === 1) return parts[0]!.slice(0, 2).toUpperCase();
|
|
return `${parts[0]![0]}${parts[parts.length - 1]![0]}`.toUpperCase();
|
|
}
|
|
|
|
function AssigneeIdentityAvatar({
|
|
assigneeValue,
|
|
label,
|
|
agentMap,
|
|
userProfileMap,
|
|
placement,
|
|
}: {
|
|
assigneeValue: string;
|
|
label: string;
|
|
agentMap: ReadonlyMap<string, import("../AgentAvatar").AvatarAgent & { icon?: string | null }> | undefined;
|
|
userProfileMap:
|
|
| ReadonlyMap<string, { label: string; image: string | null }>
|
|
| null
|
|
| undefined;
|
|
placement: "trigger" | "option";
|
|
}) {
|
|
if (assigneeValue.startsWith("agent:")) {
|
|
const agentId = assigneeValue.slice("agent:".length);
|
|
const icon = agentMap?.get(agentId)?.icon ?? "bot";
|
|
return (
|
|
<span
|
|
className="shrink-0"
|
|
data-assignee-identity={assigneeValue}
|
|
data-assignee-trigger-icon={placement === "trigger" ? icon : undefined}
|
|
data-assignee-option-icon={
|
|
placement === "option" ? assigneeValue : undefined
|
|
}
|
|
>
|
|
<AgentAvatar agent={agentMap?.get(agentId) ?? { id: agentId }} size={16} />
|
|
</span>
|
|
);
|
|
}
|
|
|
|
if (assigneeValue.startsWith("user:")) {
|
|
const userId = assigneeValue.slice("user:".length);
|
|
const profile = userProfileMap?.get(userId);
|
|
const resolvedLabel = profile?.label ?? label;
|
|
return (
|
|
<Avatar
|
|
size="xs"
|
|
className="shrink-0"
|
|
data-assignee-identity={assigneeValue}
|
|
data-assignee-trigger-avatar={
|
|
placement === "trigger" ? userId : undefined
|
|
}
|
|
data-assignee-option-avatar={
|
|
placement === "option" ? assigneeValue : undefined
|
|
}
|
|
>
|
|
{profile?.image ? <AvatarImage src={profile.image} alt="" /> : null}
|
|
<AvatarFallback>{identityInitials(resolvedLabel)}</AvatarFallback>
|
|
</Avatar>
|
|
);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/** v7 per-mode placeholder copy; `{agent}` is the pending assignee's name. */
|
|
function modePlaceholder(mode: IssueWorkMode, agentName: string, mobile: boolean): string {
|
|
if (mobile) {
|
|
if (mode === "planning") return `Plan with ${agentName}…`;
|
|
if (mode === "ask") return `Ask ${agentName}…`;
|
|
return `Message ${agentName}…`;
|
|
}
|
|
switch (mode) {
|
|
case "planning":
|
|
return `Plan with ${agentName} — shapes the plan doc, no code changes…`;
|
|
case "ask":
|
|
return `Ask ${agentName} a question — read-only, nothing runs…`;
|
|
default:
|
|
return `Message ${agentName} — describe what you want done…`;
|
|
}
|
|
}
|
|
|
|
type ComposerAttachment = {
|
|
id: string;
|
|
attachmentId?: string;
|
|
inline?: boolean;
|
|
contentType?: string;
|
|
name: string;
|
|
size?: number;
|
|
status: "uploading" | "attached" | "error";
|
|
error?: string;
|
|
/** Set once uploaded; the submit path appends `[name](contentPath)` lines. */
|
|
contentPath?: string;
|
|
/**
|
|
* The picked file, kept after a failed upload so the chip can offer Retry.
|
|
* Uploads are not deduped by the server, so they never resend on their own.
|
|
*/
|
|
file?: File;
|
|
};
|
|
|
|
type QueuedEditBackup = {
|
|
body: string;
|
|
attachments: ComposerAttachment[];
|
|
pendingMode: IssueWorkMode;
|
|
pendingAssignee: string | null;
|
|
};
|
|
|
|
/** Local duplicate of IssueChatThread's module-private helper (same rule). */
|
|
function shouldImplicitlyReopenComment(
|
|
issueStatus: string | undefined,
|
|
assigneeValue: string,
|
|
) {
|
|
const resumesToTodo =
|
|
issueStatus === "done" ||
|
|
issueStatus === "cancelled" ||
|
|
issueStatus === "blocked";
|
|
return resumesToTodo && assigneeValue.startsWith("agent:");
|
|
}
|
|
|
|
function parseAssigneeValue(value: string): CommentReassignment | undefined {
|
|
if (!value) return { assigneeAgentId: null, assigneeUserId: null };
|
|
if (value.startsWith("agent:")) {
|
|
const id = value.slice("agent:".length);
|
|
return id ? { assigneeAgentId: id, assigneeUserId: null } : undefined;
|
|
}
|
|
if (value.startsWith("user:")) {
|
|
const id = value.slice("user:".length);
|
|
return id ? { assigneeAgentId: null, assigneeUserId: id } : undefined;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function escapeMarkdownLabel(name: string): string {
|
|
return name.replace(/[[\]]/g, "\\$&");
|
|
}
|
|
|
|
export function TaskChatComposer({
|
|
onAdd,
|
|
creation,
|
|
confirmedSubmissionIds,
|
|
onStop,
|
|
confirmedStoppedRunIds,
|
|
stopPending = false,
|
|
workMode,
|
|
onWorkModeChange,
|
|
disabled = false,
|
|
disabledReason,
|
|
placeholder,
|
|
onAttachImage,
|
|
onImageUpload,
|
|
mentions,
|
|
enableReassign = false,
|
|
conversationMode = false,
|
|
reassignOptions,
|
|
agentMap,
|
|
modelAgents,
|
|
companyId,
|
|
assigneeAdapterOverrides,
|
|
userProfileMap,
|
|
currentAssigneeValue = "",
|
|
suggestedAssigneeValue,
|
|
onPendingAssigneeChange,
|
|
issueStatus,
|
|
mobile = false,
|
|
draftKey,
|
|
queuedEdit = null,
|
|
onSaveQueuedEdit,
|
|
onCancelQueuedEdit,
|
|
pause = null,
|
|
takeover = null,
|
|
pendingTakeover = null,
|
|
runnerGoalCapability = null,
|
|
onRunnerGoalCommand,
|
|
onRunnerGoalReassign,
|
|
}: TaskChatComposerProps) {
|
|
const streamlined = useStreamlinedTaskChatPresentation();
|
|
const stopControl = useComposerStop(onStop, stopPending, confirmedStoppedRunIds);
|
|
const [localBody, setBody] = useState(() => (draftKey ? loadDraft(draftKey) : ""));
|
|
const body = creation ? creation.value : localBody;
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [uncertainSubmission, setUncertainSubmission] =
|
|
useState<ComposerDraftSubmission | null>(() =>
|
|
draftKey ? loadDraftSubmission(draftKey) : null,
|
|
);
|
|
const mountedTaskKey = useRef(draftKey);
|
|
useEffect(() => {
|
|
mountedTaskKey.current = draftKey;
|
|
setUncertainSubmission(draftKey ? loadDraftSubmission(draftKey) : null);
|
|
return () => {
|
|
mountedTaskKey.current = undefined;
|
|
};
|
|
}, [draftKey]);
|
|
const [takeoverBusy, setTakeoverBusy] = useState(false);
|
|
const [takeoverError, setTakeoverError] = useState<string | null>(null);
|
|
const [takeoverHeaderClaimed, setTakeoverHeaderClaimed] = useState(false);
|
|
const [takeoverHeaderSlot, setTakeoverHeaderSlot] =
|
|
useState<HTMLElement | null>(null);
|
|
const [takeoverControlsSlot, setTakeoverControlsSlot] =
|
|
useState<HTMLElement | null>(null);
|
|
const [localPendingMode, setPendingMode] = useState<IssueWorkMode>(workMode);
|
|
const pendingMode = creation ? workMode : localPendingMode;
|
|
const [pendingAssignee, setPendingAssignee] = useState<string | null>(null);
|
|
const coordinator = useCoordinatorPresentation();
|
|
const [localRunSettings, setLocalRunSettings] = useState<ComposerRunSettings | null>(null);
|
|
const runSettings = creation ? creation.runSettings : localRunSettings;
|
|
const setRunSettings = creation ? creation.onRunSettingsChange : setLocalRunSettings;
|
|
useEffect(() => setLocalRunSettings(null), [draftKey, currentAssigneeValue]);
|
|
const [actionError, setActionError] = useState<string | null>(null);
|
|
const [attachments, setAttachmentState] = useState<ComposerAttachment[]>(
|
|
() =>
|
|
draftKey
|
|
? loadDraftAttachments(draftKey).map((item) => ({
|
|
...item,
|
|
id: `receipt:${item.attachmentId}`,
|
|
status: "attached",
|
|
}))
|
|
: [],
|
|
);
|
|
const attachmentsRef = useRef(attachments);
|
|
function setAttachments(
|
|
update:
|
|
| ComposerAttachment[]
|
|
| ((previous: ComposerAttachment[]) => ComposerAttachment[]),
|
|
) {
|
|
const next =
|
|
typeof update === "function" ? update(attachmentsRef.current) : update;
|
|
attachmentsRef.current = next;
|
|
setAttachmentState(next);
|
|
const pending = pendingDraftRef.current;
|
|
if (pending && pending.draftKey === draftKey) {
|
|
saveDraftAttachments(pending.draftKey, next
|
|
.filter(item => item.status === "attached" && item.attachmentId)
|
|
.map(item => ({ ...item, inline: item.inline === true })), pending.attemptId);
|
|
}
|
|
}
|
|
const submittingRef = useRef(submitting);
|
|
submittingRef.current = submitting;
|
|
const pendingAssigneeRef = useRef(pendingAssignee);
|
|
pendingAssigneeRef.current = pendingAssignee;
|
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
|
const editorRef = useRef<MarkdownEditorRef>(null);
|
|
const bodyRef = useRef(body);
|
|
bodyRef.current = body;
|
|
// Only the account that made a request may replay it.
|
|
const draftOwner = useDraftOwner();
|
|
const isCurrentAccount = useCurrentAccountGuard();
|
|
const pendingDraftRef = useRef<{
|
|
draftKey: string;
|
|
attemptId: string;
|
|
submittedBody: string;
|
|
submittedAttachmentIds: string[];
|
|
request: ComposerSendRequest;
|
|
requestOwner?: string;
|
|
} | null>(null);
|
|
const markRequestUnbound = useRequestOwnerBinding({
|
|
draftKey, owner: draftOwner, uncertainSubmission, setUncertainSubmission, pendingDraftRef,
|
|
});
|
|
function changeBody(value: string) {
|
|
bodyRef.current = value;
|
|
if (creation) {
|
|
creation.onChange(value);
|
|
return;
|
|
}
|
|
setBody(value);
|
|
const pending = pendingDraftRef.current;
|
|
if (!pending || pending.draftKey !== draftKey ||
|
|
loadDraftSubmission(pending.draftKey)?.attemptId !== pending.attemptId) return;
|
|
// Keep text typed during delivery durable too. Navigation may happen before
|
|
// either the request promise or the matching live server receipt arrives.
|
|
saveDraft(pending.draftKey,
|
|
value ? `${pending.submittedBody}\n\n${value}` : pending.submittedBody,
|
|
pending.attemptId);
|
|
saveDraftSubmission(pending.draftKey, {
|
|
attemptId: pending.attemptId, reviewed: false,
|
|
nextDraftOffset: pending.submittedBody.length + (value ? 2 : 0),
|
|
submittedAttachmentIds: pending.submittedAttachmentIds,
|
|
request: pending.request,
|
|
requestOwner: pending.requestOwner,
|
|
});
|
|
}
|
|
const draftTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const queuedEditRef = useRef(queuedEdit);
|
|
queuedEditRef.current = queuedEdit;
|
|
const queuedEditBackupRef = useRef<QueuedEditBackup | null>(null);
|
|
const previousQueuedEditIdRef = useRef<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
setTakeoverBusy(false);
|
|
setTakeoverError(null);
|
|
}, [takeover?.id]);
|
|
|
|
useEffect(() => {
|
|
const previousId = previousQueuedEditIdRef.current;
|
|
const nextEdit = queuedEdit;
|
|
const nextId = nextEdit?.commentId ?? null;
|
|
|
|
if (nextEdit && previousId !== nextId) {
|
|
if (!queuedEditBackupRef.current) {
|
|
queuedEditBackupRef.current = {
|
|
body: bodyRef.current,
|
|
attachments: attachmentsRef.current,
|
|
pendingMode,
|
|
pendingAssignee: pendingAssigneeRef.current,
|
|
};
|
|
if (draftKey) saveDraft(draftKey, bodyRef.current);
|
|
}
|
|
if (draftTimer.current) {
|
|
clearTimeout(draftTimer.current);
|
|
draftTimer.current = null;
|
|
}
|
|
bodyRef.current = nextEdit.body;
|
|
setBody(nextEdit.body);
|
|
setAttachments([]);
|
|
requestAnimationFrame(() => editorRef.current?.focus());
|
|
} else if (!nextId && previousId) {
|
|
const backup = queuedEditBackupRef.current;
|
|
if (backup) {
|
|
bodyRef.current = backup.body;
|
|
setBody(backup.body);
|
|
setAttachments(backup.attachments);
|
|
setPendingMode(backup.pendingMode);
|
|
setPendingAssignee(backup.pendingAssignee);
|
|
if (draftKey) saveDraft(draftKey, backup.body);
|
|
}
|
|
queuedEditBackupRef.current = null;
|
|
requestAnimationFrame(() => editorRef.current?.focus());
|
|
}
|
|
|
|
previousQueuedEditIdRef.current = nextId;
|
|
}, [draftKey, pendingMode, queuedEdit]);
|
|
|
|
useEffect(() => {
|
|
if (!draftKey || queuedEdit) return;
|
|
bodyRef.current = loadDraft(draftKey);
|
|
setBody(bodyRef.current);
|
|
}, [draftKey, queuedEdit]);
|
|
|
|
useEffect(() => {
|
|
if (!draftKey) return;
|
|
setAttachments(
|
|
loadDraftAttachments(draftKey).map((item) => ({
|
|
...item,
|
|
id: `receipt:${item.attachmentId}`,
|
|
status: "attached",
|
|
})),
|
|
);
|
|
}, [draftKey]);
|
|
|
|
useEffect(() => {
|
|
if (
|
|
!draftKey ||
|
|
queuedEdit ||
|
|
submitting ||
|
|
uncertainSubmission ||
|
|
attachments !== attachmentsRef.current
|
|
)
|
|
return;
|
|
saveDraftAttachments(
|
|
draftKey,
|
|
attachments
|
|
.filter((item) => item.status === "attached" && item.attachmentId)
|
|
.map((item) => ({
|
|
attachmentId: item.attachmentId,
|
|
name: item.name,
|
|
size: item.size,
|
|
inline: item.inline === true,
|
|
contentType: item.contentType,
|
|
contentPath: item.contentPath,
|
|
})),
|
|
);
|
|
}, [attachments, draftKey, queuedEdit, submitting]);
|
|
|
|
useEffect(() => {
|
|
if (!draftKey || queuedEdit || submitting) {
|
|
if (draftTimer.current) {
|
|
clearTimeout(draftTimer.current);
|
|
draftTimer.current = null;
|
|
}
|
|
return;
|
|
}
|
|
if (draftTimer.current) clearTimeout(draftTimer.current);
|
|
draftTimer.current = setTimeout(() => {
|
|
saveDraft(draftKey, body);
|
|
}, DRAFT_DEBOUNCE_MS);
|
|
}, [body, draftKey, queuedEdit, submitting]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (draftTimer.current) clearTimeout(draftTimer.current);
|
|
if (draftKey && !queuedEditRef.current && !submittingRef.current)
|
|
saveDraft(draftKey, bodyRef.current);
|
|
};
|
|
}, [draftKey]);
|
|
|
|
useEffect(() => {
|
|
if (!draftKey) return;
|
|
const flushDraft = () => {
|
|
if (!queuedEditRef.current && !submittingRef.current)
|
|
saveDraft(draftKey, bodyRef.current);
|
|
};
|
|
window.addEventListener("beforeunload", flushDraft);
|
|
return () => window.removeEventListener("beforeunload", flushDraft);
|
|
}, [draftKey]);
|
|
|
|
const canAcceptFiles =
|
|
!pause &&
|
|
!queuedEdit &&
|
|
!uncertainSubmission &&
|
|
Boolean(creation || onAttachImage || onImageUpload);
|
|
const showAssignee = Boolean(
|
|
enableReassign && reassignOptions && reassignOptions.length > 0,
|
|
);
|
|
const assigneeValue = creation || conversationMode ? currentAssigneeValue : pendingAssignee ?? suggestedAssigneeValue ?? currentAssigneeValue;
|
|
const showConversationModel = coordinator && conversationMode && supportsComposerModel(modelAgents?.get(assigneeValue.slice(6)));
|
|
const assigneeLabel =
|
|
reassignOptions?.find((o) => o.id === assigneeValue)?.label ?? "Unassigned";
|
|
const assigneeName =
|
|
assigneeLabel === "Unassigned" ? "the agent" : assigneeLabel;
|
|
const effectivePlaceholder = queuedEdit
|
|
? "Edit queued message…"
|
|
: (placeholder ?? modePlaceholder(pendingMode, assigneeName, mobile));
|
|
const goalUnavailable = runnerGoalCapability?.availability !== "available";
|
|
const goalCommandOption: ActionCommandOption = {
|
|
id: "action:goal",
|
|
kind: "action",
|
|
command: "goal",
|
|
name: "Goal",
|
|
description:
|
|
!runnerGoalCapability || runnerGoalCapability.verified === false
|
|
? "Support will be verified when the session starts."
|
|
: "Pursue work across turns.",
|
|
aliases: ["goal", "pursue", "continue"],
|
|
disabled: goalUnavailable && runnerGoalCapability !== null,
|
|
disabledReason:
|
|
runnerGoalCapability?.reason ??
|
|
"Session goals are unsupported by this agent.",
|
|
};
|
|
|
|
function updatePendingAssignee(value: string | null) {
|
|
if (!creation && value && companyId) {
|
|
const selection = parseAssigneeValue(value);
|
|
if (selection?.assigneeAgentId) trackRecentAssignee(selection.assigneeAgentId, companyId);
|
|
if (selection?.assigneeUserId) trackRecentAssigneeUser(selection.assigneeUserId, companyId);
|
|
}
|
|
setPendingAssignee(value);
|
|
onPendingAssigneeChange?.(value);
|
|
}
|
|
|
|
function changeMode(mode: IssueWorkMode) {
|
|
setPendingMode(mode);
|
|
if (creation) void onWorkModeChange?.(mode);
|
|
}
|
|
|
|
async function uploadImage(file: File): Promise<string> {
|
|
const id = crypto.randomUUID();
|
|
setAttachments((prev) => [
|
|
...prev,
|
|
{
|
|
id,
|
|
name: file.name,
|
|
size: file.size,
|
|
contentType: file.type,
|
|
file,
|
|
status: "uploading",
|
|
},
|
|
]);
|
|
try {
|
|
const attachment = onAttachImage ? await onAttachImage(file) : undefined;
|
|
const url = onAttachImage
|
|
? attachment?.contentPath
|
|
: await onImageUpload?.(file);
|
|
if (!url) throw new Error("Upload did not return a file URL");
|
|
if (!attachmentsRef.current.some((item) => item.id === id))
|
|
throw new Error("Attachment was removed");
|
|
setAttachments((prev) =>
|
|
prev.map((item) =>
|
|
item.id === id
|
|
? {
|
|
...item,
|
|
attachmentId: attachment?.id,
|
|
contentPath: url,
|
|
status: "attached",
|
|
}
|
|
: item,
|
|
),
|
|
);
|
|
return url;
|
|
} catch (err) {
|
|
setAttachments((prev) =>
|
|
prev.map((item) =>
|
|
item.id === id
|
|
? {
|
|
...item,
|
|
status: "error",
|
|
error: describeError(err, { action: "upload the file" }).body,
|
|
file,
|
|
}
|
|
: item,
|
|
),
|
|
);
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/** Non-image files: attach to the task and track in the chip row. */
|
|
async function attachNonImageFile(file: File) {
|
|
const id = `${file.name}:${file.size}:${file.lastModified}:${Math.random().toString(36).slice(2)}`;
|
|
setAttachments((prev) => [
|
|
...prev,
|
|
{ id, name: file.name, size: file.size, contentType: file.type, status: "uploading" },
|
|
]);
|
|
try {
|
|
if (!onAttachImage) {
|
|
setAttachments((prev) =>
|
|
prev.map((item) =>
|
|
item.id === id
|
|
? {
|
|
...item,
|
|
status: "error",
|
|
error: "This file type cannot be attached here",
|
|
}
|
|
: item,
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
const attachment = await onAttachImage(file);
|
|
if (!attachment?.contentPath)
|
|
throw new Error("Upload did not return a file URL");
|
|
const name = attachment?.originalFilename ?? file.name;
|
|
setAttachments((prev) =>
|
|
prev.map((item) =>
|
|
item.id === id
|
|
? {
|
|
...item,
|
|
name,
|
|
attachmentId: attachment?.id,
|
|
status: "attached",
|
|
contentPath: attachment?.contentPath,
|
|
}
|
|
: item,
|
|
),
|
|
);
|
|
} catch (err) {
|
|
setAttachments((prev) =>
|
|
prev.map((item) =>
|
|
item.id === id
|
|
? {
|
|
...item,
|
|
status: "error",
|
|
error: describeError(err, { action: "upload the file" }).body,
|
|
file,
|
|
}
|
|
: item,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/** Retry a failed upload from its kept file; only ever on an explicit click. */
|
|
function retryUpload(attachment: ComposerAttachment) {
|
|
const file = attachment.file;
|
|
if (!file) return;
|
|
setAttachments((prev) => prev.filter((item) => item.id !== attachment.id));
|
|
void attachPickedFile(file);
|
|
}
|
|
|
|
async function attachPickedFile(file: File) {
|
|
if (!file.type.startsWith("image/")) {
|
|
await attachNonImageFile(file);
|
|
return;
|
|
}
|
|
await uploadImage(file).catch(() => undefined);
|
|
}
|
|
|
|
function handleFileInputChange(evt: ChangeEvent<HTMLInputElement>) {
|
|
const files = evt.target.files;
|
|
if (files && files.length > 0) {
|
|
if (creation) {
|
|
creation.onSelectFiles(Array.from(files));
|
|
evt.target.value = "";
|
|
return;
|
|
}
|
|
void (async () => {
|
|
for (const file of Array.from(files)) await attachPickedFile(file);
|
|
})();
|
|
}
|
|
evt.target.value = "";
|
|
}
|
|
|
|
function prepareGoal() {
|
|
const current = bodyRef.current.trim();
|
|
changeBody(/^\/goal(?:\s|$)/.test(current) ? current : `/goal ${current}`);
|
|
setActionError(null);
|
|
requestAnimationFrame(() => editorRef.current?.focus());
|
|
}
|
|
|
|
function handlePasteCapture(evt: ReactClipboardEvent<HTMLDivElement>) {
|
|
if (!canAcceptFiles || disabled || creation?.readOnly) return;
|
|
const files = Array.from(evt.clipboardData?.files ?? []);
|
|
if (files.length === 0) return;
|
|
evt.preventDefault();
|
|
evt.stopPropagation();
|
|
if (creation) {
|
|
creation.onSelectFiles(files);
|
|
return;
|
|
}
|
|
for (const file of files) void attachPickedFile(file);
|
|
}
|
|
|
|
function isImage(item: ComposerAttachment) {
|
|
return isImageAttachment({ name: item.name, url: item.contentPath ?? "", contentType: item.contentType });
|
|
}
|
|
|
|
const attachedRefs = attachments.filter(
|
|
(item) => item.status === "attached" && item.contentPath && !item.inline,
|
|
);
|
|
const imageAttachments = attachments.filter(item => isImage(item) && !item.inline);
|
|
const visibleAttachments = attachments.filter(
|
|
(item) => (!item.inline || item.status !== "attached") && !isImage(item),
|
|
);
|
|
// Sending mid-upload would silently drop the pending file from the comment;
|
|
// sending past a failed chip would discard the file the user selected and
|
|
// clear its error state, so both hold submission until resolved or removed.
|
|
const showStop =
|
|
!queuedEdit &&
|
|
!submitting &&
|
|
body.trim().length === 0 &&
|
|
attachments.length === 0 &&
|
|
Boolean(onStop || stopControl.stopping);
|
|
const uploadPending = attachments.some((item) => item.status === "uploading");
|
|
const uploadFailed = attachments.some((item) => item.status === "error");
|
|
const takeoverVisible = Boolean(takeover && !pause);
|
|
const previousTakeoverVisibleRef = useRef(takeoverVisible);
|
|
useEffect(() => {
|
|
if (previousTakeoverVisibleRef.current && !takeoverVisible && !queuedEdit) {
|
|
requestAnimationFrame(() => editorRef.current?.focus());
|
|
}
|
|
previousTakeoverVisibleRef.current = takeoverVisible;
|
|
}, [queuedEdit, takeoverVisible]);
|
|
|
|
const canResetPausedConversation = conversationMode && !queuedEdit && body.trim() === "/new" && attachments.length === 0;
|
|
|
|
async function submit() {
|
|
if (disabled || (pause && !canResetPausedConversation)) return;
|
|
if (creation) {
|
|
if (submittingRef.current || uploadPending || uploadFailed || creation.submitDisabled || (!bodyRef.current.trim() && !creation.canSubmitWithoutBody)) return;
|
|
submittingRef.current = true;
|
|
setSubmitting(true);
|
|
setActionError(null);
|
|
try {
|
|
await creation.onSubmit(bodyRef.current, pendingMode, runSettings);
|
|
} catch (error) {
|
|
setActionError(describeError(error, { action: "create the task" }).body);
|
|
} finally {
|
|
submittingRef.current = false;
|
|
setSubmitting(false);
|
|
}
|
|
return;
|
|
}
|
|
if (!onAdd) return;
|
|
const retained =
|
|
draftKey && !queuedEdit ? loadDraftSubmission(draftKey) : null;
|
|
if (retained && !submitting) {
|
|
setUncertainSubmission(retained);
|
|
return;
|
|
}
|
|
const submittedBody = bodyRef.current;
|
|
const submittedAttachments = attachmentsRef.current;
|
|
const submittedAssignee = pendingAssigneeRef.current;
|
|
const trimmed = submittedBody.trim();
|
|
const goalCommand = queuedEdit
|
|
? ({ matched: false } as const)
|
|
: parseRunnerGoalCommand(submittedBody);
|
|
if (goalCommand.matched && conversationMode) {
|
|
setActionError("Create a separate task for work that needs an ongoing execution goal.");
|
|
return;
|
|
}
|
|
if (goalCommand.matched) {
|
|
if ("error" in goalCommand) {
|
|
setActionError(goalCommand.error);
|
|
return;
|
|
}
|
|
if (attachmentsRef.current.length > 0) {
|
|
setActionError("Remove attachments before using /goal.");
|
|
return;
|
|
}
|
|
if (!onRunnerGoalCommand) {
|
|
setActionError(
|
|
runnerGoalCapability?.reason ??
|
|
"Session goals are unsupported by this agent.",
|
|
);
|
|
return;
|
|
}
|
|
if (
|
|
runnerGoalCapability &&
|
|
runnerGoalCapability.availability !== "available"
|
|
) {
|
|
setActionError(
|
|
runnerGoalCapability.reason ??
|
|
"Session goals are unsupported by this agent.",
|
|
);
|
|
return;
|
|
}
|
|
try {
|
|
const hasReassignment =
|
|
showAssignee && assigneeValue !== currentAssigneeValue;
|
|
if (hasReassignment && goalCommand.command.action !== "focus") {
|
|
const reassignment = parseAssigneeValue(assigneeValue);
|
|
if (!reassignment?.assigneeAgentId || !onRunnerGoalReassign) {
|
|
setActionError("Select an agent before starting a session goal.");
|
|
return;
|
|
}
|
|
await onRunnerGoalReassign(reassignment);
|
|
updatePendingAssignee(null);
|
|
}
|
|
await onRunnerGoalCommand(goalCommand.command);
|
|
setActionError(null);
|
|
bodyRef.current = "";
|
|
if (draftTimer.current) clearTimeout(draftTimer.current);
|
|
draftTimer.current = null;
|
|
if (draftKey) clearDraft(draftKey);
|
|
setBody("");
|
|
editorRef.current?.clear();
|
|
} catch (error) {
|
|
setActionError(
|
|
error instanceof Error
|
|
? error.message
|
|
: "The goal action could not be applied.",
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
if (
|
|
(!trimmed && attachedRefs.length === 0) ||
|
|
uploadPending ||
|
|
uploadFailed ||
|
|
submitting ||
|
|
uncertainSubmission ||
|
|
disabled
|
|
)
|
|
return;
|
|
|
|
const refLines = attachedRefs
|
|
.map((item) => `${isImage(item) ? "!" : ""}[${escapeMarkdownLabel(item.name)}](${item.contentPath})`)
|
|
.join("\n");
|
|
// A queued edit must preserve the complete Markdown source. Normal sends
|
|
// keep the longstanding trimmed-body behavior.
|
|
const fullBody = queuedEdit
|
|
? submittedBody
|
|
: [trimmed, refLines].filter(Boolean).join("\n\n");
|
|
const hasReassignment =
|
|
showAssignee && assigneeValue !== currentAssigneeValue;
|
|
const reassignment = hasReassignment
|
|
? parseAssigneeValue(assigneeValue)
|
|
: undefined;
|
|
const reopen = shouldImplicitlyReopenComment(issueStatus, assigneeValue)
|
|
? true
|
|
: undefined;
|
|
|
|
// The thread renders the outgoing comment optimistically, so remove its
|
|
// text from the composer at the same time. The editor remains writable for
|
|
// the next draft while the request is pending.
|
|
bodyRef.current = "";
|
|
if (draftTimer.current) {
|
|
clearTimeout(draftTimer.current);
|
|
draftTimer.current = null;
|
|
}
|
|
if (draftKey && !queuedEdit) {
|
|
if (
|
|
submittedAttachments.some(
|
|
(item) => item.status === "attached" && item.attachmentId,
|
|
)
|
|
)
|
|
saveDraft(draftKey, submittedBody);
|
|
else clearDraft(draftKey);
|
|
}
|
|
setBody("");
|
|
setSubmitting(true);
|
|
let attemptId: string | null = null;
|
|
let request: ComposerSendRequest | null = null;
|
|
const requestOwner = draftOwner;
|
|
try {
|
|
if (queuedEdit) {
|
|
if (!onSaveQueuedEdit) return;
|
|
await onSaveQueuedEdit(queuedEdit.commentId, fullBody);
|
|
onCancelQueuedEdit?.();
|
|
return;
|
|
}
|
|
if (pendingMode !== workMode && onWorkModeChange) {
|
|
await onWorkModeChange(pendingMode);
|
|
}
|
|
const retainedAfterMode = draftKey ? loadDraftSubmission(draftKey) : null;
|
|
if (retainedAfterMode) {
|
|
setUncertainSubmission(retainedAfterMode);
|
|
setBody(submittedBody);
|
|
return;
|
|
}
|
|
attemptId = crypto.randomUUID();
|
|
if (requestOwner === undefined) markRequestUnbound(attemptId);
|
|
// IDs come only from this composer's upload receipts, never from parsing
|
|
// arbitrary Markdown. A removed inline image no longer selects its receipt.
|
|
const attachmentIds = [
|
|
...new Set(
|
|
submittedAttachments
|
|
.filter(
|
|
(item) =>
|
|
item.status === "attached" &&
|
|
item.attachmentId &&
|
|
(!item.inline ||
|
|
(item.contentPath &&
|
|
submittedBody.includes(item.contentPath))),
|
|
)
|
|
.map((item) => item.attachmentId!),
|
|
),
|
|
];
|
|
// The exact request is stored before it is sent, so a lost response or
|
|
// an outage can resend it with the same ID instead of losing it.
|
|
request = {
|
|
body: fullBody,
|
|
...(reopen === undefined ? {} : { reopen }),
|
|
...(reassignment ? { reassignment } : {}),
|
|
...(attachmentIds.length ? { attachmentIds } : {}),
|
|
...(runSettings ? { runSettings: runSettings as unknown as Record<string, unknown> } : {}),
|
|
};
|
|
if (draftKey) {
|
|
saveDraft(draftKey, submittedBody);
|
|
saveDraftSubmission(draftKey, { attemptId, reviewed: false, request, requestOwner });
|
|
pendingDraftRef.current = { draftKey, attemptId, submittedBody, submittedAttachmentIds: attachmentIds, request, requestOwner };
|
|
changeBody(bodyRef.current);
|
|
}
|
|
if (runSettings) {
|
|
await onAdd(fullBody, reopen, reassignment, attachmentIds.length ? attachmentIds : undefined, attemptId, runSettings);
|
|
} else {
|
|
await onAdd(fullBody, reopen, reassignment, attachmentIds.length ? attachmentIds : undefined, attemptId);
|
|
}
|
|
// Navigation does not invalidate the server receipt. Settle the captured
|
|
// task before checking whether this composer is still on screen.
|
|
if (draftKey) settleDraftSubmission(draftKey, attemptId,
|
|
mountedTaskKey.current === draftKey ? bodyRef.current : undefined);
|
|
if (mountedTaskKey.current !== draftKey) return;
|
|
const submittedIds = new Set(submittedAttachments.map((item) => item.id));
|
|
setAttachments((current) =>
|
|
current.filter((item) => !submittedIds.has(item.id)),
|
|
);
|
|
if (pendingAssigneeRef.current === submittedAssignee) {
|
|
updatePendingAssignee(null);
|
|
}
|
|
setRunSettings(null);
|
|
} catch (error) {
|
|
if (mountedTaskKey.current !== draftKey) return;
|
|
const nextDraft = bodyRef.current;
|
|
// Not delivered, or not known to be: keep it and resend with the same
|
|
// ID when the connection allows. The server dedupes by that ID.
|
|
if (attemptId && request && classifySendFailure(error) === "pending") {
|
|
const uncertain = {
|
|
attemptId, reviewed: false,
|
|
nextDraftOffset: submittedBody.length + (nextDraft ? 2 : 0),
|
|
submittedAttachmentIds: submittedAttachments.flatMap(item => item.attachmentId ? [item.attachmentId] : []),
|
|
request,
|
|
requestOwner,
|
|
};
|
|
setUncertainSubmission(uncertain);
|
|
if (draftKey && loadDraftSubmission(draftKey)?.attemptId === attemptId)
|
|
saveDraftSubmission(draftKey, uncertain);
|
|
} else if (draftKey && attemptId)
|
|
clearDraftSubmission(draftKey, attemptId);
|
|
// Restore the failed message for retry without discarding a next draft
|
|
// that was entered while the request was pending.
|
|
const restoredBody = nextDraft
|
|
? `${submittedBody}\n\n${nextDraft}`
|
|
: submittedBody;
|
|
bodyRef.current = restoredBody;
|
|
if (draftTimer.current) {
|
|
clearTimeout(draftTimer.current);
|
|
draftTimer.current = null;
|
|
}
|
|
if (draftKey) saveDraft(draftKey, restoredBody, attemptId ?? undefined);
|
|
setBody(restoredBody);
|
|
} finally {
|
|
if (pendingDraftRef.current?.attemptId === attemptId) pendingDraftRef.current = null;
|
|
setSubmitting(false);
|
|
}
|
|
}
|
|
|
|
/** A confirmed receipt for the retained send: drop its text, keep any next draft. */
|
|
const settledSubmissionIds = useRef(new Set<string>());
|
|
function settleUncertainSubmission(submission: ComposerDraftSubmission) {
|
|
const settlementKey = JSON.stringify([draftKey, submission.attemptId]);
|
|
if (settledSubmissionIds.current.has(settlementKey)) return;
|
|
settledSubmissionIds.current.add(settlementKey);
|
|
const nextDraft = submission.nextDraftOffset === undefined
|
|
? "" : bodyRef.current.slice(submission.nextDraftOffset);
|
|
if (draftKey) settleDraftSubmission(draftKey, submission.attemptId, nextDraft);
|
|
setUncertainSubmission(null);
|
|
setActionError(null);
|
|
bodyRef.current = nextDraft;
|
|
setBody(nextDraft);
|
|
const submittedIds = submission.submittedAttachmentIds;
|
|
setAttachments(current => submittedIds
|
|
? current.filter(item => !item.attachmentId || !submittedIds.includes(item.attachmentId))
|
|
: []);
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (!uncertainSubmission || !confirmedSubmissionIds?.has(uncertainSubmission.attemptId)) return;
|
|
settleUncertainSubmission(uncertainSubmission);
|
|
}, [confirmedSubmissionIds, draftKey, uncertainSubmission]);
|
|
|
|
async function resendUncertainSubmission(): Promise<SendOutcome> {
|
|
if (!isCurrentAccount()) return "pending";
|
|
const submission = uncertainSubmission;
|
|
const request = replayableRequest(submission, draftOwner);
|
|
if (!submission || !request || !onAdd) return "rejected";
|
|
const taskKey = draftKey;
|
|
const args = [
|
|
request.body,
|
|
request.reopen,
|
|
request.reassignment,
|
|
request.attachmentIds,
|
|
submission.attemptId,
|
|
] as const;
|
|
try {
|
|
if (request.runSettings) await onAdd(...args, request.runSettings as unknown as ComposerRunSettings);
|
|
else await onAdd(...args);
|
|
} catch (error) {
|
|
if (!isCurrentAccount()) return "pending";
|
|
const failure = classifySendFailure(error);
|
|
if (settledSubmissionIds.current.has(JSON.stringify([taskKey, submission.attemptId]))) return failure;
|
|
if (failure === "rejected") {
|
|
// A definitive answer: the text is already back in the editor.
|
|
if (taskKey) clearDraftSubmission(taskKey, submission.attemptId);
|
|
if (mountedTaskKey.current === taskKey) {
|
|
setUncertainSubmission(null);
|
|
setActionError(describeError(error, { action: "send this message" }).body);
|
|
}
|
|
}
|
|
return failure;
|
|
}
|
|
if (mountedTaskKey.current === taskKey) settleUncertainSubmission(submission);
|
|
else if (taskKey) settleDraftSubmission(taskKey, submission.attemptId);
|
|
return "sent";
|
|
}
|
|
|
|
const resendSourceId = useId();
|
|
const autoResend = useAutoResend({
|
|
sourceId: `task-chat-composer:${resendSourceId}`,
|
|
active: Boolean(replayableRequest(uncertainSubmission, draftOwner)) && !submitting,
|
|
resend: resendUncertainSubmission,
|
|
});
|
|
|
|
/** Stop resending; the text stays in the editor to edit or send again. */
|
|
function cancelUncertainSubmission() {
|
|
if (!uncertainSubmission) return;
|
|
settledSubmissionIds.current.add(JSON.stringify([draftKey, uncertainSubmission.attemptId]));
|
|
if (draftKey) clearDraftSubmission(draftKey, uncertainSubmission.attemptId);
|
|
setUncertainSubmission(null);
|
|
}
|
|
|
|
function skipTakeover() {
|
|
if (!takeover) return;
|
|
setTakeoverBusy(true);
|
|
setTakeoverError(null);
|
|
Promise.resolve(takeover.onSkip()).catch((cause) => {
|
|
setTakeoverBusy(false);
|
|
setTakeoverError(
|
|
cause instanceof Error
|
|
? cause.message
|
|
: "This request could not be skipped.",
|
|
);
|
|
});
|
|
}
|
|
|
|
const takeoverSkipButton = takeover ? (
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="outline"
|
|
disabled={takeoverBusy}
|
|
onClick={skipTakeover}
|
|
>
|
|
{takeoverBusy ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
|
|
) : null}
|
|
Skip
|
|
</Button>
|
|
) : null;
|
|
|
|
if (pause && (!conversationMode || queuedEdit)) {
|
|
return <TaskChatPausedTakeover {...pause} hasDraft={Boolean(body.trim() || attachments.length)} />;
|
|
}
|
|
|
|
return (
|
|
<div className={cn("flex min-w-0 flex-col", creation?.contextBar ? "gap-0" : "gap-2")}>
|
|
{takeoverVisible && takeover ? (
|
|
<section
|
|
className={cn(
|
|
"relative rounded-xl border border-border bg-card p-(--sz-18px) shadow-sm",
|
|
mobile ? "overflow-visible" : "max-h-(--tc-interaction-card-max-h) overflow-y-auto scrollbar-auto-hide",
|
|
)}
|
|
aria-label={takeover.label}
|
|
data-testid="task-chat-composer-takeover"
|
|
>
|
|
<div
|
|
className={cn(
|
|
"flex min-w-0 items-center gap-2",
|
|
takeover.hideLabel && takeover.pendingCount === 1
|
|
? "absolute right-0 top-0 z-10"
|
|
: "mb-3",
|
|
)}
|
|
data-testid="task-chat-composer-takeover-header"
|
|
>
|
|
<div className="min-w-0 flex-1">
|
|
{!takeoverHeaderClaimed && !takeover.hideLabel ? (
|
|
<strong className="block truncate text-sm font-medium text-foreground">
|
|
{takeover.label}
|
|
</strong>
|
|
) : null}
|
|
<div
|
|
ref={setTakeoverHeaderSlot}
|
|
className="flex min-w-0 items-center"
|
|
data-testid="task-chat-composer-takeover-title-slot"
|
|
/>
|
|
</div>
|
|
<div className="ml-auto flex shrink-0 items-center gap-1">
|
|
{takeover.pendingCount > 1 ? (
|
|
<Button type="button" size="sm" variant="ghost" className="h-7 px-2" onClick={takeover.onShowNext}>
|
|
{takeover.pendingCount} pending
|
|
</Button>
|
|
) : null}
|
|
<div ref={setTakeoverControlsSlot} className="flex shrink-0 items-center" data-testid="task-chat-composer-takeover-controls-slot" />
|
|
<Button type="button" size="icon-xs" variant="ghost" className="text-muted-foreground hover:text-foreground"
|
|
aria-label={`Dismiss ${takeover.label}`} disabled={takeoverBusy} onClick={takeover.onDismiss}>
|
|
<X aria-hidden />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<div className={takeover.hideLabel && takeover.pendingCount === 1 ? "pr-8" : "pr-1"}
|
|
data-testid="task-chat-composer-takeover-body">
|
|
<TaskChatComposerTakeoverActionsContext.Provider
|
|
value={{
|
|
skipButton: takeover.inlineSkip && !takeover.hideSkip && takeoverSkipButton ? takeoverSkipButton : null,
|
|
dismiss: takeover.onDismiss,
|
|
headerSlot: takeoverHeaderSlot,
|
|
controlsSlot: takeoverControlsSlot,
|
|
setHeaderClaimed: setTakeoverHeaderClaimed,
|
|
}}
|
|
>
|
|
{takeover.content}
|
|
</TaskChatComposerTakeoverActionsContext.Provider>
|
|
</div>
|
|
{takeoverError ? <p className="mt-2 text-sm text-destructive" role="alert">{takeoverError}</p> : null}
|
|
{!takeover.inlineSkip && !takeover.hideSkip ? (
|
|
<div className="mt-3 flex items-center justify-end gap-2">{takeoverSkipButton}</div>
|
|
) : null}
|
|
</section>
|
|
) : null}
|
|
{creation?.contextBar ? (
|
|
<TaskChatComposerBar
|
|
className="flex h-11 min-w-0 items-center gap-2 px-2.5"
|
|
data-testid="task-chat-composer-context"
|
|
role="group"
|
|
aria-label="Task project and worktrees"
|
|
>
|
|
{creation.contextBar}
|
|
</TaskChatComposerBar>
|
|
) : null}
|
|
<div
|
|
className={taskChatComposerSurfaceClasses(mobile, streamlined)}
|
|
onKeyDownCapture={(e) => {
|
|
// Capture mode shortcuts on the wrapper so they work while the rich
|
|
// editor is focused and win over Lexical/browser bindings. Match the
|
|
// period by key and code because hardware keyboards on iOS can omit
|
|
// `code` for Cmd+Period.
|
|
if (disabled || queuedEdit) return;
|
|
const isPeriod = e.key === "." || e.code === "Period";
|
|
const isModeShortcut =
|
|
(isPeriod && (e.metaKey || e.ctrlKey)) ||
|
|
(e.key === "Tab" && e.shiftKey);
|
|
if (isModeShortcut) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
changeMode(nextWorkMode(pendingMode));
|
|
}
|
|
}}
|
|
onPasteCapture={handlePasteCapture}
|
|
onDragOver={(event) => {
|
|
if (canAcceptFiles && !disabled && !creation && event.dataTransfer.types.includes("Files")) event.preventDefault();
|
|
}}
|
|
onDropCapture={(event) => {
|
|
if (!canAcceptFiles || disabled || creation) return;
|
|
const files = Array.from(event.dataTransfer.files);
|
|
if (!files.length) return;
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
for (const file of files) void attachPickedFile(file);
|
|
}}
|
|
>
|
|
{creation?.header}
|
|
{uncertainSubmission ? (
|
|
<PendingSendNotice
|
|
className="mb-3"
|
|
noun="message"
|
|
persisted={Boolean(draftKey && loadDraftSubmission(draftKey)?.attemptId === uncertainSubmission.attemptId && replayableRequest(loadDraftSubmission(draftKey), draftOwner))}
|
|
resending={autoResend.resending}
|
|
stalled={autoResend.stalled}
|
|
onResendNow={replayableRequest(uncertainSubmission, draftOwner) ? autoResend.resendNow : undefined}
|
|
onCancel={cancelUncertainSubmission}
|
|
/>
|
|
) : null}
|
|
{!takeoverVisible && (pendingTakeover || takeover) ? (
|
|
<button
|
|
type="button"
|
|
className="mb-2 flex w-full items-center gap-2 rounded-md bg-muted/50 px-2.5 py-2 text-left text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
|
onClick={pendingTakeover?.onOpen}
|
|
data-testid="task-chat-pending-input-indicator"
|
|
>
|
|
<CircleHelp className="h-4 w-4 shrink-0" aria-hidden />
|
|
<span className="min-w-0 flex-1 truncate">
|
|
{pendingTakeover?.label ?? takeover?.label ?? "Pending input"}
|
|
</span>
|
|
<span className="shrink-0 font-medium">
|
|
{pendingTakeover?.count ?? takeover?.pendingCount ?? 1} pending
|
|
</span>
|
|
</button>
|
|
) : null}
|
|
{pause && conversationMode ? (
|
|
<div className="space-y-2">
|
|
<TaskChatPausedTakeover {...pause} hasDraft={Boolean(body.trim() || attachments.length)} />
|
|
<p className="text-xs text-muted-foreground">Send /new to start a fresh session and resume this conversation.</p>
|
|
</div>
|
|
) : null}
|
|
<ComposerImageAttachments
|
|
images={creation?.images ?? imageAttachments}
|
|
disabled={disabled || !!uncertainSubmission || submitting || creation?.readOnly}
|
|
onRemove={(id) => {
|
|
if (creation) creation.onRemoveImage?.(id);
|
|
else setAttachments(current => current.filter(item => item.id !== id));
|
|
}}
|
|
onRetry={creation ? undefined : (id) => {
|
|
const image = attachments.find(item => item.id === id);
|
|
if (image) retryUpload(image);
|
|
}}
|
|
/>
|
|
<div data-testid="task-chat-composer-input">
|
|
<MarkdownEditor
|
|
ref={editorRef}
|
|
value={body}
|
|
onChange={changeBody}
|
|
placeholder={
|
|
disabled
|
|
? (disabledReason ?? "Composer disabled")
|
|
: effectivePlaceholder
|
|
}
|
|
readOnly={disabled || !!uncertainSubmission || Boolean(creation && (submitting || creation.readOnly))}
|
|
mentions={mentions}
|
|
actionCommands={creation ? [] : conversationMode ? [{
|
|
id: "action:new", kind: "action", command: "new", name: "New session",
|
|
description: "Start fresh context here, preserving conversation history.", aliases: ["new"],
|
|
disabled,
|
|
}] : [goalCommandOption]}
|
|
onSubmit={() => void submit()}
|
|
fileDropTarget="parent"
|
|
bordered={false}
|
|
className={cn(disabled && "opacity-60")}
|
|
contentClassName={
|
|
mobile
|
|
? "max-h-(--tc-composer-editor-max-height) min-h-(--sz-48px) overflow-y-auto px-1 py-1 text-base scrollbar-auto-hide"
|
|
: "max-h-(--sz-28dvh) min-h-(--sz-48px) overflow-y-auto px-1 py-1 text-sm scrollbar-auto-hide"
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
{actionError ? (
|
|
<p
|
|
className="px-1 text-xs text-destructive"
|
|
role="alert"
|
|
data-testid={creation ? "task-chat-create-error" : "task-chat-goal-error"}
|
|
>
|
|
{actionError}
|
|
</p>
|
|
) : null}
|
|
|
|
{visibleAttachments.length > 0 ? (
|
|
<AttachmentGroup
|
|
className="mb-1 px-1"
|
|
data-testid="task-chat-composer-attachments"
|
|
>
|
|
{visibleAttachments.map((attachment) => {
|
|
const kind = fileKindForName(attachment.name);
|
|
const KindIcon = kind.icon;
|
|
const sizeLabel = formatFileSize(attachment.size);
|
|
return (
|
|
<Attachment
|
|
key={attachment.id}
|
|
size="sm"
|
|
state={
|
|
attachment.status === "uploading"
|
|
? "uploading"
|
|
: attachment.status === "error"
|
|
? "error"
|
|
: "done"
|
|
}
|
|
>
|
|
<AttachmentMedia>
|
|
{attachment.status === "uploading" ? (
|
|
<Loader2 className="animate-spin" aria-hidden />
|
|
) : (
|
|
<KindIcon aria-hidden />
|
|
)}
|
|
</AttachmentMedia>
|
|
<AttachmentContent>
|
|
<AttachmentTitle className="max-w-48">
|
|
{attachment.name}
|
|
</AttachmentTitle>
|
|
<AttachmentDescription className="max-w-48">
|
|
{attachment.status === "uploading"
|
|
? "Uploading…"
|
|
: attachment.status === "error"
|
|
? (attachment.error ?? "Upload failed")
|
|
: [kind.label, sizeLabel]
|
|
.filter(Boolean)
|
|
.join(" · ")}
|
|
</AttachmentDescription>
|
|
</AttachmentContent>
|
|
<AttachmentActions>
|
|
{attachment.status === "error" && attachment.file ? (
|
|
<AttachmentAction
|
|
aria-label={`Retry ${attachment.name}`}
|
|
disabled={!!uncertainSubmission}
|
|
onClick={() => retryUpload(attachment)}
|
|
>
|
|
<RotateCw aria-hidden />
|
|
</AttachmentAction>
|
|
) : null}
|
|
<AttachmentAction
|
|
aria-label={`Remove ${attachment.name}`}
|
|
disabled={!!uncertainSubmission}
|
|
onClick={() =>
|
|
setAttachments((prev) =>
|
|
prev.filter((item) => item.id !== attachment.id),
|
|
)
|
|
}
|
|
>
|
|
<X aria-hidden />
|
|
</AttachmentAction>
|
|
</AttachmentActions>
|
|
</Attachment>
|
|
);
|
|
})}
|
|
</AttachmentGroup>
|
|
) : null}
|
|
|
|
{creation?.details}
|
|
|
|
<div
|
|
className={cn("mt-2 flex items-center gap-x-2 gap-y-3", mobile && !queuedEdit ? "flex-nowrap" : "flex-wrap")}
|
|
data-testid="task-chat-composer-actions"
|
|
>
|
|
<div className="flex min-w-0 max-w-full items-center gap-2">
|
|
{canAcceptFiles ? (
|
|
<input ref={fileInputRef} type="file" className="hidden" multiple onChange={handleFileInputChange} />
|
|
) : null}
|
|
<ComposerAddMenu
|
|
privacy={creation?.privacy}
|
|
mode={pendingMode}
|
|
onModeChange={!queuedEdit && onWorkModeChange ? changeMode : undefined}
|
|
onAttachFile={canAcceptFiles ? () => fileInputRef.current?.click() : undefined}
|
|
onGoal={!queuedEdit && !conversationMode && attachments.length === 0 &&
|
|
runnerGoalCapability?.availability === "available" && onRunnerGoalCommand
|
|
? prepareGoal : undefined}
|
|
disabled={disabled || !!uncertainSubmission || Boolean(creation?.readOnly)}
|
|
mobile={mobile}
|
|
triggerTestId="task-chat-composer-add"
|
|
menuTestId="task-chat-composer-add-menu"
|
|
/>
|
|
{creation?.privacy?.private ? <ComposerPrivacyChip inherited={creation.privacy.inherited} onRemove={() => creation.privacy!.onChange(false)} disabled={disabled} /> : null}
|
|
{queuedEdit ? (
|
|
<span className="px-1 text-xs font-medium text-muted-foreground">
|
|
{queuedEdit.stale
|
|
? "Queued message changed"
|
|
: "Editing queued message"}
|
|
</span>
|
|
) : (
|
|
<ComposerModeChip mode={pendingMode} onRemove={onWorkModeChange ? () => changeMode("standard") : undefined}
|
|
disabled={disabled || !!uncertainSubmission} testId="task-chat-composer-mode" mobile={mobile} />
|
|
)}
|
|
</div>
|
|
|
|
<div className={cn("ml-auto flex min-w-0 max-w-full items-center gap-2", mobile && !queuedEdit && "flex-1 justify-end")}>
|
|
|
|
{(showAssignee || showConversationModel) && !queuedEdit && companyId && modelAgents ? (
|
|
<ComposerRunSettingsPicker
|
|
companyId={companyId}
|
|
assigneeValue={assigneeValue}
|
|
currentAssigneeValue={currentAssigneeValue}
|
|
options={reassignOptions ?? []}
|
|
agents={modelAgents}
|
|
overrides={assigneeAdapterOverrides}
|
|
settings={runSettings}
|
|
onSettingsChange={setRunSettings}
|
|
onAssigneeChange={updatePendingAssignee}
|
|
allowAssigneeChange={showAssignee && !conversationMode}
|
|
renderAssigneeIdentity={(value, label, placement) => (
|
|
<AssigneeIdentityAvatar
|
|
assigneeValue={value}
|
|
label={label}
|
|
agentMap={agentMap}
|
|
userProfileMap={userProfileMap}
|
|
placement={placement}
|
|
/>
|
|
)}
|
|
disabled={disabled}
|
|
mobile={mobile}
|
|
/>
|
|
) : showAssignee && !queuedEdit ? (
|
|
<InlineEntitySelector
|
|
value={assigneeValue}
|
|
options={reassignOptions ?? []}
|
|
placeholder="Assignee"
|
|
mobileTitle="Select assignee"
|
|
noneLabel="No assignee"
|
|
searchPlaceholder="Search assignees…"
|
|
emptyMessage="No matches."
|
|
onChange={updatePendingAssignee}
|
|
disabled={disabled}
|
|
triggerTestId="task-chat-composer-assignee"
|
|
className="h-8 gap-1.5 border-0 bg-transparent px-2.5 text-xs shadow-none hover:bg-accent focus-visible:bg-accent focus-visible:ring-0"
|
|
renderTriggerValue={(option) => {
|
|
return (
|
|
<>
|
|
<AssigneeIdentityAvatar
|
|
assigneeValue={option?.id ?? ""}
|
|
label={assigneeLabel}
|
|
agentMap={agentMap}
|
|
userProfileMap={userProfileMap}
|
|
placement="trigger"
|
|
/>
|
|
<span className="max-w-40 truncate">{assigneeLabel}</span>
|
|
<ChevronDown
|
|
className="h-3 w-3 shrink-0 text-muted-foreground"
|
|
aria-hidden
|
|
/>
|
|
</>
|
|
);
|
|
}}
|
|
renderOption={(option) => {
|
|
return (
|
|
<>
|
|
<AssigneeIdentityAvatar
|
|
assigneeValue={option.id}
|
|
label={option.label}
|
|
agentMap={agentMap}
|
|
userProfileMap={userProfileMap}
|
|
placement="option"
|
|
/>
|
|
<span className="truncate">{option.label}</span>
|
|
</>
|
|
);
|
|
}}
|
|
/>
|
|
) : null}
|
|
|
|
{queuedEdit ? (
|
|
<button
|
|
type="button"
|
|
onClick={onCancelQueuedEdit}
|
|
disabled={submitting}
|
|
className="h-8 shrink-0 rounded-md px-2.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-50"
|
|
>
|
|
Cancel
|
|
</button>
|
|
) : null}
|
|
<button
|
|
type="button"
|
|
onClick={() => void (showStop ? stopControl.stop() : submit())}
|
|
disabled={
|
|
showStop
|
|
? disabled || stopControl.stopping
|
|
: disabled ||
|
|
(Boolean(pause) && !canResetPausedConversation) ||
|
|
submitting ||
|
|
!!uncertainSubmission ||
|
|
uploadPending ||
|
|
uploadFailed ||
|
|
Boolean(creation?.submitDisabled) ||
|
|
(body.trim().length === 0 && attachedRefs.length === 0 && !creation?.canSubmitWithoutBody)
|
|
}
|
|
title={
|
|
showStop
|
|
? stopControl.stopping
|
|
? "Stopping…"
|
|
: "Stop response"
|
|
: queuedEdit
|
|
? queuedEdit.stale
|
|
? "Queue as new message"
|
|
: "Save queued message"
|
|
: uploadPending
|
|
? "Waiting for upload to finish"
|
|
: uploadFailed
|
|
? "Remove the failed attachment to send"
|
|
: creation ? `${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>
|
|
);
|
|
}
|