Skip to content
Source
Sign inSign up
Open History to see the last commit

queued-run-edit.ts

188 lines6.8 KB

1import type {
2 ChatAttachment,
3 MessageId,
4 OrchestrationMessageContext,
5 RunId,
6 UploadChatAttachment,
7} from "@t3tools/contracts";
8import { useAtomValue } from "@effect/atom-react";
9import { stripAttachmentOnlyBootstrapPrompt } from "@t3tools/shared/composerContextReferences";
10import {
11 makeQueuedRunEditStore,
12 resolveQueuedEditExit,
13 type QueuedRunsAtom,
14} from "@t3tools/client-runtime/queued-run-edit";
15​
16import { uploadedComposerContext } from "../lib/composerContext";
17import { appAtomRegistry } from "./atom-registry";
18import { abortComposerDraftOperations } from "./composerDraftOperations";
19import { queuedEditDraftKey } from "./queued-edit-draft-key";
20import { setThreadComposerError } from "./thread-composer-error";
21import {
22 clearComposerDraft,
23 getComposerDraftSnapshot,
24 mergeComposerDraftContent,
25 setComposerDraftContext,
26 setComposerDraftText,
27} from "./use-composer-drafts";
28​
29export interface QueuedRunEdit {
30 readonly runId: RunId;
31 readonly messageId: MessageId;
32 readonly originalText: string;
33 readonly existingAttachments: ReadonlyArray<ChatAttachment>;
34 readonly context?: OrchestrationMessageContext;
35 /** Removing a server attachment changes the message even when the draft looks untouched. */
36 readonly removedExistingAttachment?: true;
38​
39const queuedRunEdits = makeQueuedRunEditStore<QueuedRunEdit>({
40 registry: appAtomRegistry,
41 label: "mobile-queued-run-edits",
42});
43​
44export { queuedEditDraftKey } from "./queued-edit-draft-key";
45​
46export const getQueuedRunEdit = queuedRunEdits.get;
47​
48/**
49 * The draft a sheet opened for `requestedKey` should edit now: a queued edit's
50 * draft only while that edit is still open, else the thread's own draft.
51 */
52export function activeComposerDraftKey(
53 threadKey: string,
54 requestedKey: string,
55 edit: QueuedRunEdit | null,
56): string {
57 if (requestedKey === threadKey) return threadKey;
58 return edit !== null && queuedEditDraftKey(threadKey, edit.runId) === requestedKey
59 ? requestedKey
60 : threadKey;
62​
63export function useQueuedRunEdit(threadKey: string | null): QueuedRunEdit | null {
64 const edits = useAtomValue(queuedRunEdits.atom);
65 return threadKey === null ? null : (edits[threadKey] ?? null);
67​
68/**
69 * The run started or was cancelled elsewhere. A changed edit goes back into
70 * the thread's composer when that is empty; otherwise it is dropped. Either
71 * way the thread says so, wherever the user is when it happens.
72 */
73function exitQueuedRunEdit(threadKey: string, edit: QueuedRunEdit): void {
74 const editDraftKey = queuedEditDraftKey(threadKey, edit.runId);
75 const editDraft = getComposerDraftSnapshot(editDraftKey);
76 const threadDraft = getComposerDraftSnapshot(threadKey);
77 const editRestorable = editDraft.text.trim().length > 0 || editDraft.attachments.length > 0;
78 const recovery = resolveQueuedEditExit({
79 editChanged:
80 editDraft.text !== edit.originalText ||
81 editDraft.attachments.length > 0 ||
82 edit.removedExistingAttachment === true,
83 // A cleared edit has nothing to put back, so it can only be discarded.
84 threadDraftEmpty:
85 editRestorable &&
86 threadDraft.text.trim().length === 0 &&
87 threadDraft.attachments.length === 0,
88 });
89 if (recovery === "kept") {
90 // The merge publishes the draft before writing it, so a failed write only delays persistence to the next one.
91 mergeComposerDraftContent(threadKey, {
92 text: editDraft.text,
93 attachments: editDraft.attachments,
94 ...(editDraft.context ? { context: editDraft.context } : {}),
95 }).catch((error: unknown) => {
96 console.warn("[queued-run-edit] could not persist the recovered edit", error);
97 });
98 }
99 clearComposerDraft(editDraftKey, { deferAttachmentCleanup: recovery === "kept" });
100 if (recovery === "clean") return;
101 setThreadComposerError(
102 threadKey,
103 recovery === "kept"
104 ? "That message already started. Your edit is back in the composer."
105 : "That message already started, so the edit was discarded.",
106 );
108​
109/** `runs` is the thread's runs; the edit ends when its run leaves the queue. */
110export function beginQueuedRunEdit(
111 threadKey: string,
112 edit: QueuedRunEdit,
113 runs: QueuedRunsAtom,
114): void {
115 const previous = getQueuedRunEdit(threadKey);
116 if (previous !== null && previous.runId !== edit.runId) {
117 clearComposerDraft(queuedEditDraftKey(threadKey, previous.runId));
118 }
119 // The composer moves off the thread draft, so a paste or pick still running for it is dropped.
120 abortComposerDraftOperations(threadKey);
121 const draftKey = queuedEditDraftKey(threadKey, edit.runId);
122 const originalText = stripAttachmentOnlyBootstrapPrompt(edit.originalText);
123 clearComposerDraft(draftKey);
124 setComposerDraftText(draftKey, originalText);
125 setComposerDraftContext(draftKey, edit.context);
126 queuedRunEdits.begin(
127 threadKey,
128 { ...edit, originalText },
129 { runs, onRunLeftQueue: exitQueuedRunEdit },
130 );
132​
133export function removeQueuedRunEditAttachment(threadKey: string, attachmentId: string): void {
134 queuedRunEdits.update(threadKey, (edit) => ({
135 ...edit,
136 removedExistingAttachment: true,
137 existingAttachments: edit.existingAttachments.filter(
138 (attachment) => attachment.id !== attachmentId,
139 ),
140 }));
142​
143export function endQueuedRunEdit(
144 threadKey: string,
145 options?: { readonly deferAttachmentCleanup?: boolean },
146): void {
147 const edit = getQueuedRunEdit(threadKey);
148 if (edit === null) return;
149 clearComposerDraft(queuedEditDraftKey(threadKey, edit.runId), options);
150 queuedRunEdits.end(threadKey);
152​
153/**
154 * Claims the open edit for a save; while it runs, the save's result decides the
155 * edit's fate. Null when there is no edit or a save is already in flight.
156 */
157export const startQueuedRunEditSave = queuedRunEdits.startSave;
158​
159/** Attachments replace wholesale; resend kept server attachments with uploads in composer order. */
160export function resolveQueuedEditPayload(input: {
161 readonly edit: QueuedRunEdit;
162 readonly draftContext: OrchestrationMessageContext | undefined;
163 readonly draftAttachments: ReadonlyArray<{ readonly id: string }>;
164 readonly uploaded: ReadonlyArray<ChatAttachment | UploadChatAttachment>;
165}): {
166 readonly attachments: ReadonlyArray<ChatAttachment | UploadChatAttachment>;
167 readonly context: OrchestrationMessageContext | undefined;
168} {
169 const attachments = [...input.edit.existingAttachments, ...input.uploaded];
170 const rebound = uploadedComposerContext(
171 input.draftContext,
172 input.draftAttachments,
173 input.uploaded,
174 );
175 if (rebound === undefined) return { attachments, context: undefined };
176 const liveIds = new Set(
177 attachments.flatMap((attachment) =>
178 "id" in attachment && attachment.id ? [attachment.id] : [],
179 ),
180 );
181 const records = rebound.records.filter(
182 (record) => !("attachmentId" in record) || liveIds.has(record.attachmentId),
183 );
184 return {
185 attachments,
186 context: records.length > 0 ? { version: 1, records } : undefined,
187 };