1/**
2 * Editing a message that is already queued on the server.
3 *
4 * The composer is reused rather than duplicated, so the edit needs somewhere
5 * to put its text that is not the user's own draft for the thread. It gets a
6 * derived draft key: the thread's key plus a marker and the run id. That key
7 * still ends in the thread id segment, so everything keyed off the draft key
8 * (attachment uploads resolving an environment, per-draft persistence) keeps
9 * working, while the user's draft is never touched and is waiting unchanged
10 * when the edit is cancelled.
11 *
12 * The record below is in-memory only: an edit that does not survive a restart
13 * is better than a composer that reopens pointed at a run the server may have
14 * already started.
15 */
16import type {
17 ChatAttachment,
18 MessageId,
19 OrchestrationMessageContext,
20 RunId,
21 UploadChatAttachment,
22} from "@t3tools/contracts";
23import { Atom } from "effect/reactivity";
24import { useAtomValue } from "@effect/atom-react";
25
26import { uploadedComposerContext } from "../lib/composerContext";
27import { appAtomRegistry } from "./atom-registry";
28import { queuedEditDraftKey } from "./queued-edit-draft-key";
29import {
30 clearComposerDraft,
31 setComposerDraftContext,
32 setComposerDraftText,
33} from "./use-composer-drafts";
34
35export interface QueuedRunEdit {
36 readonly runId: RunId;
37 readonly messageId: MessageId;
38 readonly originalText: string;
39 /** Server attachments still attached; removing one drops it from this list. */
40 readonly existingAttachments: ReadonlyArray<ChatAttachment>;
41 readonly context?: OrchestrationMessageContext;
42}
43
44export const queuedRunEditsAtom = Atom.make<Readonly<Record<string, QueuedRunEdit>>>({}).pipe(
45 Atom.keepAlive,
46 Atom.withLabel("mobile-queued-run-edits"),
47);
48
49export { isQueuedEditDraftKey, queuedEditDraftKey } from "./queued-edit-draft-key";
50
51export function getQueuedRunEdit(threadKey: string | null): QueuedRunEdit | null {
52 if (threadKey === null) return null;
53 return appAtomRegistry.get(queuedRunEditsAtom)[threadKey] ?? null;
54}
55
56export function useQueuedRunEdit(threadKey: string | null): QueuedRunEdit | null {
57 const edits = useAtomValue(queuedRunEditsAtom);
58 return threadKey === null ? null : (edits[threadKey] ?? null);
59}
60
61function setQueuedRunEdit(threadKey: string, edit: QueuedRunEdit | null): void {
62 const current = appAtomRegistry.get(queuedRunEditsAtom);
63 if (edit === null) {
64 if (!current[threadKey]) return;
65 const next = { ...current };
66 delete next[threadKey];
67 appAtomRegistry.set(queuedRunEditsAtom, next);
68 return;
69 }
70 appAtomRegistry.set(queuedRunEditsAtom, { ...current, [threadKey]: edit });
71}
72
73/** Loads the queued message into its own draft and puts the composer in edit mode. */
74export function beginQueuedRunEdit(threadKey: string, edit: QueuedRunEdit): void {
75 const previous = getQueuedRunEdit(threadKey);
76 if (previous !== null && previous.runId !== edit.runId) {
77 clearComposerDraft(queuedEditDraftKey(threadKey, previous.runId));
78 }
79 const draftKey = queuedEditDraftKey(threadKey, edit.runId);
80 clearComposerDraft(draftKey);
81 setComposerDraftText(draftKey, edit.originalText);
82 setComposerDraftContext(draftKey, edit.context);
83 setQueuedRunEdit(threadKey, edit);
84}
85
86export function removeQueuedRunEditAttachment(threadKey: string, attachmentId: string): void {
87 const edit = getQueuedRunEdit(threadKey);
88 if (edit === null) return;
89 setQueuedRunEdit(threadKey, {
90 ...edit,
91 existingAttachments: edit.existingAttachments.filter(
92 (attachment) => attachment.id !== attachmentId,
93 ),
94 });
95}
96
97export function endQueuedRunEdit(
98 threadKey: string,
99 options?: { readonly deferAttachmentCleanup?: boolean },
100): void {
101 const edit = getQueuedRunEdit(threadKey);
102 if (edit === null) return;
103 clearComposerDraft(queuedEditDraftKey(threadKey, edit.runId), options);
104 setQueuedRunEdit(threadKey, null);
105}
106
107/**
108 * The replacement payload for `queued-run.edit`. Attachments replace the
109 * message's list wholesale, so kept server attachments are resent alongside
110 * the newly uploaded ones, in composer order. Context records are rebound to
111 * the uploaded ids and then pruned to what the final list still contains, so
112 * removing an attachment cannot leave a record pointing at nothing.
113 */
114export function resolveQueuedEditPayload(input: {
115 readonly edit: QueuedRunEdit;
116 readonly draftContext: OrchestrationMessageContext | undefined;
117 readonly draftAttachments: ReadonlyArray<{ readonly id: string }>;
118 readonly uploaded: ReadonlyArray<ChatAttachment | UploadChatAttachment>;
119}): {
120 readonly attachments: ReadonlyArray<ChatAttachment | UploadChatAttachment>;
121 readonly context: OrchestrationMessageContext | undefined;
122} {
123 const attachments = [...input.edit.existingAttachments, ...input.uploaded];
124 const rebound = uploadedComposerContext(
125 input.draftContext,
126 input.draftAttachments,
127 input.uploaded,
128 );
129 if (rebound === undefined) return { attachments, context: undefined };
130 const liveIds = new Set(
131 attachments.flatMap((attachment) =>
132 "id" in attachment && attachment.id ? [attachment.id] : [],
133 ),
134 );
135 const records = rebound.records.filter(
136 (record) => !("attachmentId" in record) || liveIds.has(record.attachmentId),
137 );
138 return {
139 attachments,
140 context: records.length > 0 ? { version: 1, records } : undefined,
141 };
142}