640 lines · 24.0 KB · TypeScript
RawDownload
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.