import { useAtomValue } from "@effect/atom-react";import { useCallback, useEffect, useMemo, useState } from "react";import { Alert } from "react-native"; import { CommandId, DEFAULT_PROVIDER_INTERACTION_MODE, MessageId, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, type EnvironmentId, type ModelSelection, type ProviderInteractionMode, type RuntimeMode, type ThreadId,} from "@t3tools/contracts";import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";import { parseCodexFeedbackCommand, submitCodexFeedback, type CodexFeedbackSubmission,} from "@t3tools/client-runtime/state/threads";import { deriveActiveWorkStartedAt } from "@t3tools/shared/orchestrationTiming"; import { makeQueuedMessageMetadata } from "../lib/commandMetadata";import { isModelSelectionUnavailable } from "../lib/modelOptions";import { resolveProviderInteractionMode } from "../features/threads/legacy-plan-mode";import { convertPastedImagesToAttachments, pasteComposerClipboard, pickComposerFiles, pickComposerMedia,} from "../lib/composerImages";import type { DraftComposerImageAttachment } from "../lib/composerImages";import { scopedThreadKey } from "../lib/scopedEntities";import { buildThreadFeed } from "../lib/threadActivity";import { acknowledgedThreadMessagesAtom } from "./acknowledged-thread-messages";import { appendPendingThreadMessages } from "../features/threads/pending-thread-feed";import { appAtomRegistry } from "../state/atom-registry";import { pendingThreadCreationMessage } from "./pending-thread-creation";import { appendComposerDraftAttachments, appendComposerDraftText, clearComposerDraftContent, composerDraftsAtom, ensureComposerDraftsLoaded, getComposerDraftSnapshot, mergeComposerDraftContent, removeComposerDraftAttachment, scheduleUnusedComposerAttachmentCleanup, setComposerDraftText, updateComposerDraftSettings, useComposerDraft,} from "./use-composer-drafts";import { setPendingConnectionError } from "../state/use-remote-environment-registry";import { useSelectedThreadDetail } from "../state/use-thread-detail";import { useThreadSelection } from "../state/use-thread-selection";import { enqueueThreadOutboxMessage } from "./thread-outbox";import { dispatchingQueuedMessageIdAtom, useThreadOutboxMessages } from "./use-thread-outbox";import { threadEnvironment } from "./threads";import { useAtomCommand } from "./use-atom-command";import { composerAttachmentUploadBlockReason, composerAttachmentUploadsAtom,} from "./composer-attachment-uploads"; export function appendReviewCommentToDraft(input: { readonly environmentId: EnvironmentId; readonly threadId: ThreadId; readonly text: string; readonly attachments?: ReadonlyArray<DraftComposerImageAttachment>;}): void { const threadKey = scopedThreadKey(input.environmentId, input.threadId); const existing = appAtomRegistry.get(composerDraftsAtom)[threadKey]?.text ?? ""; const separator = existing.trim().length > 0 && !existing.endsWith("\n") ? "\n\n" : ""; setComposerDraftText(threadKey, `${existing}${separator}${input.text}`); if (input.attachments && input.attachments.length > 0) { // Capped: a review comment is new content, not a send-failure restore, so // it must not push the draft over the send limit. Overflow is released. const rejectedCount = appendComposerDraftAttachments(threadKey, input.attachments); if (rejectedCount > 0) { setPendingConnectionError( `${rejectedCount} comment attachment${rejectedCount === 1 ? " was" : "s were"} not added. Messages can contain at most ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} attachments.`, ); } }} export function useThreadDraftForThread(input: { readonly environmentId?: EnvironmentId; readonly threadId?: ThreadId;}) { const threadKey = input.environmentId && input.threadId ? scopedThreadKey(input.environmentId, input.threadId) : null; const draft = useComposerDraft(threadKey); return { draftMessage: draft.text, draftAttachments: draft.attachments, };} export function useThreadComposerState() { const { selectedThread: selectedThreadShell, selectedThreadCreation, selectedEnvironmentRuntime, } = useThreadSelection(); const selectedThreadDetail = useSelectedThreadDetail(); const composerDrafts = useAtomValue(composerDraftsAtom); const acknowledgedMessages = useAtomValue(acknowledgedThreadMessagesAtom); const queuedMessagesByThreadKey = useThreadOutboxMessages(); const dispatchingQueuedMessageId = useAtomValue(dispatchingQueuedMessageIdAtom); const [feedbackSubmissionsByThreadKey, setFeedbackSubmissionsByThreadKey] = useState< Record<string, ReadonlyArray<CodexFeedbackSubmission>> >({}); const uploadThreadFeedback = useAtomCommand(threadEnvironment.uploadFeedback, { reportFailure: false, }); useEffect(() => { ensureComposerDraftsLoaded(); }, []); const selectedThreadKey = selectedThreadShell ? scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id) : null; // The creation entry is the thread itself (rendered as the first message), // not a follow-up waiting behind it. const selectedThreadQueuedMessages = useMemo( () => selectedThreadKey ? (queuedMessagesByThreadKey[selectedThreadKey] ?? []).filter( (message) => message.creation === undefined, ) : [], [queuedMessagesByThreadKey, selectedThreadKey], ); const feedbackSubmissions = useMemo( () => (selectedThreadKey ? (feedbackSubmissionsByThreadKey[selectedThreadKey] ?? []) : []), [feedbackSubmissionsByThreadKey, selectedThreadKey], ); const dismissFeedback = useCallback( (id: MessageId) => { if (!selectedThreadKey) return; setFeedbackSubmissionsByThreadKey((current) => ({ ...current, [selectedThreadKey]: (current[selectedThreadKey] ?? []).filter((entry) => entry.id !== id), })); }, [selectedThreadKey], ); const selectedThreadMessages = selectedThreadDetail?.messages; const selectedThreadActivities = selectedThreadDetail?.activities; // A thread whose creation has not delivered its turn yet: the prompt only // exists in the outbox, so it is appended to whatever the server has. The // detail is usually present but empty during a worktree checkout, so this // cannot be an either/or with the loaded messages. const pendingCreationMessage = selectedThreadCreation?.message ?? null; const selectedThreadFeed = useMemo(() => { const loadedMessages = selectedThreadMessages ?? []; const feed = (selectedThreadMessages && selectedThreadActivities) || pendingCreationMessage !== null ? buildThreadFeed({ messages: pendingCreationMessage !== null && !loadedMessages.some((message) => message.id === pendingCreationMessage.messageId) ? [...loadedMessages, pendingThreadCreationMessage(pendingCreationMessage)] : loadedMessages, activities: selectedThreadActivities ?? [], }) : []; const pendingAcknowledgments = acknowledgedMessages.filter( (message) => scopedThreadKey(message.environmentId, message.threadId) === selectedThreadKey && !selectedThreadQueuedMessages.some((queued) => queued.messageId === message.messageId), ); if (pendingAcknowledgments.length === 0) return feed; return appendPendingThreadMessages(feed, feed, pendingAcknowledgments).map((entry) => entry.pendingMessage ? { ...entry, acknowledged: true } : entry, ); }, [ selectedThreadActivities, selectedThreadMessages, pendingCreationMessage, selectedThreadKey, selectedThreadQueuedMessages, acknowledgedMessages, ]); useEffect(() => { const echoedIds = new Set(selectedThreadMessages?.map((message) => message.id)); if (acknowledgedMessages.some((message) => echoedIds.has(message.messageId))) { appAtomRegistry.set( acknowledgedThreadMessagesAtom, appAtomRegistry .get(acknowledgedThreadMessagesAtom) .filter((message) => !echoedIds.has(message.messageId)), ); } }, [acknowledgedMessages, selectedThreadMessages]); const selectedDraft = selectedThreadKey ? composerDrafts[selectedThreadKey] : null; const draftMessage = selectedDraft?.text ?? ""; const draftAttachments = selectedDraft?.attachments ?? []; const selectedThreadQueueCount = selectedThreadQueuedMessages.length; const selectedThread = selectedThreadDetail ?? selectedThreadShell; const modelSelection = selectedDraft?.modelSelection ?? selectedThread?.modelSelection ?? null; const runtimeMode = selectedDraft?.runtimeMode ?? selectedThread?.runtimeMode ?? null; const selectedProvider = selectedEnvironmentRuntime?.serverConfig?.providers.find( (provider) => provider.instanceId === modelSelection?.instanceId, ); const interactionMode = selectedThread ? resolveProviderInteractionMode( selectedProvider, selectedDraft?.interactionMode ?? selectedThread.interactionMode, ) : null; const selectedThreadSessionActivity = useMemo(() => { const selectedThread = selectedThreadDetail ?? selectedThreadShell; if (!selectedThread?.session) { return null; } return { orchestrationStatus: selectedThread.session.status, activeTurnId: selectedThread.session.activeTurnId ?? undefined, }; }, [selectedThreadDetail, selectedThreadShell]); const isCompacting = useMemo(() => { const queuedMessage = selectedThreadQueuedMessages.findLast( (message) => message.messageId === dispatchingQueuedMessageId && message.text.trim().toLowerCase() === "/compact" && message.attachments.length === 0, ); const latestCompactMessage = selectedThreadDetail?.messages.findLast( (message) => message.role === "user" && message.text.trim().toLowerCase() === "/compact" && !message.attachments?.length, ); const compactRequestIsActive = latestCompactMessage !== undefined && (latestCompactMessage.createdAt > (selectedThread?.latestTurn?.requestedAt ?? latestCompactMessage.createdAt) || (selectedThread?.latestTurn?.state === "running" && latestCompactMessage.createdAt === selectedThread.latestTurn.requestedAt)); const compactionSettled = selectedThreadDetail?.activities.some((activity) => { if (!["context-compaction", "provider.turn.start.failed"].includes(activity.kind)) return false; const payload = typeof activity.payload === "object" && activity.payload !== null ? (activity.payload as { readonly requestId?: unknown }) : null; return payload?.requestId === latestCompactMessage?.id; }); return ( queuedMessage !== undefined || ((selectedThread?.session?.status === "starting" || selectedThread?.session?.status === "running") && compactRequestIsActive && !compactionSettled) ); }, [ dispatchingQueuedMessageId, selectedThread, selectedThreadDetail, selectedThreadQueuedMessages, ]); const activeWorkStartedAt = useMemo(() => { const selectedThread = selectedThreadDetail ?? selectedThreadShell; if (!selectedThread) { return null; } return deriveActiveWorkStartedAt( selectedThread.latestTurn, selectedThreadSessionActivity, null, ); }, [selectedThreadDetail, selectedThreadSessionActivity, selectedThreadShell]); const onSendMessage = useCallback(async () => { if (!selectedThreadShell) { return null; } // The server has not created this thread yet. Queuing a follow-up against // its id would strand the message: if the creation is rejected the thread // never appears and the drain drops the orphan. The composer disables its // send button too; this guard also covers the editor's submit key. if (selectedThreadCreation !== null) { return null; } const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); const draft = getComposerDraftSnapshot(threadKey); const thread = selectedThreadDetail ?? selectedThreadShell; const text = draft.text.trim(); const attachments = draft.attachments; if ( composerAttachmentUploadBlockReason({ environmentId: selectedThreadShell.environmentId, attachments, connected: selectedEnvironmentRuntime?.connectionState === "connected", serverConfig: selectedEnvironmentRuntime?.serverConfig ?? null, states: appAtomRegistry.get(composerAttachmentUploadsAtom), }) !== null ) return null; if (text.length === 0 && attachments.length === 0) { return null; } // A send-failure restore appends with allowOverflow so it never drops the // user's files, which can leave the draft over the cap. Sending it anyway // would enqueue a message that outbox recovery rejects forever, so block // here until the user removes attachments. if (attachments.length > PROVIDER_SEND_TURN_MAX_ATTACHMENTS) { Alert.alert( "Too many attachments", `Remove attachments until there are at most ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS}.`, ); return null; } const modelSelection = draft.modelSelection ?? thread.modelSelection; const serverConfig = selectedEnvironmentRuntime?.serverConfig; if ( selectedEnvironmentRuntime?.connectionState === "connected" && isModelSelectionUnavailable(serverConfig, modelSelection) ) { Alert.alert( "Antigravity model unavailable", "Set up Antigravity on web or desktop, or choose another model.", ); return null; } const provider = serverConfig?.providers.find( (entry) => entry.instanceId === modelSelection.instanceId, ); const feedbackCommand = attachments.length === 0 && (provider?.driver === "codex" || thread.session?.providerName === "codex") ? parseCodexFeedbackCommand(text) : null; if (feedbackCommand) { if (thread.session === null) { Alert.alert("Start a Codex thread first", "Send a message before you submit feedback."); return null; } const metadata = makeQueuedMessageMetadata(); await submitCodexFeedback({ submission: { id: MessageId.make(metadata.messageId), command: text, createdAt: metadata.createdAt, }, clearDraft: () => clearComposerDraftContent(threadKey), onUpdate: (submission) => { setFeedbackSubmissionsByThreadKey((current) => { const existing = current[threadKey] ?? []; const found = existing.some((entry) => entry.id === submission.id); return { ...current, [threadKey]: found ? existing.map((entry) => (entry.id === submission.id ? submission : entry)) : [...existing, submission], }; }); }, upload: () => uploadThreadFeedback({ environmentId: selectedThreadShell.environmentId, input: { threadId: selectedThreadShell.id, ...feedbackCommand, }, }), }); return null; } const metadata = makeQueuedMessageMetadata(); const messageId = MessageId.make(metadata.messageId); // Enqueue publishes the queued atom synchronously (the durable write // happens behind it), so clearing the draft here gives send feedback on // the tap frame instead of after file I/O. If the write fails the message // is rolled out of the queue and the content is merged back into the // draft, preserving anything typed since. const enqueuePromise = enqueueThreadOutboxMessage({ environmentId: selectedThreadShell.environmentId, threadId: selectedThreadShell.id, messageId, commandId: CommandId.make(metadata.commandId), text, attachments, modelSelection, runtimeMode: draft.runtimeMode ?? thread.runtimeMode, interactionMode: resolveProviderInteractionMode( provider, draft.interactionMode ?? thread.interactionMode, ), createdAt: metadata.createdAt, }); clearComposerDraftContent(threadKey, { deferAttachmentCleanup: true }); enqueuePromise.then( () => { // The queued message owns the files now; the sweep sees that and // spares them. Deferred to here so a failed write cannot roll the // message out of the queue mid-sweep and lose the bytes. scheduleUnusedComposerAttachmentCleanup(attachments); }, (error: unknown) => { // Restore text via merge (idempotent) but attachments via the uncapped // append: the merge path slots existing attachments first and truncates // at the send limit, which would silently drop this message's images if // the user attached new ones while the write was in flight. void mergeComposerDraftContent(threadKey, { text, attachments: [] }); appendComposerDraftAttachments(threadKey, attachments, { allowOverflow: true }); setPendingConnectionError( error instanceof Error ? error.message : "Failed to save the queued message.", ); }, ); return messageId; }, [ selectedEnvironmentRuntime?.connectionState, selectedEnvironmentRuntime?.serverConfig, selectedThreadCreation, selectedThreadDetail, selectedThreadShell, uploadThreadFeedback, ]); const onChangeDraftMessage = useCallback( (value: string) => { if (!selectedThreadShell) { return; } const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); setComposerDraftText(threadKey, value); }, [selectedThreadShell], ); const onPickDraftMedia = useCallback(async () => { if (!selectedThreadShell) { return; } const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); const capabilities = selectedEnvironmentRuntime?.serverConfig?.environment.capabilities; const result = await pickComposerMedia({ existingCount: composerDrafts[threadKey]?.attachments.length ?? 0, maxVideoBytes: capabilities?.attachmentUploads === true ? capabilities.fileAttachments?.maxUploadBytes : undefined, }); const rejectedCount = appendComposerDraftAttachments(threadKey, result.attachments); const problems = [ ...(result.error ? [result.error] : []), ...(rejectedCount > 0 ? [`You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} attachments per message.`] : []), ]; if (problems.length > 0) { Alert.alert("Could not attach photo or video", problems.join("\n\n")); } }, [composerDrafts, selectedEnvironmentRuntime?.serverConfig, selectedThreadShell]); const onPickDraftFiles = useCallback(async () => { if (!selectedThreadShell) { return; } const maxBytes = selectedEnvironmentRuntime?.serverConfig?.environment.capabilities.fileAttachments ?.maxUploadBytes; if (maxBytes === undefined) { Alert.alert("Could not attach file", "This server does not support file attachments."); return; } const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); // pickComposerFiles clamps the advertised limit to the contract maximum. const result = await pickComposerFiles({ existingCount: composerDrafts[threadKey]?.attachments.length ?? 0, maxBytes, }); const rejectedCount = appendComposerDraftAttachments(threadKey, result.files); // The picker error and the live-cap rejection can both happen in one // pick; report both in a single alert. const problems = [ ...(result.error ? [result.error] : []), ...(rejectedCount > 0 ? [`You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} files per message.`]Lines 1–500 of 640. Load more
import { useAtomValue } from "@effect/atom-react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { Alert } from "react-native"; import { CommandId, DEFAULT_PROVIDER_INTERACTION_MODE, MessageId, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, type EnvironmentId, type ModelSelection, type ProviderInteractionMode, type RuntimeMode, type ThreadId, } from "@t3tools/contracts"; import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; import { parseCodexFeedbackCommand, submitCodexFeedback, type CodexFeedbackSubmission, } from "@t3tools/client-runtime/state/threads"; import { deriveActiveWorkStartedAt } from "@t3tools/shared/orchestrationTiming"; import { makeQueuedMessageMetadata } from "../lib/commandMetadata"; import { isModelSelectionUnavailable } from "../lib/modelOptions"; import { resolveProviderInteractionMode } from "../features/threads/legacy-plan-mode"; import { convertPastedImagesToAttachments, pasteComposerClipboard, pickComposerFiles, pickComposerMedia, } from "../lib/composerImages"; import type { DraftComposerImageAttachment } from "../lib/composerImages"; import { scopedThreadKey } from "../lib/scopedEntities"; import { buildThreadFeed } from "../lib/threadActivity"; import { acknowledgedThreadMessagesAtom } from "./acknowledged-thread-messages"; import { appendPendingThreadMessages } from "../features/threads/pending-thread-feed"; import { appAtomRegistry } from "../state/atom-registry"; import { pendingThreadCreationMessage } from "./pending-thread-creation"; import { appendComposerDraftAttachments, appendComposerDraftText, clearComposerDraftContent, composerDraftsAtom, ensureComposerDraftsLoaded, getComposerDraftSnapshot, mergeComposerDraftContent, removeComposerDraftAttachment, scheduleUnusedComposerAttachmentCleanup, setComposerDraftText, updateComposerDraftSettings, useComposerDraft, } from "./use-composer-drafts"; import { setPendingConnectionError } from "../state/use-remote-environment-registry"; import { useSelectedThreadDetail } from "../state/use-thread-detail"; import { useThreadSelection } from "../state/use-thread-selection"; import { enqueueThreadOutboxMessage } from "./thread-outbox"; import { dispatchingQueuedMessageIdAtom, useThreadOutboxMessages } from "./use-thread-outbox"; import { threadEnvironment } from "./threads"; import { useAtomCommand } from "./use-atom-command"; import { composerAttachmentUploadBlockReason, composerAttachmentUploadsAtom, } from "./composer-attachment-uploads"; export function appendReviewCommentToDraft(input: { readonly environmentId: EnvironmentId; readonly threadId: ThreadId; readonly text: string; readonly attachments?: ReadonlyArray<DraftComposerImageAttachment>; }): void { const threadKey = scopedThreadKey(input.environmentId, input.threadId); const existing = appAtomRegistry.get(composerDraftsAtom)[threadKey]?.text ?? ""; const separator = existing.trim().length > 0 && !existing.endsWith("\n") ? "\n\n" : ""; setComposerDraftText(threadKey, `${existing}${separator}${input.text}`); if (input.attachments && input.attachments.length > 0) { // Capped: a review comment is new content, not a send-failure restore, so // it must not push the draft over the send limit. Overflow is released. const rejectedCount = appendComposerDraftAttachments(threadKey, input.attachments); if (rejectedCount > 0) { setPendingConnectionError( `${rejectedCount} comment attachment${rejectedCount === 1 ? " was" : "s were"} not added. Messages can contain at most ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} attachments.`, ); } } } export function useThreadDraftForThread(input: { readonly environmentId?: EnvironmentId; readonly threadId?: ThreadId; }) { const threadKey = input.environmentId && input.threadId ? scopedThreadKey(input.environmentId, input.threadId) : null; const draft = useComposerDraft(threadKey); return { draftMessage: draft.text, draftAttachments: draft.attachments, }; } export function useThreadComposerState() { const { selectedThread: selectedThreadShell, selectedThreadCreation, selectedEnvironmentRuntime, } = useThreadSelection(); const selectedThreadDetail = useSelectedThreadDetail(); const composerDrafts = useAtomValue(composerDraftsAtom); const acknowledgedMessages = useAtomValue(acknowledgedThreadMessagesAtom); const queuedMessagesByThreadKey = useThreadOutboxMessages(); const dispatchingQueuedMessageId = useAtomValue(dispatchingQueuedMessageIdAtom); const [feedbackSubmissionsByThreadKey, setFeedbackSubmissionsByThreadKey] = useState< Record<string, ReadonlyArray<CodexFeedbackSubmission>> >({}); const uploadThreadFeedback = useAtomCommand(threadEnvironment.uploadFeedback, { reportFailure: false, }); useEffect(() => { ensureComposerDraftsLoaded(); }, []); const selectedThreadKey = selectedThreadShell ? scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id) : null; // The creation entry is the thread itself (rendered as the first message), // not a follow-up waiting behind it. const selectedThreadQueuedMessages = useMemo( () => selectedThreadKey ? (queuedMessagesByThreadKey[selectedThreadKey] ?? []).filter( (message) => message.creation === undefined, ) : [], [queuedMessagesByThreadKey, selectedThreadKey], ); const feedbackSubmissions = useMemo( () => (selectedThreadKey ? (feedbackSubmissionsByThreadKey[selectedThreadKey] ?? []) : []), [feedbackSubmissionsByThreadKey, selectedThreadKey], ); const dismissFeedback = useCallback( (id: MessageId) => { if (!selectedThreadKey) return; setFeedbackSubmissionsByThreadKey((current) => ({ ...current, [selectedThreadKey]: (current[selectedThreadKey] ?? []).filter((entry) => entry.id !== id), })); }, [selectedThreadKey], ); const selectedThreadMessages = selectedThreadDetail?.messages; const selectedThreadActivities = selectedThreadDetail?.activities; // A thread whose creation has not delivered its turn yet: the prompt only // exists in the outbox, so it is appended to whatever the server has. The // detail is usually present but empty during a worktree checkout, so this // cannot be an either/or with the loaded messages. const pendingCreationMessage = selectedThreadCreation?.message ?? null; const selectedThreadFeed = useMemo(() => { const loadedMessages = selectedThreadMessages ?? []; const feed = (selectedThreadMessages && selectedThreadActivities) || pendingCreationMessage !== null ? buildThreadFeed({ messages: pendingCreationMessage !== null && !loadedMessages.some((message) => message.id === pendingCreationMessage.messageId) ? [...loadedMessages, pendingThreadCreationMessage(pendingCreationMessage)] : loadedMessages, activities: selectedThreadActivities ?? [], }) : []; const pendingAcknowledgments = acknowledgedMessages.filter( (message) => scopedThreadKey(message.environmentId, message.threadId) === selectedThreadKey && !selectedThreadQueuedMessages.some((queued) => queued.messageId === message.messageId), ); if (pendingAcknowledgments.length === 0) return feed; return appendPendingThreadMessages(feed, feed, pendingAcknowledgments).map((entry) => entry.pendingMessage ? { ...entry, acknowledged: true } : entry, ); }, [ selectedThreadActivities, selectedThreadMessages, pendingCreationMessage, selectedThreadKey, selectedThreadQueuedMessages, acknowledgedMessages, ]); useEffect(() => { const echoedIds = new Set(selectedThreadMessages?.map((message) => message.id)); if (acknowledgedMessages.some((message) => echoedIds.has(message.messageId))) { appAtomRegistry.set( acknowledgedThreadMessagesAtom, appAtomRegistry .get(acknowledgedThreadMessagesAtom) .filter((message) => !echoedIds.has(message.messageId)), ); } }, [acknowledgedMessages, selectedThreadMessages]); const selectedDraft = selectedThreadKey ? composerDrafts[selectedThreadKey] : null; const draftMessage = selectedDraft?.text ?? ""; const draftAttachments = selectedDraft?.attachments ?? []; const selectedThreadQueueCount = selectedThreadQueuedMessages.length; const selectedThread = selectedThreadDetail ?? selectedThreadShell; const modelSelection = selectedDraft?.modelSelection ?? selectedThread?.modelSelection ?? null; const runtimeMode = selectedDraft?.runtimeMode ?? selectedThread?.runtimeMode ?? null; const selectedProvider = selectedEnvironmentRuntime?.serverConfig?.providers.find( (provider) => provider.instanceId === modelSelection?.instanceId, ); const interactionMode = selectedThread ? resolveProviderInteractionMode( selectedProvider, selectedDraft?.interactionMode ?? selectedThread.interactionMode, ) : null; const selectedThreadSessionActivity = useMemo(() => { const selectedThread = selectedThreadDetail ?? selectedThreadShell; if (!selectedThread?.session) { return null; } return { orchestrationStatus: selectedThread.session.status, activeTurnId: selectedThread.session.activeTurnId ?? undefined, }; }, [selectedThreadDetail, selectedThreadShell]); const isCompacting = useMemo(() => { const queuedMessage = selectedThreadQueuedMessages.findLast( (message) => message.messageId === dispatchingQueuedMessageId && message.text.trim().toLowerCase() === "/compact" && message.attachments.length === 0, ); const latestCompactMessage = selectedThreadDetail?.messages.findLast( (message) => message.role === "user" && message.text.trim().toLowerCase() === "/compact" && !message.attachments?.length, ); const compactRequestIsActive = latestCompactMessage !== undefined && (latestCompactMessage.createdAt > (selectedThread?.latestTurn?.requestedAt ?? latestCompactMessage.createdAt) || (selectedThread?.latestTurn?.state === "running" && latestCompactMessage.createdAt === selectedThread.latestTurn.requestedAt)); const compactionSettled = selectedThreadDetail?.activities.some((activity) => { if (!["context-compaction", "provider.turn.start.failed"].includes(activity.kind)) return false; const payload = typeof activity.payload === "object" && activity.payload !== null ? (activity.payload as { readonly requestId?: unknown }) : null; return payload?.requestId === latestCompactMessage?.id; }); return ( queuedMessage !== undefined || ((selectedThread?.session?.status === "starting" || selectedThread?.session?.status === "running") && compactRequestIsActive && !compactionSettled) ); }, [ dispatchingQueuedMessageId, selectedThread, selectedThreadDetail, selectedThreadQueuedMessages, ]); const activeWorkStartedAt = useMemo(() => { const selectedThread = selectedThreadDetail ?? selectedThreadShell; if (!selectedThread) { return null; } return deriveActiveWorkStartedAt( selectedThread.latestTurn, selectedThreadSessionActivity, null, ); }, [selectedThreadDetail, selectedThreadSessionActivity, selectedThreadShell]); const onSendMessage = useCallback(async () => { if (!selectedThreadShell) { return null; } // The server has not created this thread yet. Queuing a follow-up against // its id would strand the message: if the creation is rejected the thread // never appears and the drain drops the orphan. The composer disables its // send button too; this guard also covers the editor's submit key. if (selectedThreadCreation !== null) { return null; } const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); const draft = getComposerDraftSnapshot(threadKey); const thread = selectedThreadDetail ?? selectedThreadShell; const text = draft.text.trim(); const attachments = draft.attachments; if ( composerAttachmentUploadBlockReason({ environmentId: selectedThreadShell.environmentId, attachments, connected: selectedEnvironmentRuntime?.connectionState === "connected", serverConfig: selectedEnvironmentRuntime?.serverConfig ?? null, states: appAtomRegistry.get(composerAttachmentUploadsAtom), }) !== null ) return null; if (text.length === 0 && attachments.length === 0) { return null; } // A send-failure restore appends with allowOverflow so it never drops the // user's files, which can leave the draft over the cap. Sending it anyway // would enqueue a message that outbox recovery rejects forever, so block // here until the user removes attachments. if (attachments.length > PROVIDER_SEND_TURN_MAX_ATTACHMENTS) { Alert.alert( "Too many attachments", `Remove attachments until there are at most ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS}.`, ); return null; } const modelSelection = draft.modelSelection ?? thread.modelSelection; const serverConfig = selectedEnvironmentRuntime?.serverConfig; if ( selectedEnvironmentRuntime?.connectionState === "connected" && isModelSelectionUnavailable(serverConfig, modelSelection) ) { Alert.alert( "Antigravity model unavailable", "Set up Antigravity on web or desktop, or choose another model.", ); return null; } const provider = serverConfig?.providers.find( (entry) => entry.instanceId === modelSelection.instanceId, ); const feedbackCommand = attachments.length === 0 && (provider?.driver === "codex" || thread.session?.providerName === "codex") ? parseCodexFeedbackCommand(text) : null; if (feedbackCommand) { if (thread.session === null) { Alert.alert("Start a Codex thread first", "Send a message before you submit feedback."); return null; } const metadata = makeQueuedMessageMetadata(); await submitCodexFeedback({ submission: { id: MessageId.make(metadata.messageId), command: text, createdAt: metadata.createdAt, }, clearDraft: () => clearComposerDraftContent(threadKey), onUpdate: (submission) => { setFeedbackSubmissionsByThreadKey((current) => { const existing = current[threadKey] ?? []; const found = existing.some((entry) => entry.id === submission.id); return { ...current, [threadKey]: found ? existing.map((entry) => (entry.id === submission.id ? submission : entry)) : [...existing, submission], }; }); }, upload: () => uploadThreadFeedback({ environmentId: selectedThreadShell.environmentId, input: { threadId: selectedThreadShell.id, ...feedbackCommand, }, }), }); return null; } const metadata = makeQueuedMessageMetadata(); const messageId = MessageId.make(metadata.messageId); // Enqueue publishes the queued atom synchronously (the durable write // happens behind it), so clearing the draft here gives send feedback on // the tap frame instead of after file I/O. If the write fails the message // is rolled out of the queue and the content is merged back into the // draft, preserving anything typed since. const enqueuePromise = enqueueThreadOutboxMessage({ environmentId: selectedThreadShell.environmentId, threadId: selectedThreadShell.id, messageId, commandId: CommandId.make(metadata.commandId), text, attachments, modelSelection, runtimeMode: draft.runtimeMode ?? thread.runtimeMode, interactionMode: resolveProviderInteractionMode( provider, draft.interactionMode ?? thread.interactionMode, ), createdAt: metadata.createdAt, }); clearComposerDraftContent(threadKey, { deferAttachmentCleanup: true }); enqueuePromise.then( () => { // The queued message owns the files now; the sweep sees that and // spares them. Deferred to here so a failed write cannot roll the // message out of the queue mid-sweep and lose the bytes. scheduleUnusedComposerAttachmentCleanup(attachments); }, (error: unknown) => { // Restore text via merge (idempotent) but attachments via the uncapped // append: the merge path slots existing attachments first and truncates // at the send limit, which would silently drop this message's images if // the user attached new ones while the write was in flight. void mergeComposerDraftContent(threadKey, { text, attachments: [] }); appendComposerDraftAttachments(threadKey, attachments, { allowOverflow: true }); setPendingConnectionError( error instanceof Error ? error.message : "Failed to save the queued message.", ); }, ); return messageId; }, [ selectedEnvironmentRuntime?.connectionState, selectedEnvironmentRuntime?.serverConfig, selectedThreadCreation, selectedThreadDetail, selectedThreadShell, uploadThreadFeedback, ]); const onChangeDraftMessage = useCallback( (value: string) => { if (!selectedThreadShell) { return; } const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); setComposerDraftText(threadKey, value); }, [selectedThreadShell], ); const onPickDraftMedia = useCallback(async () => { if (!selectedThreadShell) { return; } const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); const capabilities = selectedEnvironmentRuntime?.serverConfig?.environment.capabilities; const result = await pickComposerMedia({ existingCount: composerDrafts[threadKey]?.attachments.length ?? 0, maxVideoBytes: capabilities?.attachmentUploads === true ? capabilities.fileAttachments?.maxUploadBytes : undefined, }); const rejectedCount = appendComposerDraftAttachments(threadKey, result.attachments); const problems = [ ...(result.error ? [result.error] : []), ...(rejectedCount > 0 ? [`You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} attachments per message.`] : []), ]; if (problems.length > 0) { Alert.alert("Could not attach photo or video", problems.join("\n\n")); } }, [composerDrafts, selectedEnvironmentRuntime?.serverConfig, selectedThreadShell]); const onPickDraftFiles = useCallback(async () => { if (!selectedThreadShell) { return; } const maxBytes = selectedEnvironmentRuntime?.serverConfig?.environment.capabilities.fileAttachments ?.maxUploadBytes; if (maxBytes === undefined) { Alert.alert("Could not attach file", "This server does not support file attachments."); return; } const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); // pickComposerFiles clamps the advertised limit to the contract maximum. const result = await pickComposerFiles({ existingCount: composerDrafts[threadKey]?.attachments.length ?? 0, maxBytes, }); const rejectedCount = appendComposerDraftAttachments(threadKey, result.files); // The picker error and the live-cap rejection can both happen in one // pick; report both in a single alert. const problems = [ ...(result.error ? [result.error] : []), ...(rejectedCount > 0 ? [`You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} files per message.`]