1import { AuthOrchestrationOperateScope } from "@t3tools/contracts";
2import { readEnvironmentScope } from "./session";
3import type { ComposerTextPaste } from "../native/T3ComposerEditor.types";
4import { useAtomValue } from "@effect/atom-react";
5import { threadRuntimeIsActive } from "@t3tools/client-runtime/state/shell";
6import { resolvePendingHandoff } from "@t3tools/client-runtime/handoff";
7import {
8 deriveProviderSubagentStatus,
9 deriveRunlessWorkStartedAt,
10 deriveThreadActivityRun,
11 deriveThreadRuntime,
12 threadRuntimeHasInterruptibleRun,
13} from "@t3tools/client-runtime/state/thread-execution";
14import { useCallback, useEffect, useMemo, useRef, useState } from "react";
15import { Alert } from "react-native";
16
17import {
18 CommandId,
19 DEFAULT_PROVIDER_INTERACTION_MODE,
20 MessageId,
21 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
22 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
23 type EnvironmentId,
24 type ModelSelection,
25 type ProviderInteractionMode,
26 type RuntimeMode,
27 type ThreadId,
28} from "@t3tools/contracts";
29import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";
30import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/attachments";
31import { nextPastedTextFileName, pastedTextDisposition } from "@t3tools/client-runtime/text-paste";
32import {
33 parseCodexFeedbackCommand,
34 submitCodexFeedback,
35 type CodexFeedbackSubmission,
36} from "@t3tools/client-runtime/state/threads";
37import { resolveThreadWorkingStartedAt } from "@t3tools/client-runtime/state/models";
38import { upgradeLegacyContextMessage } from "@t3tools/shared/composerContextLegacy";
39import { composerContextSendBlockReason, reidentifyComposerContext } from "../lib/composerContext";
40import { uuidv4 } from "../lib/uuid";
41
42import { makeQueuedMessageMetadata } from "../lib/commandMetadata";
43import {
44 describeUnavailableModelSelection,
45 isModelSelectionUnavailable,
46} from "../lib/modelOptions";
47import { resolveProviderInteractionMode } from "./legacy-plan-mode";
48import {
49 convertPastedImagesToAttachments,
50 createPastedTextComposerAttachment,
51 pasteComposerClipboard,
52 pickComposerFiles,
53 pickComposerMedia,
54 removePersistedComposerAttachmentFile,
55} from "../lib/composerImages";
56import type { DraftComposerImageAttachment } from "../lib/composerImages";
57import { scopedThreadKey } from "../lib/scopedEntities";
58import { buildThreadFeed } from "../lib/threadActivity";
59import { acknowledgedThreadMessagesAtom } from "./acknowledged-thread-messages";
60import { appendPendingThreadMessages } from "../features/threads/pending-thread-feed";
61import { threadAllowsProviderSwitch } from "./thread-provider-switching";
62import { appAtomRegistry } from "../state/atom-registry";
63import { pendingThreadCreationMessage } from "./pending-thread-creation";
64import {
65 composerAttachmentUploadBlockReason,
66 composerAttachmentUploadsAtom,
67} from "../state/composer-attachment-uploads";
68import {
69 appendComposerDraftAttachments,
70 captureComposerDraftInsertion,
71 countComposerDraftAttachmentsAfterSelection,
72 insertComposerDraftText,
73 insertComposerDraftContext,
74 clearComposerDraftContent,
75 composerDraftsAtom,
76 composerContextImportsAtom,
77 ensureComposerDraftsLoaded,
78 getComposerDraftSnapshot,
79 mergeComposerDraftContent,
80 removeComposerDraftAttachment,
81 scheduleUnusedComposerAttachmentCleanup,
82 setComposerDraftText,
83 updateComposerDraftSettings,
84 useComposerDraft,
85} from "./use-composer-drafts";
86import {
87 resolveComposerDispatchMode,
88 type ActiveTurnComposerAction,
89} from "@t3tools/client-runtime/state/composer-dispatch";
90import { Atom } from "effect/reactivity";
91import { AsyncResult } from "effect/reactivity";
92import { prepareTurnAttachments } from "../lib/attachmentUpload";
93import { DEFAULT_FOLLOW_UP_BEHAVIOR } from "../lib/followUpBehavior";
94import { mobilePreferencesAtom } from "./preferences";
95import { environmentThreadDetails } from "./threads";
96import {
97 endQueuedRunEdit,
98 getQueuedRunEdit,
99 queuedEditDraftKey,
100 removeQueuedRunEditAttachment,
101 resolveQueuedEditPayload,
102 useQueuedRunEdit,
103} from "./queued-run-edit";
104import { setPendingConnectionError } from "../state/use-remote-environment-registry";
105import { clearThreadComposerError, setThreadComposerError } from "./thread-composer-error";
106import {
107 useSelectedThreadProjection,
108 useSelectedThreadVisibleTurnItems,
109} from "../state/use-thread-detail";
110import { useThreadSelection } from "../state/use-thread-selection";
111import { enqueueThreadOutboxMessage } from "./thread-outbox";
112import { dispatchingQueuedMessageIdAtom, useThreadOutboxMessages } from "./use-thread-outbox";
113import { threadEnvironment } from "./threads";
114import { useAtomCommand } from "./use-atom-command";
115
116const EMPTY_QUEUE_WORKFLOW_ATOM = Atom.make<null>(null).pipe(
117 Atom.withLabel("mobile-thread-queue-workflow:empty"),
118);
119
120export function appendReviewCommentToDraft(input: {
121 readonly environmentId: EnvironmentId;
122 readonly threadId: ThreadId;
123 readonly text: string;
124 readonly attachments?: ReadonlyArray<DraftComposerImageAttachment>;
125}): void {
126 const threadKey = scopedThreadKey(input.environmentId, input.threadId);
127 const upgraded = upgradeLegacyContextMessage(input.text);
128 if (
129 !insertComposerDraftContext(
130 threadKey,
131 reidentifyComposerContext(upgraded.text, upgraded.records, uuidv4),
132 )
133 ) {
134 Alert.alert("Too many context items", "Remove some context from the draft and try again.");
135 return;
136 }
137 if (input.attachments && input.attachments.length > 0) {
138 // Capped: a review comment is new content, not a send-failure restore, so
139 // it must not push the draft over the send limit. Overflow is released.
140 const rejectedCount = appendComposerDraftAttachments(threadKey, input.attachments, {
141 appendReference: true,
142 });
143 if (rejectedCount > 0) {
144 setPendingConnectionError(
145 `${rejectedCount} comment attachment${rejectedCount === 1 ? " was" : "s were"} not added. Messages can contain at most ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} attachments.`,
146 );
147 }
148 }
149}
150
151/**
152 * Which draft the composer is editing right now. While a queued message is
153 * being edited the composer is pointed at that edit's own draft, so typing,
154 * attaching, and pasting never touch the user's draft for the thread.
155 * Resolved per call rather than captured, so a callback created before the
156 * edit began still writes to the right place.
157 */
158function activeComposerDraftKey(thread: {
159 readonly environmentId: EnvironmentId;
160 readonly id: ThreadId;
161}): string {
162 const threadKey = scopedThreadKey(thread.environmentId, thread.id);
163 const edit = getQueuedRunEdit(threadKey);
164 return edit === null ? threadKey : queuedEditDraftKey(threadKey, edit.runId);
165}
166
167export function useThreadDraftForThread(input: {
168 readonly environmentId?: EnvironmentId;
169 readonly threadId?: ThreadId;
170}) {
171 const threadKey =
172 input.environmentId && input.threadId
173 ? scopedThreadKey(input.environmentId, input.threadId)
174 : null;
175 const draft = useComposerDraft(threadKey);
176
177 return {
178 draftMessage: draft.text,
179 draftAttachments: draft.attachments,
180 };
181}
182
183export function useThreadComposerState() {
184 const {
185 selectedThread: selectedThreadShell,
186 selectedThreadCreation,
187 selectedEnvironmentRuntime,
188 } = useThreadSelection();
189 const selectedThreadProjection = useSelectedThreadProjection();
190 const selectedThreadVisibleTurnItems = useSelectedThreadVisibleTurnItems();
191 const composerDrafts = useAtomValue(composerDraftsAtom);
192 const acknowledgedMessages = useAtomValue(acknowledgedThreadMessagesAtom);
193 const queuedMessagesByThreadKey = useThreadOutboxMessages();
194 const dispatchingQueuedMessageId = useAtomValue(dispatchingQueuedMessageIdAtom);
195 const [feedbackSubmissionsByThreadKey, setFeedbackSubmissionsByThreadKey] = useState<
196 Record<string, ReadonlyArray<CodexFeedbackSubmission>>
197 >({});
198 const uploadThreadFeedback = useAtomCommand(threadEnvironment.uploadFeedback, {
199 reportFailure: false,
200 });
201 const editQueuedRun = useAtomCommand(threadEnvironment.editQueuedRun, {
202 label: "edit queued message",
203 reportFailure: false,
204 });
205 const [isSavingQueuedEdit, setIsSavingQueuedEdit] = useState(false);
206 const savingQueuedEditRef = useRef(false);
207 const pastedTextFileNamesRef = useRef<{ threadKey: string | null; names: Set<string> }>({
208 threadKey: null,
209 names: new Set(),
210 });
211 const reservePastedTextFileName = useCallback(
212 (threadKey: string, existingNames: ReadonlyArray<string>) => {
213 if (pastedTextFileNamesRef.current.threadKey !== threadKey) {
214 pastedTextFileNamesRef.current = { threadKey, names: new Set() };
215 }
216 const names = pastedTextFileNamesRef.current.names;
217 for (const name of existingNames) names.add(name);
218 const nextName = nextPastedTextFileName([...names]);
219 names.add(nextName);
220 return nextName;
221 },
222 [],
223 );
224
225 useEffect(() => {
226 ensureComposerDraftsLoaded();
227 }, []);
228
229 const selectedThreadKey = selectedThreadShell
230 ? scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id)
231 : null;
232 // The creation entry is the thread itself (rendered as the first message),
233 // not a follow-up waiting behind it.
234 const selectedThreadQueuedMessages = useMemo(
235 () =>
236 selectedThreadKey
237 ? (queuedMessagesByThreadKey[selectedThreadKey] ?? []).filter(
238 (message) => message.creation === undefined,
239 )
240 : [],
241 [queuedMessagesByThreadKey, selectedThreadKey],
242 );
243 const feedbackSubmissions = useMemo(
244 () => (selectedThreadKey ? (feedbackSubmissionsByThreadKey[selectedThreadKey] ?? []) : []),
245 [feedbackSubmissionsByThreadKey, selectedThreadKey],
246 );
247 const dismissFeedback = useCallback(
248 (id: MessageId) => {
249 if (!selectedThreadKey) return;
250 setFeedbackSubmissionsByThreadKey((current) => ({
251 ...current,
252 [selectedThreadKey]: (current[selectedThreadKey] ?? []).filter((entry) => entry.id !== id),
253 }));
254 },
255 [selectedThreadKey],
256 );
257 const selectedThreadMessages = selectedThreadProjection?.projection.messages;
258 const selectedThreadAttempts = selectedThreadProjection?.projection.attempts;
259 const selectedThreadNodes = selectedThreadProjection?.projection.nodes;
260 // A thread whose creation has not delivered its turn yet: the prompt only
261 // exists in the outbox, so it is appended to whatever the server has. The
262 // detail is usually present but empty during a worktree checkout, so this
263 // cannot be an either/or with the loaded messages.
264 const pendingCreationMessage = selectedThreadCreation?.message ?? null;
265 const selectedThreadFeed = useMemo(() => {
266 const pendingCreation =
267 pendingCreationMessage !== null &&
268 !selectedThreadMessages?.some((message) => message.id === pendingCreationMessage.messageId)
269 ? [pendingThreadCreationMessage(pendingCreationMessage)]
270 : [];
271 const feed = buildThreadFeed(selectedThreadVisibleTurnItems, {
272 anchoredMessages: pendingCreation,
273 attempts: selectedThreadAttempts,
274 nodes: selectedThreadNodes,
275 });
276 const pendingAcknowledgments = acknowledgedMessages.filter(
277 (message) =>
278 scopedThreadKey(message.environmentId, message.threadId) === selectedThreadKey &&
279 !selectedThreadQueuedMessages.some((queued) => queued.messageId === message.messageId),
280 );
281 if (pendingAcknowledgments.length === 0) return feed;
282 return appendPendingThreadMessages(feed, feed, pendingAcknowledgments).map((entry) =>
283 entry.pendingMessage ? { ...entry, acknowledged: true } : entry,
284 );
285 }, [
286 selectedThreadMessages,
287 selectedThreadAttempts,
288 selectedThreadNodes,
289 selectedThreadVisibleTurnItems,
290 pendingCreationMessage,
291 selectedThreadKey,
292 selectedThreadQueuedMessages,
293 acknowledgedMessages,
294 ]);
295 useEffect(() => {
296 const echoedIds = new Set(selectedThreadMessages?.map((message) => message.id));
297 if (acknowledgedMessages.some((message) => echoedIds.has(message.messageId))) {
298 appAtomRegistry.set(
299 acknowledgedThreadMessagesAtom,
300 appAtomRegistry
301 .get(acknowledgedThreadMessagesAtom)
302 .filter((message) => !echoedIds.has(message.messageId)),
303 );
304 }
305 }, [acknowledgedMessages, selectedThreadMessages]);
306
307 const preferencesResult = useAtomValue(mobilePreferencesAtom);
308 const followUpBehavior = AsyncResult.isSuccess(preferencesResult)
309 ? (preferencesResult.value.followUpBehavior ?? DEFAULT_FOLLOW_UP_BEHAVIOR)
310 : DEFAULT_FOLLOW_UP_BEHAVIOR;
311 // Steering needs a live provider turn the adapter can interrupt; the queue
312 // workflow already derives that from the session's capabilities.
313 const queueWorkflow = useAtomValue(
314 selectedThreadShell === null
315 ? EMPTY_QUEUE_WORKFLOW_ATOM
316 : environmentThreadDetails.queueWorkflowAtom({
317 environmentId: selectedThreadShell.environmentId,
318 threadId: selectedThreadShell.id,
319 }),
320 );
321 const canSteerActiveTurn = queueWorkflow?.canPromoteToSteer === true;
322 const queuedRunEdit = useQueuedRunEdit(selectedThreadKey);
323 const composerDraftKey =
324 selectedThreadKey === null
325 ? null
326 : queuedRunEdit === null
327 ? selectedThreadKey
328 : queuedEditDraftKey(selectedThreadKey, queuedRunEdit.runId);
329 // Content follows the composer's current draft; the model and mode pickers
330 // stay bound to the thread's own draft, which is what they write to.
331 const editedDraft = composerDraftKey ? composerDrafts[composerDraftKey] : null;
332 const selectedDraft = selectedThreadKey ? composerDrafts[selectedThreadKey] : null;
333 const draftMessage = editedDraft?.text ?? "";
334 const draftAttachments = editedDraft?.attachments ?? [];
335 const selectedThreadQueueCount = selectedThreadQueuedMessages.length;
336 const selectedThread = selectedThreadShell;
337 const modelSelection = selectedDraft?.modelSelection ?? selectedThread?.modelSelection ?? null;
338 const pendingHandoff = useMemo(
339 () =>
340 resolvePendingHandoff(
341 selectedThreadProjection?.projection,
342 modelSelection,
343 selectedEnvironmentRuntime?.serverConfig?.providers ?? [],
344 ),
345 [modelSelection, selectedThreadProjection, selectedEnvironmentRuntime?.serverConfig?.providers],
346 );
347 const runtimeMode = selectedDraft?.runtimeMode ?? selectedThread?.runtimeMode ?? null;
348 const selectedProvider = selectedEnvironmentRuntime?.serverConfig?.providers.find(
349 (provider) => provider.instanceId === modelSelection?.instanceId,
350 );
351 const interactionMode = selectedThread
352 ? resolveProviderInteractionMode(
353 selectedProvider,
354 selectedDraft?.interactionMode ?? selectedThread.interactionMode,
355 )
356 : null;
357 // Whether the model picker may leave this thread's provider. Derived here
358 // because the projection already drives this hook; the composer only needs
359 // the answer, not a subscription to every projection update.
360 const canSwitchThreadProvider = useMemo(
361 () =>
362 threadAllowsProviderSwitch({
363 thread: selectedThreadShell,
364 projection: selectedThreadProjection?.projection,
365 }),
366 [selectedThreadProjection, selectedThreadShell],
367 );
368 const selectedThreadRuntime = useMemo(
369 () =>
370 selectedThreadProjection
371 ? deriveThreadRuntime(selectedThreadProjection.projection)
372 : (selectedThreadShell?.runtime ?? null),
373 [selectedThreadProjection, selectedThreadShell?.runtime],
374 );
375 const selectedThreadActivityRun = useMemo(
376 () =>
377 selectedThreadProjection
378 ? deriveThreadActivityRun(selectedThreadProjection.projection)
379 : (selectedThreadShell?.latestRun ?? null),
380 [selectedThreadProjection, selectedThreadShell?.latestRun],
381 );
382
383 const isCompacting = useMemo(() => {
384 const queuedCompact = selectedThreadQueuedMessages.some(
385 (message) =>
386 message.messageId === dispatchingQueuedMessageId &&
387 message.text.trim().toLowerCase() === "/compact" &&
388 message.attachments.length === 0,
389 );
390 if (queuedCompact) return true;
391 const activeRunId = selectedThreadRuntime?.activeRunId;
392 if (!activeRunId || !threadRuntimeIsActive(selectedThreadRuntime)) return false;
393 const compactMessage = selectedThreadVisibleTurnItems.findLast(
394 ({ item }) =>
395 item.runId === activeRunId &&
396 item.type === "user_message" &&
397 item.text.trim().toLowerCase() === "/compact" &&
398 item.attachments.length === 0,
399 );
400 if (!compactMessage) return false;
401 return !selectedThreadVisibleTurnItems.some(
402 ({ item }) =>
403 item.runId === activeRunId &&
404 item.type === "compaction" &&
405 (item.status === "completed" || item.status === "failed"),
406 );
407 }, [
408 dispatchingQueuedMessageId,
409 selectedThreadQueuedMessages,
410 selectedThreadRuntime,
411 selectedThreadVisibleTurnItems,
412 ]);
413
414 const runlessWorkStartedAt = useMemo(
415 () =>
416 selectedThreadProjection
417 ? deriveRunlessWorkStartedAt(selectedThreadProjection.projection)
418 : null,
419 [selectedThreadProjection],
420 );
421 const activeWorkStartedAt = useMemo(() => {
422 if (!selectedThreadShell) {
423 return null;
424 }
425 return (
426 resolveThreadWorkingStartedAt({
427 latestRun: selectedThreadActivityRun,
428 runtime: selectedThreadRuntime,
429 }) ?? runlessWorkStartedAt
430 );
431 }, [selectedThreadActivityRun, runlessWorkStartedAt, selectedThreadRuntime, selectedThreadShell]);
432 const runlessWorkActive = runlessWorkStartedAt !== null;
433
434 const providerSubagentStatus = useMemo(
435 () =>
436 selectedThreadProjection
437 ? deriveProviderSubagentStatus(selectedThreadProjection.projection)
438 : null,
439 [selectedThreadProjection],
440 );
441
442 // The run can start, or be cancelled from another client, while its message
443 // is open in the composer. Leave edit mode rather than saving into a run the
444 // server will refuse, and keep whatever was typed if there is room for it.
445 const selectedThreadRuns = selectedThreadProjection?.projection.runs;
446 const editedRunId = queuedRunEdit?.runId ?? null;
447 useEffect(() => {
448 if (selectedThreadKey === null || editedRunId === null || selectedThreadRuns === undefined) {
449 return;
450 }
451 if (savingQueuedEditRef.current) return;
452 const stillQueued = selectedThreadRuns.some(
453 (run) => run.id === editedRunId && run.status === "queued",
454 );
455 if (stillQueued) return;
456 const editDraftKey = queuedEditDraftKey(selectedThreadKey, editedRunId);
457 const editDraft = getComposerDraftSnapshot(editDraftKey);
458 const threadDraft = getComposerDraftSnapshot(selectedThreadKey);
459 const keepable =
460 editDraft.text.trim().length > 0 &&
461 threadDraft.text.trim().length === 0 &&
462 threadDraft.attachments.length === 0;
463 if (keepable) {
464 void mergeComposerDraftContent(selectedThreadKey, {
465 text: editDraft.text,
466 attachments: editDraft.attachments,
467 ...(editDraft.context ? { context: editDraft.context } : {}),
468 });
469 }
470 endQueuedRunEdit(selectedThreadKey, { deferAttachmentCleanup: keepable });
471 setThreadComposerError(
472 selectedThreadKey,
473 keepable
474 ? "That message already started. Your edit is back in the composer."
475 : "That message already started, so the edit was discarded.",
476 );
477 }, [editedRunId, selectedThreadKey, selectedThreadRuns]);
478
479 const activeThreadBusy = threadRuntimeIsActive(selectedThreadRuntime);
480 const interruptibleRunId = threadRuntimeHasInterruptibleRun(selectedThreadRuntime)
481 ? (selectedThreadRuntime?.activeRunId ?? null)
482 : null;
483
484 const cancelQueuedRunEdit = useCallback(() => {
485 if (selectedThreadKey === null || savingQueuedEditRef.current) return;
486 endQueuedRunEdit(selectedThreadKey);
487 }, [selectedThreadKey]);
488
489 const onRemoveQueuedEditAttachment = useCallback(
490 (attachmentId: string) => {
491 if (selectedThreadKey === null) return;
492 removeQueuedRunEditAttachment(selectedThreadKey, attachmentId);
493 },
494 [selectedThreadKey],
495 );
496
497 const saveQueuedRunEdit = useCallback(async () => {
498 const thread = selectedThreadShell;
499 if (!thread || savingQueuedEditRef.current) return;
500 const threadKey = scopedThreadKey(thread.environmentId, thread.id);
501 const edit = getQueuedRunEdit(threadKey);
502 if (edit === null) return;
503 const draft = getComposerDraftSnapshot(queuedEditDraftKey(threadKey, edit.runId));
504 const text = draft.text.trim();
505 if (text.length === 0) {
506 // The server rejects an empty queued message, attachments or not.
507 Alert.alert("Add a message", "A queued message cannot be left empty.");
508 return;
509 }
510 if (
511 edit.existingAttachments.length + draft.attachments.length >
512 PROVIDER_SEND_TURN_MAX_ATTACHMENTS
513 ) {
514 Alert.alert(
515 "Too many attachments",
516 `Remove attachments until there are at most ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS}.`,
517 );
518 return;
519 }
520 savingQueuedEditRef.current = true;
521 setIsSavingQueuedEdit(true);
522 try {
523 const capabilities = selectedEnvironmentRuntime?.serverConfig?.environment.capabilities;
524 const prepared = await prepareTurnAttachments({
525 environmentId: thread.environmentId,
526 attachments: draft.attachments,
527 supportsImageUploads: capabilities?.attachmentUploads === true,
528 });
529 if (prepared.status !== "ready") return;
530 const payload = resolveQueuedEditPayload({
531 edit,
532 draftContext: draft.context,
533 draftAttachments: draft.attachments,
534 uploaded: prepared.attachments,
535 });
536 const result = await editQueuedRun({
537 environmentId: thread.environmentId,
538 input: {
539 threadId: thread.id,
540 runId: edit.runId,
541 text,
542 edit: {
543 messageId: edit.messageId,
544 attachments: payload.attachments,
545 ...(payload.context ? { context: payload.context } : {}),
546 },
547 },
548 });
549 if (result._tag !== "Success") {
550 Alert.alert(
551 "Could not save the queued message",
552 "It may have already started. Your edit is still in the composer.",
553 );
554 return;
555 }
556 endQueuedRunEdit(threadKey, { deferAttachmentCleanup: true });
557 scheduleUnusedComposerAttachmentCleanup(draft.attachments);
558 } finally {
559 savingQueuedEditRef.current = false;
560 setIsSavingQueuedEdit(false);
561 }
562 }, [editQueuedRun, selectedEnvironmentRuntime?.serverConfig, selectedThreadShell]);
563
564 const onSendMessage = useCallback(
565 async (followUpOverride?: ActiveTurnComposerAction) => {
566 if (
567 selectedThreadShell &&
568 selectedEnvironmentRuntime?.connectionState === "connected" &&
569 !readEnvironmentScope(selectedThreadShell.environmentId, AuthOrchestrationOperateScope)
570 )
571 return null;
572 if (!selectedThreadShell) {
573 return null;
574 }
575 // The server has not created this thread yet. Queuing a follow-up against
576 // its id would strand the message: if the creation is rejected the thread
577 // never appears and the drain drops the orphan. The composer disables its
578 // send button too; this guard also covers the editor's submit key.
579 if (selectedThreadCreation !== null) {
580 return null;
581 }
582
583 // Editing a queued message repurposes the composer: the send button saves
584 // the edit in place instead of enqueuing a new message.
585 const editKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id);
586 if (getQueuedRunEdit(editKey) !== null) {
587 await saveQueuedRunEdit();
588 return null;
589 }
590
591 const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id);
592 const draft = getComposerDraftSnapshot(threadKey);
593 if (appAtomRegistry.get(composerContextImportsAtom)[threadKey]) return null;
594 const thread = selectedThreadShell;
595 const text = draft.text.trim();
596 const attachments = draft.attachments;
597 if (
598 composerAttachmentUploadBlockReason({
599 environmentId: selectedThreadShell.environmentId,
600 attachments,
601 connected: selectedEnvironmentRuntime?.connectionState === "connected",
602 serverConfig: selectedEnvironmentRuntime?.serverConfig ?? null,
603 states: appAtomRegistry.get(composerAttachmentUploadsAtom),
604 }) !== null
605 )
606 return null;
607 if (text.length === 0 && attachments.length === 0) {
608 return null;
609 }
610 // A send-failure restore appends with allowOverflow so it never drops the
611 // user's files, which can leave the draft over the cap. Sending it anyway
612 // would enqueue a message that outbox recovery rejects forever, so block
613 // here until the user removes attachments.
614 if (attachments.length > PROVIDER_SEND_TURN_MAX_ATTACHMENTS) {
615 Alert.alert(
616 "Too many attachments",
617 `Remove attachments until there are at most ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS}.`,
618 );
619 return null;
620 }
621
622 const contextBlockReason = composerContextSendBlockReason(draft.context);
623 if (contextBlockReason) {
624 Alert.alert("Too much context", contextBlockReason);
625 return null;
626 }
627
628 const modelSelection = draft.modelSelection ?? thread.modelSelection;
629 const serverConfig = selectedEnvironmentRuntime?.serverConfig;
630 if (
631 selectedEnvironmentRuntime?.connectionState === "connected" &&
632 isModelSelectionUnavailable(serverConfig, modelSelection, thread.projectId)
633 ) {
634 const { title, detail } = describeUnavailableModelSelection(
635 serverConfig,
636 modelSelection,
637 thread.projectId,
638 );
639 Alert.alert(title, detail);
640 return null;
641 }
642 const provider = serverConfig?.providers.find(
643 (entry) => entry.instanceId === modelSelection.instanceId,
644 );
645 const feedbackCommand =
646 attachments.length === 0 && provider?.driver === "codex"
647 ? parseCodexFeedbackCommand(text)
648 : null;
649 if (feedbackCommand) {
650 if (!readEnvironmentScope(selectedThreadShell.environmentId, AuthOrchestrationOperateScope))
651 return null;
652 if (thread.activeProviderThreadId === null) {
653 Alert.alert("Start a Codex thread first", "Send a message before you submit feedback.");
654 return null;
655 }
656 const metadata = makeQueuedMessageMetadata();
657 await submitCodexFeedback({
658 submission: {
659 id: MessageId.make(metadata.messageId),
660 command: text,
661 createdAt: metadata.createdAt,
662 },
663 clearDraft: () => clearComposerDraftContent(threadKey),
664 onUpdate: (submission) => {
665 setFeedbackSubmissionsByThreadKey((current) => {
666 const existing = current[threadKey] ?? [];
667 const found = existing.some((entry) => entry.id === submission.id);
668 return {
669 ...current,
670 [threadKey]: found
671 ? existing.map((entry) => (entry.id === submission.id ? submission : entry))
672 : [...existing, submission],
673 };
674 });
675 },
676 upload: () =>
677 uploadThreadFeedback({
678 environmentId: thread.environmentId,
679 input: { threadId: thread.id, ...feedbackCommand },
680 }),
681 });
682 return null;
683 }
684
685 // Resolved here rather than at drain time: the outbox can deliver minutes
686 // later, and the choice belongs to the moment the user pressed send.
687 // Steering travels as "auto" so a turn that ends in the meantime degrades
688 // to a queued run on the server instead of failing the delivery and
689 // bouncing the message back into the draft.
690 const followUpAction = resolveComposerDispatchMode({
691 running: activeThreadBusy && canSteerActiveTurn,
692 alternateModifier: followUpOverride !== undefined && followUpOverride !== followUpBehavior,
693 activeTurnDefault: followUpBehavior,
694 });
695 // A send while the worktree is still being set up waits behind that setup,
696 // so the server can hold it if the setup ends without a worktree.
697 // Before detail loads, the shell's latest run can be a queued follow-up
698 // while its runtime still reports the setup.
699 const setupRunning = [selectedThreadActivityRun?.status, selectedThreadRuntime?.status].some(
700 (status) => status === "preparing" || status === "starting",
701 );
702 const followUpDispatchMode = setupRunning
703 ? "queue"
704 : followUpAction === "auto"
705 ? null
706 : followUpAction === "queue"
707 ? "queue"
708 : "auto";
709
710 const metadata = makeQueuedMessageMetadata();
711 const messageId = MessageId.make(metadata.messageId);
712 // A new send supersedes the reason the previous one bounced back.
713 clearThreadComposerError(threadKey);
714 // Enqueue publishes the queued atom synchronously (the durable write
715 // happens behind it), so clearing the draft here gives send feedback on
716 // the tap frame instead of after file I/O. If the write fails the message
717 // is rolled out of the queue and the content is merged back into the
718 // draft, preserving anything typed since.
719 const enqueuePromise = enqueueThreadOutboxMessage({
720 environmentId: selectedThreadShell.environmentId,
721 threadId: selectedThreadShell.id,
722 messageId,
723 commandId: CommandId.make(metadata.commandId),
724 text,
725 attachments,
726 context: draft.context,
727 modelSelection,
728 runtimeMode: draft.runtimeMode ?? thread.runtimeMode,
729 interactionMode: resolveProviderInteractionMode(
730 provider,
731 draft.interactionMode ?? thread.interactionMode,
732 ),
733 ...(followUpDispatchMode === null ? {} : { dispatchMode: followUpDispatchMode }),
734 createdAt: metadata.createdAt,
735 });
736 clearComposerDraftContent(threadKey, { deferAttachmentCleanup: true });
737 enqueuePromise.then(
738 () => scheduleUnusedComposerAttachmentCleanup(attachments),
739 (error: unknown) => {
740 // Restore text via merge (idempotent) but attachments via the uncapped
741 // append: the merge path slots existing attachments first and truncates
742 // at the send limit, which would silently drop this message's images if
743 // the user attached new ones while the write was in flight.
744 void mergeComposerDraftContent(threadKey, {
745 text,
746 context: draft.context,
747 attachments: [],
748 });
749 appendComposerDraftAttachments(threadKey, attachments, { allowOverflow: true });
750 setThreadComposerError(
751 threadKey,
752 error instanceof Error ? error.message : "Failed to save the queued message.",
753 );
754 },
755 );
756 return messageId;
757 },
758 [
759 activeThreadBusy,
760 canSteerActiveTurn,
761 followUpBehavior,
762 saveQueuedRunEdit,
763 selectedEnvironmentRuntime?.connectionState,
764 selectedEnvironmentRuntime?.serverConfig,
765 selectedThreadActivityRun?.status,
766 selectedThreadCreation,
767 selectedThreadRuntime?.status,
768 selectedThreadShell,
769 uploadThreadFeedback,
770 ],
771 );
772
773 const onChangeDraftMessage = useCallback(
774 (value: string) => {
775 if (!selectedThreadShell) {
776 return;
777 }
778
779 const threadKey = activeComposerDraftKey(selectedThreadShell);
780 setComposerDraftText(threadKey, value);
781 },
782 [selectedThreadShell],
783 );
784
785 const onPickDraftMedia = useCallback(async () => {
786 if (!selectedThreadShell) {
787 return;
788 }
789
790 const threadKey = activeComposerDraftKey(selectedThreadShell);
791 const insertion = captureComposerDraftInsertion(threadKey);
792 const capabilities = selectedEnvironmentRuntime?.serverConfig?.environment.capabilities;
793 const result = await pickComposerMedia({
794 existingCount: countComposerDraftAttachmentsAfterSelection(threadKey, insertion),
795 maxVideoBytes:
796 capabilities?.attachmentUploads === true
797 ? capabilities.fileAttachments?.maxUploadBytes
798 : undefined,
799 });
800 const rejectedCount = appendComposerDraftAttachments(threadKey, result.attachments, {
801 appendReference: true,
802 insertion,
803 });
804 const problems = [
805 ...(result.error ? [result.error] : []),
806 ...(rejectedCount > 0
807 ? [`You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} attachments per message.`]
808 : []),
809 ];
810 if (problems.length > 0) {
811 Alert.alert("Could not attach photo or video", problems.join("\n\n"));
812 }
813 }, [composerDrafts, selectedEnvironmentRuntime?.serverConfig, selectedThreadShell]);
814
815 const onPickDraftFiles = useCallback(async () => {
816 if (!selectedThreadShell) {
817 return;
818 }
819 const maxBytes =
820 selectedEnvironmentRuntime?.serverConfig?.environment.capabilities.fileAttachments
821 ?.maxUploadBytes;
822 if (maxBytes === undefined) {
823 Alert.alert("Could not attach file", "This server does not support file attachments.");
824 return;
825 }
826
827 const threadKey = activeComposerDraftKey(selectedThreadShell);
828 const insertion = captureComposerDraftInsertion(threadKey);
829 // pickComposerFiles clamps the advertised limit to the contract maximum.
830 const result = await pickComposerFiles({
831 existingCount: countComposerDraftAttachmentsAfterSelection(threadKey, insertion),
832 maxBytes,
833 });
834 const rejectedCount = appendComposerDraftAttachments(threadKey, result.files, {
835 appendReference: true,
836 insertion,
837 });
838 // The picker error and the live-cap rejection can both happen in one
839 // pick; report both in a single alert.
840 const problems = [
841 ...(result.error ? [result.error] : []),
842 ...(rejectedCount > 0
843 ? [`You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} files per message.`]
844 : []),
845 ];
846 if (problems.length > 0) {
847 Alert.alert("Could not attach file", problems.join("\n\n"));
848 }
849 }, [composerDrafts, selectedEnvironmentRuntime?.serverConfig, selectedThreadShell]);
850
851 const onPasteIntoDraft = useCallback(async () => {
852 if (!selectedThreadShell) {
853 return;
854 }
855
856 const threadKey = activeComposerDraftKey(selectedThreadShell);
857 const insertion = captureComposerDraftInsertion(threadKey);
858 const result = await pasteComposerClipboard({
859 existingCount: countComposerDraftAttachmentsAfterSelection(threadKey, insertion),
860 });
861 const rejectedPasteCount = appendComposerDraftAttachments(threadKey, result.images, {
862 appendReference: true,
863 insertion,
864 });
865 if (result.text) {
866 const currentDraft = getComposerDraftSnapshot(threadKey);
867 const currentAttachments = currentDraft.attachments;
868 const capabilities = selectedEnvironmentRuntime?.serverConfig?.environment.capabilities;
869 const advertisedMax =
870 capabilities?.attachmentUploads === true
871 ? capabilities.fileAttachments?.maxUploadBytes
872 : undefined;
873 const maxBytes =
874 advertisedMax === undefined ? null : clampFileAttachmentUploadBytes(advertisedMax);
875 const wouldExceedInputLimit =
876 currentDraft.text.length -
877 (currentDraft.text === insertion.text
878 ? Math.max(0, insertion.end - insertion.start)
879 : 0) +
880 result.text.length >
881 PROVIDER_SEND_TURN_MAX_INPUT_CHARS;
882 const shouldFold =
883 pastedTextDisposition({
884 text: result.text,
885 wouldExceedInputLimit,
886 canAttach: true,
887 }) === "attachment";
888 const canAttach =
889 maxBytes !== null &&
890 countComposerDraftAttachmentsAfterSelection(threadKey, insertion) <
891 PROVIDER_SEND_TURN_MAX_ATTACHMENTS &&
892 new TextEncoder().encode(result.text).byteLength <= maxBytes;
893 if (shouldFold && canAttach && maxBytes !== null) {
894 try {
895 const attachment = await createPastedTextComposerAttachment({
896 text: result.text,
897 name: reservePastedTextFileName(
898 threadKey,
899 currentAttachments.map((item) => item.name),
900 ),
901 maxBytes,
902 });
903 // Same reference the pasted images above get: a folded paste is only visible
904 // as its chip until the message is sent.
905 if (
906 appendComposerDraftAttachments(threadKey, [attachment], {
907 appendReference: true,
908 insertion,
909 }) > 0
910 ) {
911 await removePersistedComposerAttachmentFile(attachment.fileUri);
912 setPendingConnectionError(
913 `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} files per message.`,
914 );
915 }
916 } catch (error) {
917 setPendingConnectionError(
918 error instanceof Error ? error.message : "Could not attach pasted text.",
919 );
920 }
921 } else if (shouldFold && !wouldExceedInputLimit) {
922 insertComposerDraftText(threadKey, result.text, insertion);
923 } else if (shouldFold) {
924 setPendingConnectionError(
925 wouldExceedInputLimit
926 ? "Pasted text is too large for this message. Remove some text or an attachment, then paste again."
927 : "Could not attach pasted text. Remove an attachment or use a smaller paste, then try again.",
928 );
929 } else {
930 insertComposerDraftText(threadKey, result.text, insertion);
931 }
932 }
933 if (result.error) {
934 setPendingConnectionError(result.error);
935 } else if (rejectedPasteCount > 0) {
936 setPendingConnectionError(
937 `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} files per message.`,
938 );
939 }
940 }, [
941 composerDrafts,
942 reservePastedTextFileName,
943 selectedEnvironmentRuntime?.serverConfig,
944 selectedThreadShell,
945 ]);
946
947 const onNativePasteImages = useCallback(
948 async (uris: ReadonlyArray<string>) => {
949 if (!selectedThreadShell || uris.length === 0) {
950 return;
951 }
952
953 const threadKey = activeComposerDraftKey(selectedThreadShell);
954 const insertion = captureComposerDraftInsertion(threadKey);
955 try {
956 const images = await convertPastedImagesToAttachments({
957 uris,
958 existingCount: countComposerDraftAttachmentsAfterSelection(threadKey, insertion),
959 });
960 if (images.length > 0) {
961 appendComposerDraftAttachments(threadKey, images, { appendReference: true, insertion });
962 }
963 } catch (error) {
964 console.error("[native paste] error converting images", {
965 environmentId: selectedThreadShell.environmentId,
966 threadId: selectedThreadShell.id,
967 uriCount: uris.length,
968 ...safeErrorLogAttributes(error),
969 });
970 }
971 },
972 [composerDrafts, selectedThreadShell],
973 );
974
975 const onNativePasteText = useCallback(
976 async (paste: ComposerTextPaste) => {
977 if (!selectedThreadShell) return;
978 const capabilities = selectedEnvironmentRuntime?.serverConfig?.environment.capabilities;
979 const advertisedMax =
980 capabilities?.attachmentUploads === true
981 ? capabilities.fileAttachments?.maxUploadBytes
982 : undefined;
983 if (advertisedMax === undefined) return;
984
985 const threadKey = activeComposerDraftKey(selectedThreadShell);
986 const insertion = { text: paste.value, ...paste.selection };
987 const currentAttachments = getComposerDraftSnapshot(threadKey).attachments;
988 try {
989 const attachment = await createPastedTextComposerAttachment({
990 text: paste.text,
991 name: reservePastedTextFileName(
992 threadKey,
993 currentAttachments.map((item) => item.name),
994 ),
995 maxBytes: clampFileAttachmentUploadBytes(advertisedMax),
996 });
997 // The chip is how a folded paste stays visible: without it the attachment is in the
998 // draft but nothing in the composer says so until the message is sent. Web folds
999 // through its ordinary attach path, which always writes a reference; match that.
1000 const rejectedCount = appendComposerDraftAttachments(threadKey, [attachment], {
1001 appendReference: true,
1002 insertion,
1003 });
1004 if (rejectedCount > 0) {
1005 await removePersistedComposerAttachmentFile(attachment.fileUri);
1006 setPendingConnectionError(
1007 `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} files per message.`,
1008 );
1009 }
1010 } catch (error) {
1011 setPendingConnectionError(
1012 error instanceof Error ? error.message : "Could not attach pasted text.",
1013 );
1014 }
1015 },
1016 [
1017 composerDrafts,
1018 reservePastedTextFileName,
1019 selectedEnvironmentRuntime?.serverConfig,
1020 selectedThreadShell,
1021 ],
1022 );
1023
1024 const onRemoveDraftImage = useCallback(
1025 (imageId: string) => {
1026 if (!selectedThreadShell) {
1027 return;
1028 }
1029
1030 const threadKey = activeComposerDraftKey(selectedThreadShell);
1031 removeComposerDraftAttachment(threadKey, imageId);
1032 },
1033 [selectedThreadShell],
1034 );
1035
1036 const onUpdateModelSelection = useCallback(
1037 (value: ModelSelection) => {
1038 if (!selectedThreadKey) {
1039 return;
1040 }
1041 const provider = selectedEnvironmentRuntime?.serverConfig?.providers.find(
1042 (candidate) => candidate.instanceId === value.instanceId,
1043 );
1044 updateComposerDraftSettings(selectedThreadKey, {
1045 modelSelection: value,
1046 ...(provider?.showInteractionModeToggle === false
1047 ? { interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE }
1048 : {}),
1049 });
1050 },
1051 [selectedEnvironmentRuntime?.serverConfig, selectedThreadKey],
1052 );
1053
1054 const onUpdateRuntimeMode = useCallback(
1055 (value: RuntimeMode) => {
1056 if (!selectedThreadKey) {
1057 return;
1058 }
1059 updateComposerDraftSettings(selectedThreadKey, { runtimeMode: value });
1060 },
1061 [selectedThreadKey],
1062 );
1063
1064 const onUpdateInteractionMode = useCallback(
1065 (value: ProviderInteractionMode) => {
1066 if (!selectedThreadKey) {
1067 return;
1068 }
1069 const modelSelection =
1070 getComposerDraftSnapshot(selectedThreadKey).modelSelection ??
1071 selectedThread?.modelSelection;
1072 const provider = selectedEnvironmentRuntime?.serverConfig?.providers.find(
1073 (candidate) => candidate.instanceId === modelSelection?.instanceId,
1074 );
1075 updateComposerDraftSettings(selectedThreadKey, {
1076 interactionMode: resolveProviderInteractionMode(provider, value),
1077 });
1078 },
1079 [selectedEnvironmentRuntime?.serverConfig, selectedThread?.modelSelection, selectedThreadKey],
1080 );
1081
1082 return {
1083 feedbackSubmissions,
1084 dismissFeedback,
1085 selectedThreadFeed,
1086 selectedThreadActivityRun,
1087 selectedThreadQueueCount,
1088 selectedThreadQueuedMessages,
1089 dispatchingQueuedMessageId,
1090 activeWorkStartedAt,
1091 runlessWorkActive,
1092 providerSubagentStatus,
1093 isCompacting,
1094 draftMessage,
1095 draftAttachments,
1096 composerDraftKey,
1097 followUpBehavior,
1098 canSteerActiveTurn,
1099 queuedRunEdit,
1100 isSavingQueuedEdit,
1101 cancelQueuedRunEdit,
1102 onRemoveQueuedEditAttachment,
1103 modelSelection,
1104 pendingHandoff,
1105 canSwitchThreadProvider,
1106 runtimeMode,
1107 interactionMode,
1108 activeThreadBusy,
1109 interruptibleRunId,
1110 onChangeDraftMessage,
1111 onPickDraftMedia,
1112 onPickDraftFiles,
1113 onPasteIntoDraft,
1114 onNativePasteImages,
1115 onNativePasteText,
1116 onRemoveDraftImage,
1117 onSendMessage,
1118 onUpdateModelSelection,
1119 onUpdateRuntimeMode,
1120 onUpdateInteractionMode,
1121 };
1122}