317 lines · 10.8 KB · TypeScript
RawDownload
import { useAtomValue } from "@effect/atom-react";import { useCallback, useEffect, useMemo } from "react"; import {  CommandId,  MessageId,  type EnvironmentId,  type ModelSelection,  type ProviderInteractionMode,  type RuntimeMode,  type ThreadId,} from "@t3tools/contracts";import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";import { deriveActiveWorkStartedAt } from "@t3tools/shared/orchestrationTiming"; import { makeQueuedMessageMetadata } from "../lib/commandMetadata";import {  convertPastedImagesToAttachments,  pasteComposerClipboard,  pickComposerImages,} from "../lib/composerImages";import type { DraftComposerImageAttachment } from "../lib/composerImages";import { scopedThreadKey } from "../lib/scopedEntities";import { buildThreadFeed } from "../lib/threadActivity";import { appAtomRegistry } from "../state/atom-registry";import {  appendComposerDraftAttachments,  appendComposerDraftText,  clearComposerDraftContent,  composerDraftsAtom,  ensureComposerDraftsLoaded,  getComposerDraftSnapshot,  mergeComposerDraftContent,  removeComposerDraftAttachment,  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 { useThreadOutboxMessages } from "./use-thread-outbox"; 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) {    appendComposerDraftAttachments(threadKey, input.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 } = useThreadSelection();  const selectedThreadDetail = useSelectedThreadDetail();  const composerDrafts = useAtomValue(composerDraftsAtom);  const queuedMessagesByThreadKey = useThreadOutboxMessages();   useEffect(() => {    ensureComposerDraftsLoaded();  }, []);   const selectedThreadKey = selectedThreadShell    ? scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id)    : null;  const selectedThreadQueuedMessages = useMemo(    () => (selectedThreadKey ? (queuedMessagesByThreadKey[selectedThreadKey] ?? []) : []),    [queuedMessagesByThreadKey, selectedThreadKey],  );  const selectedThreadFeed = useMemo(    () => (selectedThreadDetail ? buildThreadFeed(selectedThreadDetail) : []),    [selectedThreadDetail],  );   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 interactionMode = 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 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;    }     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 (text.length === 0 && attachments.length === 0) {      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: draft.modelSelection ?? thread.modelSelection,      runtimeMode: draft.runtimeMode ?? thread.runtimeMode,      interactionMode: draft.interactionMode ?? thread.interactionMode,      createdAt: metadata.createdAt,    });    clearComposerDraftContent(threadKey);    enqueuePromise.catch((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);      setPendingConnectionError(        error instanceof Error ? error.message : "Failed to save the queued message.",      );    });    return messageId;  }, [selectedThreadDetail, selectedThreadShell]);   const onChangeDraftMessage = useCallback(    (value: string) => {      if (!selectedThreadShell) {        return;      }       const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id);      setComposerDraftText(threadKey, value);    },    [selectedThreadShell],  );   const onPickDraftImages = useCallback(async () => {    if (!selectedThreadShell) {      return;    }     const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id);    const result = await pickComposerImages({      existingCount: composerDrafts[threadKey]?.attachments.length ?? 0,    });    if (result.images.length > 0) {      appendComposerDraftAttachments(threadKey, result.images);    }    if (result.error) {      setPendingConnectionError(result.error);    }  }, [composerDrafts, selectedThreadShell]);   const onPasteIntoDraft = useCallback(async () => {    if (!selectedThreadShell) {      return;    }     const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id);    const result = await pasteComposerClipboard({      existingCount: composerDrafts[threadKey]?.attachments.length ?? 0,    });    if (result.images.length > 0) {      appendComposerDraftAttachments(threadKey, result.images);    }    if (result.text) {      appendComposerDraftText(threadKey, result.text);    }    if (result.error) {      setPendingConnectionError(result.error);    }  }, [composerDrafts, selectedThreadShell]);   const onNativePasteImages = useCallback(    async (uris: ReadonlyArray<string>) => {      if (!selectedThreadShell || uris.length === 0) {        return;      }       const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id);      try {        const images = await convertPastedImagesToAttachments({          uris,          existingCount: composerDrafts[threadKey]?.attachments.length ?? 0,        });        if (images.length > 0) {          appendComposerDraftAttachments(threadKey, images);        }      } catch (error) {        console.error("[native paste] error converting images", {          environmentId: selectedThreadShell.environmentId,          threadId: selectedThreadShell.id,          uriCount: uris.length,          ...safeErrorLogAttributes(error),        });      }    },    [composerDrafts, selectedThreadShell],  );   const onRemoveDraftImage = useCallback(    (imageId: string) => {      if (!selectedThreadShell) {        return;      }       const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id);      removeComposerDraftAttachment(threadKey, imageId);    },    [selectedThreadShell],  );   const onUpdateModelSelection = useCallback(    (value: ModelSelection) => {      if (!selectedThreadKey) {        return;      }      updateComposerDraftSettings(selectedThreadKey, { modelSelection: value });    },    [selectedThreadKey],  );   const onUpdateRuntimeMode = useCallback(    (value: RuntimeMode) => {      if (!selectedThreadKey) {        return;      }      updateComposerDraftSettings(selectedThreadKey, { runtimeMode: value });    },    [selectedThreadKey],  );   const onUpdateInteractionMode = useCallback(    (value: ProviderInteractionMode) => {      if (!selectedThreadKey) {        return;      }      updateComposerDraftSettings(selectedThreadKey, { interactionMode: value });    },    [selectedThreadKey],  );   return {    selectedThreadFeed,    selectedThreadQueueCount,    activeWorkStartedAt,    draftMessage,    draftAttachments,    modelSelection,    runtimeMode,    interactionMode,    onChangeDraftMessage,    onPickDraftImages,    onPasteIntoDraft,    onNativePasteImages,    onRemoveDraftImage,    onSendMessage,    onUpdateModelSelection,    onUpdateRuntimeMode,    onUpdateInteractionMode,  };}