Skip to content
Source
Sign inSign up

composerContextClipboard.ts

258 lines8.8 KB

1import { requireNativeModule } from "expo";
2import {
3 type ComposerContextClipboardFragment,
4 type ComposerContextRecord,
5 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
6 PROVIDER_SEND_TURN_MAX_FILE_BYTES,
7 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
8 COMPOSER_CONTEXT_MAX_RECORDS,
9 type EnvironmentId,
10} from "@t3tools/contracts";
11import {
12 decodeComposerContextClipboardHtml,
13 decodeComposerContextFragment,
14 encodeComposerContextFragment,
15} from "@t3tools/shared/composerContextClipboard";
16import {
17 threadContextsOutsideEnvironment,
18 threadLinkContextReferences,
19} from "@t3tools/shared/composerContextReferences";
20import { executeAtomQuery, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
21import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets";
22import * as Option from "effect/Option";
23import { appAtomRegistry } from "../state/atom-registry";
24import { assetEnvironment } from "../state/assets";
25import { environmentSession } from "../state/session";
26import { environmentThreadShells } from "../state/threads";
27import { workspaceConnections } from "../state/workspace";
28import { downloadAttachmentForPreview } from "./attachmentDownload";
29import {
30 persistComposerAttachmentFile,
31 removePersistedComposerAttachmentFile,
32 type DraftComposerAttachment,
33} from "./composerImages";
34import { referencedComposerContext, reidentifyComposerContext } from "./composerContext";
35import { uuidv4 } from "./uuid";
36import {
37 findLocalComposerClipboardAttachment,
38 waitForComposerDraftsLoaded,
39} from "../state/use-composer-drafts";
40import { loadLocalAttachmentPreview } from "./localAttachmentPreview";
41​
42export interface NativeContextClipboard {
43 readonly text: string;
44 readonly fragment: string;
45 readonly html: string;
47​
48function checkAborted(signal: AbortSignal): void {
49 if (signal.aborted) throw new Error("Context import cancelled");
51​
52const nativeClipboard = () =>
53 requireNativeModule<{
54 writeContextClipboard: (text: string, fragment: string) => Promise<void>;
55 }>("T3ComposerEditor");
56​
57export function writeComposerContextClipboard(
58 text: string,
59 fragment: ComposerContextClipboardFragment,
60): Promise<void> {
61 const encoded = encodeComposerContextFragment(fragment);
62 if (!encoded)
63 return Promise.reject(
64 new Error("This context selection is too large to copy. Select fewer items."),
65 );
66 return nativeClipboard().writeContextClipboard(text, encoded);
68​
69export async function importComposerContextClipboard(
70 input: NativeContextClipboard,
71 existingCount: number,
72 signal: AbortSignal,
73 existingContextCount = 0,
74 destinationEnvironmentId?: EnvironmentId,
75 /** The machines the destination links to, whose threads its agent can read too. */
76 linkedEnvironments: ReadonlyMap<EnvironmentId, string> = new Map(),
77) {
78 const copied =
79 decodeComposerContextFragment(input.fragment) ?? decodeComposerContextClipboardHtml(input.html);
80 // A thread link becomes a chip only where this agent can read its machine; others stay links.
81 const threads = threadLinkContextReferences(
82 input.text,
83 copied?.records ?? [],
84 (ref) => appAtomRegistry.get(environmentThreadShells.threadShellAtom(ref))?.title,
85 (record) =>
86 destinationEnvironmentId === undefined ||
87 record.environmentId === destinationEnvironmentId ||
88 linkedEnvironments.has(record.environmentId)
89 ? record.contextId
90 : undefined,
91 );
92 const source =
93 copied?.source ??
94 (threads.records[0] ? { environmentId: threads.records[0].environmentId } : null);
95 if (!source) return null;
96 const fragment = {
97 source,
98 records: [
99 ...(copied?.records ?? []).filter((record) =>
100 threads.records.every((thread) => thread.contextId !== record.contextId),
101 ),
102 ...threads.records,
103 ],
104 };
105 const selected = referencedComposerContext(threads.text, {
106 version: 1,
107 records: fragment.records,
108 });
109 const imported = reidentifyComposerContext(threads.text, selected?.records ?? [], uuidv4);
110 const skippedThreads =
111 destinationEnvironmentId === undefined
112 ? []
113 : threadContextsOutsideEnvironment({
114 text: imported.text,
115 records: imported.context.records,
116 environmentId: destinationEnvironmentId,
117 linkedEnvironments,
118 });
119 const skippedIds = new Set(skippedThreads.map((record) => record.contextId));
120 const retained = imported.context.records.filter((record) => !skippedIds.has(record.contextId));
121 if (existingContextCount + retained.length > COMPOSER_CONTEXT_MAX_RECORDS)
122 throw new Error("Remove some context items from the draft before pasting more.");
123 const attachments: DraftComposerAttachment[] = [];
124 const records: ComposerContextRecord[] = [];
125 const failures: string[] = [];
126 try {
127 for (const record of retained) {
128 checkAborted(signal);
129 if (!("attachmentId" in record)) {
130 records.push(record);
131 continue;
132 }
133 try {
134 if (existingCount + attachments.length >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS)
135 throw new Error("Attachment limit reached");
136 const file = await importAttachment(record, fragment.source.environmentId, signal);
137 attachments.push(file);
138 records.push({ ...record, attachmentId: file.id });
139 } catch (error) {
140 if (signal.aborted) throw error;
141 failures.push(record.name);
142 }
143 }
144 // The last copy can be cancelled after its own check; a cancelled import must not return files nobody will release.
145 checkAborted(signal);
146 return {
147 text: imported.text,
148 context: { version: 1 as const, records },
149 attachments,
150 failures,
151 skippedThreads: skippedThreads.map((record) => record.title),
152 };
153 } catch (error) {
154 await Promise.all(
155 attachments.map((attachment) =>
156 attachment.fileUri
157 ? removePersistedComposerAttachmentFile(attachment.fileUri)
158 : Promise.resolve(),
159 ),
160 );
161 throw error;
162 }
164​
165async function importAttachment(
166 record: Extract<ComposerContextRecord, { attachmentId: string }>,
167 environmentId: EnvironmentId,
168 signal: AbortSignal,
169): Promise<DraftComposerAttachment> {
170 await waitForComposerDraftsLoaded();
171 checkAborted(signal);
172 const local = findLocalComposerClipboardAttachment(
173 appAtomRegistry
174 .get(workspaceConnections.environmentsAtom)
175 .map((environment) => environment.environmentId),
176 environmentId,
177 record.attachmentId,
178 );
179 if (local) {
180 if (!local.fileUri && local.type === "image" && local.dataUrl)
181 return {
182 ...local,
183 id: uuidv4(),
184 uploadedAttachmentId: undefined,
185 uploadEnvironmentId: undefined,
186 };
187 if (local.fileUri) {
188 const preview = await loadLocalAttachmentPreview(
189 { ...local, fileUri: local.fileUri },
190 signal,
191 );
192 if (!preview) throw new Error("Context import cancelled");
193 try {
194 return await persistImportedAttachment(record, preview.uri, signal);
195 } finally {
196 preview.dispose();
197 }
198 }
199 }
200 const connection = appAtomRegistry.get(
201 environmentSession.preparedConnectionValueAtom(environmentId),
202 );
203 if (Option.isNone(connection)) throw new Error("Reconnect to the source environment");
204 const result = await executeAtomQuery(
205 appAtomRegistry,
206 assetEnvironment.createUrl({
207 environmentId,
208 input: {
209 resource: { _tag: "attachment", attachmentId: record.attachmentId, fileName: record.name },
210 },
211 }),
212 { refresh: true, reportFailure: false },
213 );
214 if (result._tag === "Failure") throw squashAtomCommandFailure(result);
215 checkAborted(signal);
216 const url = resolveAssetUrl(connection.value.httpBaseUrl, result.value.relativeUrl);
217 if (!url) throw new Error("Attachment URL unavailable");
218 const temporary = await downloadAttachmentForPreview({
219 attachment: { name: record.name, mimeType: record.mimeType },
220 url,
221 signal,
222 });
223 if (!temporary) throw new Error("Attachment import cancelled");
224 try {
225 return await persistImportedAttachment(record, temporary.uri, signal);
226 } finally {
227 temporary.dispose();
228 }
230​
231async function persistImportedAttachment(
232 record: Extract<ComposerContextRecord, { attachmentId: string }>,
233 uri: string,
234 signal: AbortSignal,
235): Promise<DraftComposerAttachment> {
236 const fileUri = await persistComposerAttachmentFile(
237 uri,
238 record.name,
239 record.kind === "image"
240 ? PROVIDER_SEND_TURN_MAX_IMAGE_BYTES
241 : PROVIDER_SEND_TURN_MAX_FILE_BYTES,
242 );
243 if (signal.aborted) {
244 await removePersistedComposerAttachmentFile(fileUri);
245 checkAborted(signal);
246 }
247 const { File } = await import("expo-file-system");
248 const common = {
249 id: uuidv4(),
250 fileUri,
251 name: record.name,
252 mimeType: record.mimeType,
253 sizeBytes: new File(fileUri).size,
254 };
255 return record.kind === "image"
256 ? { ...common, type: "image", previewUri: fileUri }
257 : { ...common, type: "file" };