@mevdragon/vidfarm-devcli 0.5.3 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/README.md +3 -3
  2. package/SKILL.director.md +28 -7
  3. package/SKILL.platform.md +9 -5
  4. package/demo/README.md +28 -0
  5. package/demo/dist/app.css +1 -0
  6. package/demo/dist/app.js +1184 -0
  7. package/demo/dist/chunks/chunk-DXB73IDG.js +1 -0
  8. package/demo/dist/chunks/chunk-S7OWAJDS.js +36 -0
  9. package/demo/dist/chunks/chunk-VTIBZ6AN.js +1 -0
  10. package/demo/dist/chunks/dist-ADSJKBVE.js +332 -0
  11. package/demo/dist/chunks/domEditingLayers-VZMLL4AP-SGHWPND4.js +1 -0
  12. package/demo/dist/chunks/hyperframes-player-XB65TCD6.js +425 -0
  13. package/demo/dist/chunks/lib-XAQ37YOE.js +1 -0
  14. package/demo/dist/chunks/src-TJ2QYA4U.js +207 -0
  15. package/demo/dist/favicon.ico +0 -0
  16. package/demo/dist/icons/timeline/audio.svg +7 -0
  17. package/demo/dist/icons/timeline/captions.svg +5 -0
  18. package/demo/dist/icons/timeline/composition.svg +12 -0
  19. package/demo/dist/icons/timeline/image.svg +18 -0
  20. package/demo/dist/icons/timeline/music.svg +10 -0
  21. package/demo/dist/icons/timeline/text.svg +3 -0
  22. package/demo/dist/index.html +15 -0
  23. package/dist/src/account-pages-legacy.js +9396 -0
  24. package/dist/src/account-pages.js +61 -0
  25. package/dist/src/app.js +14663 -0
  26. package/dist/src/cli.js +60 -97
  27. package/dist/src/composition-runtime.js +613 -0
  28. package/dist/src/config.js +173 -0
  29. package/dist/src/context.js +447 -0
  30. package/dist/src/dev-app-legacy.js +739 -0
  31. package/dist/src/dev-app.js +6 -0
  32. package/dist/src/domain.js +2 -0
  33. package/dist/src/editor-chat-history.js +82 -0
  34. package/dist/src/editor-chat.js +449 -0
  35. package/dist/src/editor-dark-theme.js +1128 -0
  36. package/dist/src/frontend/debug.js +71 -0
  37. package/dist/src/frontend/flockposter-cache-store.js +124 -0
  38. package/dist/src/frontend/homepage-client.js +182 -0
  39. package/dist/src/frontend/homepage-shared.js +4 -0
  40. package/dist/src/frontend/homepage-store.js +28 -0
  41. package/dist/src/frontend/homepage-view.js +547 -0
  42. package/dist/src/frontend/page-runtime-client.js +132 -0
  43. package/dist/src/frontend/page-runtime-store.js +9 -0
  44. package/dist/src/frontend/sentry.js +42 -0
  45. package/dist/src/frontend/template-editor-chat.js +3960 -0
  46. package/dist/src/help-page.js +346 -0
  47. package/dist/src/homepage.js +1235 -0
  48. package/dist/src/hyperframes/composition.js +180 -0
  49. package/dist/src/index.js +16 -0
  50. package/dist/src/instrument.js +30 -0
  51. package/dist/src/lib/crypto.js +45 -0
  52. package/dist/src/lib/dev-log.js +54 -0
  53. package/dist/src/lib/display-name.js +11 -0
  54. package/dist/src/lib/ids.js +24 -0
  55. package/dist/src/lib/images.js +19 -0
  56. package/dist/src/lib/json.js +15 -0
  57. package/dist/src/lib/package-root.js +47 -0
  58. package/dist/src/lib/template-paths.js +28 -0
  59. package/dist/src/lib/time.js +7 -0
  60. package/dist/src/lib/url-clean.js +85 -0
  61. package/dist/src/page-runtime.js +2 -0
  62. package/dist/src/page-shell.js +1381 -0
  63. package/dist/src/primitive-context.js +357 -0
  64. package/dist/src/primitive-registry.js +2561 -0
  65. package/dist/src/primitive-sdk.js +4 -0
  66. package/dist/src/primitives/hyperframes-media.js +108 -0
  67. package/dist/src/react-page-shell.js +35 -0
  68. package/dist/src/ready-post-schedule-component.js +1540 -0
  69. package/dist/src/registry.js +296 -0
  70. package/dist/src/runtime.js +35 -0
  71. package/dist/src/services/api-call-history.js +249 -0
  72. package/dist/src/services/auth.js +152 -0
  73. package/dist/src/services/billing-pricing.js +39 -0
  74. package/dist/src/services/billing.js +228 -0
  75. package/dist/src/services/cast.js +127 -0
  76. package/dist/src/services/chat-threads.js +92 -0
  77. package/dist/src/services/composition-sanitize.js +124 -0
  78. package/dist/src/services/composition-watch.js +79 -0
  79. package/dist/src/services/fork-access.js +93 -0
  80. package/dist/src/services/fork-manifest.js +42 -0
  81. package/dist/src/services/ghostcut.js +179 -0
  82. package/dist/src/services/hyperframes.js +2307 -0
  83. package/dist/src/services/job-capacity.js +14 -0
  84. package/dist/src/services/job-logs.js +197 -0
  85. package/dist/src/services/jobs.js +136 -0
  86. package/dist/src/services/local-dynamo.js +0 -0
  87. package/dist/src/services/media-processing.js +766 -0
  88. package/dist/src/services/primitive-media-lambda.js +280 -0
  89. package/dist/src/services/providers.js +2926 -0
  90. package/dist/src/services/rate-limits.js +262 -0
  91. package/dist/src/services/serverless-auth.js +382 -0
  92. package/dist/src/services/serverless-jobs.js +1082 -0
  93. package/dist/src/services/serverless-provider-keys.js +409 -0
  94. package/dist/src/services/serverless-records.js +1385 -0
  95. package/dist/src/services/serverless-template-configs.js +75 -0
  96. package/dist/src/services/storage.js +383 -0
  97. package/dist/src/services/template-certification.js +413 -0
  98. package/dist/src/services/template-loader.js +99 -0
  99. package/dist/src/services/template-runtime-bundles.js +217 -0
  100. package/dist/src/services/template-sources.js +1017 -0
  101. package/dist/src/services/upstream.js +248 -0
  102. package/dist/src/services/video-normalization.js +2 -0
  103. package/dist/src/services/webhooks.js +62 -0
  104. package/dist/src/template-editor-pages.js +2576 -0
  105. package/dist/src/template-editor-shell.js +2840 -0
  106. package/dist/src/template-sdk.js +4 -0
  107. package/dist/src/worker.js +17 -0
  108. package/package.json +6 -4
  109. package/public/assets/homepage-app.js +54 -0
  110. package/public/assets/homepage-client-app.js +80 -0
  111. package/public/assets/page-runtime-client-app.js +94 -0
  112. package/src/assets/SELLING_AWARENESS_STAGES.md +579 -0
  113. package/src/assets/SELLING_WITH_HOOKS.md +377 -0
  114. package/src/assets/SELLING_WITH_VSLS.md +606 -0
  115. package/src/assets/favicon.ico +0 -0
  116. package/src/assets/logo-vidfarm.png +0 -0
@@ -0,0 +1,3960 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { memo, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
3
+ import { createPortal } from "react-dom";
4
+ import { debugError, debugLog, debugWarn } from "./debug.js";
5
+ const MB = 1024 * 1024;
6
+ const MY_FILES_UPLOAD_LIMIT_BYTES = 50 * MB;
7
+ const CHAT_INLINE_IMAGE_TARGET_BYTES = 2 * MB;
8
+ const CHAT_INLINE_IMAGE_HARD_LIMIT_BYTES = 3 * MB;
9
+ const CHAT_INLINE_TOTAL_BUDGET_BYTES = Math.floor(3.5 * MB);
10
+ const CHAT_INLINE_IMAGE_MAX_EDGE = 2048;
11
+ const CHAT_TEMPORARY_UPLOAD_FOLDER = "chat";
12
+ function normalizeUploadedAttachment(raw) {
13
+ const id = raw.id || raw.attachment_id || raw.file_id || "";
14
+ const fileName = raw.fileName || raw.file_name;
15
+ const contentType = raw.contentType || raw.content_type;
16
+ const viewUrl = raw.viewUrl || raw.view_url || "";
17
+ if (!id || !fileName || !contentType || !viewUrl) {
18
+ return null;
19
+ }
20
+ return {
21
+ id,
22
+ fileName,
23
+ contentType,
24
+ viewUrl,
25
+ sizeBytes: typeof raw.sizeBytes === "number" ? raw.sizeBytes : raw.size_bytes,
26
+ folderPath: raw.folderPath ?? raw.folder_path ?? ""
27
+ };
28
+ }
29
+ function isImageAttachment(attachment) {
30
+ return attachment.contentType.startsWith("image/");
31
+ }
32
+ function isVideoAttachment(attachment) {
33
+ return attachment.contentType.startsWith("video/");
34
+ }
35
+ const INLINE_MEDIA_IMAGE_EXTENSIONS = new Set([
36
+ "apng",
37
+ "avif",
38
+ "gif",
39
+ "jpeg",
40
+ "jpg",
41
+ "png",
42
+ "svg",
43
+ "webp"
44
+ ]);
45
+ const INLINE_MEDIA_VIDEO_EXTENSIONS = new Set([
46
+ "m4v",
47
+ "mov",
48
+ "mp4",
49
+ "mpeg",
50
+ "mpg",
51
+ "ogv",
52
+ "webm"
53
+ ]);
54
+ const MAX_CHAT_PAYLOAD_STRING_LENGTH = 1200;
55
+ const MAX_CHAT_PAYLOAD_ARRAY_ITEMS = 24;
56
+ const MAX_CHAT_PAYLOAD_OBJECT_KEYS = 80;
57
+ const DATA_URL_PREFIX_REGEX = /^data:([^;,]+)[^,]*,/i;
58
+ const SECRET_HEADER_REGEX = /authorization|api[-_]?key|secret|token|vidfarm-api-key/i;
59
+ const CHAT_TRANSPORT_COMPACT_CHAR_THRESHOLD = 48_000;
60
+ const CHAT_TRANSPORT_RECENT_MESSAGES = 14;
61
+ const CHAT_TRANSPORT_MAX_SUMMARY_CHARS = 12_000;
62
+ const CHAT_TRANSPORT_MAX_MESSAGE_CHARS = 1_200;
63
+ const LOCAL_THREAD_MESSAGE_CACHE_LIMIT = 30;
64
+ function formatJsonBody(value) {
65
+ if (value === undefined || value === null || value === "") {
66
+ return "";
67
+ }
68
+ if (typeof value === "string") {
69
+ try {
70
+ return JSON.stringify(JSON.parse(value), null, 2);
71
+ }
72
+ catch {
73
+ return value;
74
+ }
75
+ }
76
+ return JSON.stringify(value, null, 2);
77
+ }
78
+ function summarizeLargeString(value) {
79
+ const dataUrlMatch = DATA_URL_PREFIX_REGEX.exec(value);
80
+ if (dataUrlMatch) {
81
+ return `[${dataUrlMatch[1]} data URL omitted, ${value.length.toLocaleString()} chars]`;
82
+ }
83
+ if (value.length > MAX_CHAT_PAYLOAD_STRING_LENGTH) {
84
+ return `${value.slice(0, MAX_CHAT_PAYLOAD_STRING_LENGTH)}… [truncated ${value.length.toLocaleString()} chars]`;
85
+ }
86
+ return value;
87
+ }
88
+ function sanitizeChatPayload(value, depth = 0) {
89
+ if (value === null || value === undefined) {
90
+ return value;
91
+ }
92
+ if (typeof value === "string") {
93
+ return summarizeLargeString(value);
94
+ }
95
+ if (typeof value === "number" || typeof value === "boolean") {
96
+ return value;
97
+ }
98
+ if (typeof value === "bigint") {
99
+ return value.toString();
100
+ }
101
+ if (typeof value === "function" || typeof value === "symbol") {
102
+ return undefined;
103
+ }
104
+ if (depth >= 8) {
105
+ return "[nested payload omitted]";
106
+ }
107
+ if (Array.isArray(value)) {
108
+ const items = value
109
+ .slice(0, MAX_CHAT_PAYLOAD_ARRAY_ITEMS)
110
+ .map((entry) => sanitizeChatPayload(entry, depth + 1));
111
+ if (value.length > MAX_CHAT_PAYLOAD_ARRAY_ITEMS) {
112
+ items.push(`[${value.length - MAX_CHAT_PAYLOAD_ARRAY_ITEMS} more items omitted]`);
113
+ }
114
+ return items;
115
+ }
116
+ if (typeof value === "object") {
117
+ const output = {};
118
+ const entries = Object.entries(value);
119
+ for (const [key, entry] of entries.slice(0, MAX_CHAT_PAYLOAD_OBJECT_KEYS)) {
120
+ const sanitized = sanitizeChatPayload(entry, depth + 1);
121
+ if (sanitized !== undefined) {
122
+ output[key] = sanitized;
123
+ }
124
+ }
125
+ if (entries.length > MAX_CHAT_PAYLOAD_OBJECT_KEYS) {
126
+ output.__omitted_keys = entries.length - MAX_CHAT_PAYLOAD_OBJECT_KEYS;
127
+ }
128
+ return output;
129
+ }
130
+ return String(value);
131
+ }
132
+ function sanitizeToolResult(result) {
133
+ return sanitizeChatPayload(result);
134
+ }
135
+ function sanitizeHttpExchange(exchange) {
136
+ return {
137
+ ...exchange,
138
+ request: {
139
+ ...exchange.request,
140
+ headers: Object.fromEntries(Object.entries(exchange.request.headers).map(([key, value]) => [
141
+ key,
142
+ SECRET_HEADER_REGEX.test(key) ? "[redacted]" : summarizeLargeString(value)
143
+ ])),
144
+ body: sanitizeChatPayload(exchange.request.body)
145
+ },
146
+ result: {
147
+ ...exchange.result,
148
+ headers: Object.fromEntries(Object.entries(exchange.result.headers).map(([key, value]) => [
149
+ key,
150
+ SECRET_HEADER_REGEX.test(key) ? "[redacted]" : summarizeLargeString(value)
151
+ ])),
152
+ body: sanitizeChatPayload(exchange.result.body),
153
+ error: exchange.result.error ? summarizeLargeString(exchange.result.error) : exchange.result.error
154
+ }
155
+ };
156
+ }
157
+ function buildBrowserDateContext() {
158
+ const now = new Date();
159
+ let localDateTime = null;
160
+ let timeZone = null;
161
+ try {
162
+ localDateTime = new Intl.DateTimeFormat(undefined, {
163
+ dateStyle: "full",
164
+ timeStyle: "long"
165
+ }).format(now);
166
+ }
167
+ catch {
168
+ localDateTime = now.toString();
169
+ }
170
+ try {
171
+ timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || null;
172
+ }
173
+ catch {
174
+ timeZone = null;
175
+ }
176
+ return {
177
+ currentDateTime: now.toISOString(),
178
+ localDateTime,
179
+ timeZone
180
+ };
181
+ }
182
+ function escapeHtml(value) {
183
+ return value
184
+ .replaceAll("&", "&")
185
+ .replaceAll("<", "&lt;")
186
+ .replaceAll(">", "&gt;")
187
+ .replaceAll("\"", "&quot;")
188
+ .replaceAll("'", "&#39;");
189
+ }
190
+ function renderInlineMarkdown(value) {
191
+ let html = escapeHtml(value);
192
+ html = html.replace(/`([^`]+)`/g, "<code>$1</code>");
193
+ html = html.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
194
+ html = html.replace(/(^|[^\*])\*([^*]+)\*(?!\*)/g, "$1<em>$2</em>");
195
+ html = html.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
196
+ html = html.replace(/(^|[\s(])(https?:\/\/[^\s<]+[^<.,:;"')\]\s])/g, '$1<a href="$2" target="_blank" rel="noopener noreferrer">$2</a>');
197
+ return html;
198
+ }
199
+ function renderMarkdownToHtml(value) {
200
+ const normalized = value.replace(/\r\n/g, "\n").trim();
201
+ if (!normalized) {
202
+ return "";
203
+ }
204
+ const blocks = normalized.split(/\n{2,}/);
205
+ return blocks.map((block) => {
206
+ const trimmed = block.trim();
207
+ if (!trimmed) {
208
+ return "";
209
+ }
210
+ if (trimmed.startsWith("```") && trimmed.endsWith("```")) {
211
+ const code = trimmed.replace(/^```[^\n]*\n?/, "").replace(/\n?```$/, "");
212
+ return `<pre><code>${escapeHtml(code)}</code></pre>`;
213
+ }
214
+ const lines = trimmed.split("\n");
215
+ const isBulletList = lines.every((line) => /^[-*]\s+/.test(line.trim()));
216
+ if (isBulletList) {
217
+ const items = lines
218
+ .map((line) => line.trim().replace(/^[-*]\s+/, ""))
219
+ .map((line) => `<li>${renderInlineMarkdown(line)}</li>`)
220
+ .join("");
221
+ return `<ul>${items}</ul>`;
222
+ }
223
+ const isOrderedList = lines.every((line) => /^\d+\.\s+/.test(line.trim()));
224
+ if (isOrderedList) {
225
+ const items = lines
226
+ .map((line) => line.trim().replace(/^\d+\.\s+/, ""))
227
+ .map((line) => `<li>${renderInlineMarkdown(line)}</li>`)
228
+ .join("");
229
+ return `<ol>${items}</ol>`;
230
+ }
231
+ return `<p>${lines.map((line) => renderInlineMarkdown(line.trim())).join("<br />")}</p>`;
232
+ }).filter(Boolean).join("");
233
+ }
234
+ function titleCaseLabel(value) {
235
+ return value
236
+ .replace(/[_-]+/g, " ")
237
+ .replace(/\s+/g, " ")
238
+ .trim()
239
+ .replace(/\b\w/g, (char) => char.toUpperCase());
240
+ }
241
+ function isGenericPathLabel(value) {
242
+ return /^item \d+$/i.test(value.trim()) || /^output$/i.test(value.trim());
243
+ }
244
+ function extractStorageKeyFromUrl(url) {
245
+ try {
246
+ const parsed = new URL(url);
247
+ const queryKey = parsed.searchParams.get("key");
248
+ if (queryKey?.trim()) {
249
+ return queryKey.trim().replace(/^\/+/, "");
250
+ }
251
+ return null;
252
+ }
253
+ catch {
254
+ return null;
255
+ }
256
+ }
257
+ function safeDecodeURIComponent(value) {
258
+ try {
259
+ return decodeURIComponent(value);
260
+ }
261
+ catch {
262
+ return value;
263
+ }
264
+ }
265
+ function extractFilenameFromUrl(url) {
266
+ const storageKey = extractStorageKeyFromUrl(url);
267
+ if (storageKey) {
268
+ const fileName = safeDecodeURIComponent(storageKey.split("/").filter(Boolean).pop() || "");
269
+ if (fileName) {
270
+ return fileName;
271
+ }
272
+ }
273
+ try {
274
+ const pathname = new URL(url).pathname;
275
+ return safeDecodeURIComponent(pathname.split("/").filter(Boolean).pop() || "");
276
+ }
277
+ catch {
278
+ return "";
279
+ }
280
+ }
281
+ function labelFromUrl(url) {
282
+ const filename = extractFilenameFromUrl(url);
283
+ return filename || "Output";
284
+ }
285
+ function downloadNameFromUrl(url, fallback) {
286
+ const filename = extractFilenameFromUrl(url);
287
+ if (filename) {
288
+ return filename;
289
+ }
290
+ const normalizedFallback = fallback.trim().replace(/[\\/:*?"<>|]+/g, "-");
291
+ return normalizedFallback || "download";
292
+ }
293
+ function getMediaKindFromUrl(url) {
294
+ const fileName = extractFilenameFromUrl(url);
295
+ const extension = fileName.split(".").pop()?.trim().toLowerCase() || "";
296
+ if (INLINE_MEDIA_IMAGE_EXTENSIONS.has(extension)) {
297
+ return "image";
298
+ }
299
+ if (INLINE_MEDIA_VIDEO_EXTENSIONS.has(extension)) {
300
+ return "video";
301
+ }
302
+ return null;
303
+ }
304
+ function extractCandidateUrls(value) {
305
+ const urls = new Set();
306
+ const markdownLinkPattern = /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g;
307
+ const bareUrlPattern = /(^|[\s(])(https?:\/\/[^\s<]+[^<.,:;"')\]\s])/gm;
308
+ let match = null;
309
+ while ((match = markdownLinkPattern.exec(value)) !== null) {
310
+ urls.add(match[2]);
311
+ }
312
+ while ((match = bareUrlPattern.exec(value)) !== null) {
313
+ urls.add(match[2]);
314
+ }
315
+ return [...urls];
316
+ }
317
+ function extractInlineMediaPreviews(text, excludedUrls = []) {
318
+ const excluded = new Set(excludedUrls);
319
+ const seen = new Set();
320
+ const previews = [];
321
+ for (const url of extractCandidateUrls(text)) {
322
+ if (excluded.has(url) || seen.has(url)) {
323
+ continue;
324
+ }
325
+ const kind = getMediaKindFromUrl(url);
326
+ if (!kind) {
327
+ continue;
328
+ }
329
+ seen.add(url);
330
+ previews.push({
331
+ kind,
332
+ url,
333
+ label: labelFromUrl(url)
334
+ });
335
+ }
336
+ return previews;
337
+ }
338
+ function collectLabeledUrls(value, output, seen = new Set(), path = []) {
339
+ if (output.length >= 12 || value == null) {
340
+ return;
341
+ }
342
+ if (typeof value === "string") {
343
+ const trimmed = value.trim();
344
+ if (/^https?:\/\//i.test(trimmed) && !seen.has(trimmed)) {
345
+ seen.add(trimmed);
346
+ const rawPathLabel = path.length ? path[path.length - 1] || "Output" : "";
347
+ const pathLabel = titleCaseLabel(rawPathLabel);
348
+ const filenameLabel = labelFromUrl(trimmed);
349
+ output.push({
350
+ label: filenameLabel !== "Output" ? filenameLabel : pathLabel && !isGenericPathLabel(rawPathLabel) ? pathLabel : filenameLabel,
351
+ url: trimmed
352
+ });
353
+ }
354
+ return;
355
+ }
356
+ if (Array.isArray(value)) {
357
+ for (const [index, entry] of value.entries()) {
358
+ collectLabeledUrls(entry, output, seen, [...path, `item ${index + 1}`]);
359
+ if (output.length >= 12) {
360
+ return;
361
+ }
362
+ }
363
+ return;
364
+ }
365
+ if (typeof value === "object") {
366
+ for (const [key, entry] of Object.entries(value)) {
367
+ collectLabeledUrls(entry, output, seen, [...path, key]);
368
+ if (output.length >= 12) {
369
+ return;
370
+ }
371
+ }
372
+ }
373
+ }
374
+ function buildJobQueuedMessage(job) {
375
+ return [
376
+ `Started render \`${job.job_id}\`${job.tracer ? ` with tracer \`${job.tracer}\`` : ""}.`,
377
+ "If it takes a while, ask for an update later and I can check it for you."
378
+ ].join("\n\n");
379
+ }
380
+ function isBrainstormPrimitiveId(value) {
381
+ return typeof value === "string" && value.startsWith("primitive:brainstorm_");
382
+ }
383
+ function normalizeJobArtifacts(value) {
384
+ if (!Array.isArray(value)) {
385
+ return [];
386
+ }
387
+ const artifacts = [];
388
+ for (const entry of value) {
389
+ if (!entry || typeof entry !== "object") {
390
+ continue;
391
+ }
392
+ const artifact = entry;
393
+ const normalized = {
394
+ artifact_id: typeof artifact.artifact_id === "string" ? artifact.artifact_id : undefined,
395
+ kind: typeof artifact.kind === "string" ? artifact.kind : undefined,
396
+ storage_key: typeof artifact.storage_key === "string" ? artifact.storage_key : undefined,
397
+ public_url: typeof artifact.public_url === "string" ? artifact.public_url : undefined,
398
+ metadata: artifact.metadata && typeof artifact.metadata === "object"
399
+ ? artifact.metadata
400
+ : null,
401
+ created_at: typeof artifact.created_at === "string" ? artifact.created_at : undefined
402
+ };
403
+ if (normalized.public_url || normalized.storage_key) {
404
+ artifacts.push(normalized);
405
+ }
406
+ }
407
+ return artifacts;
408
+ }
409
+ function artifactLabelFromStorageKey(storageKey) {
410
+ if (!storageKey) {
411
+ return "JSON result";
412
+ }
413
+ const name = safeDecodeURIComponent(storageKey.split("/").filter(Boolean).pop() || "result.json");
414
+ return name;
415
+ }
416
+ function artifactFileName(storageKey) {
417
+ return safeDecodeURIComponent(storageKey?.split("/").filter(Boolean).pop() || "");
418
+ }
419
+ function brainstormLabelFromPrimitiveId(primitiveId) {
420
+ switch (primitiveId) {
421
+ case "primitive:brainstorm_hooks":
422
+ return "Hooks JSON";
423
+ case "primitive:brainstorm_angles":
424
+ return "Angles JSON";
425
+ case "primitive:brainstorm_awareness_stages":
426
+ return "Awareness JSON";
427
+ case "primitive:brainstorm_coldstart":
428
+ return "Coldstart JSON";
429
+ default:
430
+ return "Brainstorm JSON";
431
+ }
432
+ }
433
+ function extractBrainstormJsonArtifactsFromJob(job) {
434
+ if (job.status !== "succeeded" || !isBrainstormPrimitiveId(job.primitive_id)) {
435
+ return [];
436
+ }
437
+ const previews = [];
438
+ for (const [index, artifact] of normalizeJobArtifacts(job.artifacts).entries()) {
439
+ const publicUrl = artifact.public_url?.trim();
440
+ const storageKey = artifact.storage_key?.trim();
441
+ const isJsonArtifact = artifact.kind === "json"
442
+ || Boolean(publicUrl && /\.json(?:[?#]|$)/i.test(publicUrl))
443
+ || Boolean(storageKey && /\.json$/i.test(storageKey));
444
+ if (!isJsonArtifact || !publicUrl) {
445
+ continue;
446
+ }
447
+ const fileLabel = artifactLabelFromStorageKey(artifact.storage_key);
448
+ const prefixLabel = brainstormLabelFromPrimitiveId(job.primitive_id);
449
+ previews.push({
450
+ key: artifact.artifact_id || `${job.job_id}:${publicUrl}:${index}`,
451
+ url: publicUrl,
452
+ label: fileLabel === "result.json" ? prefixLabel : `${prefixLabel} • ${fileLabel}`,
453
+ primitiveId: job.primitive_id,
454
+ primitiveType: job.primitive_type,
455
+ jobId: job.job_id
456
+ });
457
+ }
458
+ return previews;
459
+ }
460
+ function brainstormDocumentLabelFromPrimitiveId(primitiveId) {
461
+ switch (primitiveId) {
462
+ case "primitive:brainstorm_awareness_stages":
463
+ return "Awareness response";
464
+ default:
465
+ return "Brainstorm response";
466
+ }
467
+ }
468
+ function extractBrainstormDocumentArtifactsFromJob(job) {
469
+ if (job.status !== "succeeded" || !isBrainstormPrimitiveId(job.primitive_id)) {
470
+ return [];
471
+ }
472
+ const previews = [];
473
+ for (const [index, artifact] of normalizeJobArtifacts(job.artifacts).entries()) {
474
+ const publicUrl = artifact.public_url?.trim();
475
+ const storageKey = artifact.storage_key?.trim();
476
+ const fileName = artifactFileName(storageKey);
477
+ const isPrompt = /^prompt\./i.test(fileName);
478
+ const isMarkdown = artifact.kind === "text"
479
+ ? Boolean(publicUrl && /\.md(?:[?#]|$)/i.test(publicUrl) || storageKey && /\.md$/i.test(storageKey))
480
+ : Boolean(publicUrl && /\.md(?:[?#]|$)/i.test(publicUrl) || storageKey && /\.md$/i.test(storageKey));
481
+ const isText = Boolean(publicUrl && /\.txt(?:[?#]|$)/i.test(publicUrl) || storageKey && /\.txt$/i.test(storageKey));
482
+ if (!publicUrl || isPrompt || (!isMarkdown && !isText)) {
483
+ continue;
484
+ }
485
+ const defaultLabel = brainstormDocumentLabelFromPrimitiveId(job.primitive_id);
486
+ previews.push({
487
+ key: artifact.artifact_id || `${job.job_id}:${publicUrl}:${index}`,
488
+ url: publicUrl,
489
+ label: fileName === "response.md" || fileName === "response.txt" ? defaultLabel : artifactLabelFromStorageKey(storageKey),
490
+ jobId: job.job_id,
491
+ format: isMarkdown ? "markdown" : "text"
492
+ });
493
+ }
494
+ return previews;
495
+ }
496
+ function buildJobCompletionMessage(job) {
497
+ if (job.status === "succeeded") {
498
+ const urls = [];
499
+ collectLabeledUrls(job.result, urls);
500
+ const lines = [`Render \`${job.job_id}\` succeeded.`];
501
+ if (isBrainstormPrimitiveId(job.primitive_id)
502
+ && (extractBrainstormJsonArtifactsFromJob(job).length || extractBrainstormDocumentArtifactsFromJob(job).length)) {
503
+ lines.push("");
504
+ lines.push("Open the result below. It contains the brainstorm output you can review, copy, and reuse.");
505
+ }
506
+ if (urls.length) {
507
+ lines.push("");
508
+ lines.push("Outputs:");
509
+ lines.push(...urls.map(({ url, label }) => `- [${label}](${url})`));
510
+ }
511
+ return lines.join("\n");
512
+ }
513
+ if (job.status === "failed") {
514
+ return `Render \`${job.job_id}\` failed.${job.error?.message ? ` ${job.error.message}` : ""}`;
515
+ }
516
+ if (job.status === "cancelled") {
517
+ return `Render \`${job.job_id}\` was cancelled.`;
518
+ }
519
+ return `Render \`${job.job_id}\` is ${job.status}.`;
520
+ }
521
+ function appendDistinctMessageText(current, addition) {
522
+ const normalizedCurrent = current.trim();
523
+ const normalizedAddition = addition.trim();
524
+ if (!normalizedAddition) {
525
+ return current;
526
+ }
527
+ if (normalizedCurrent.includes(normalizedAddition)) {
528
+ return current;
529
+ }
530
+ return normalizedCurrent ? `${normalizedCurrent}\n\n${normalizedAddition}` : normalizedAddition;
531
+ }
532
+ const CHAT_BOTTOM_STICKY_THRESHOLD_PX = 48;
533
+ function isScrolledNearBottom(element) {
534
+ return element.scrollHeight - element.scrollTop - element.clientHeight <= CHAT_BOTTOM_STICKY_THRESHOLD_PX;
535
+ }
536
+ function upsertAutomaticPollExchange(exchanges, nextExchange) {
537
+ const current = [...(exchanges ?? [])];
538
+ const matchIndex = current.findIndex((exchange) => (exchange.pollMode === "automatic" && exchange.pollJobId === nextExchange.pollJobId));
539
+ if (matchIndex === -1) {
540
+ current.push(nextExchange);
541
+ return current;
542
+ }
543
+ current[matchIndex] = nextExchange;
544
+ return current;
545
+ }
546
+ const JOB_POLL_INTERVAL_MS = 30_000;
547
+ const JOB_POLL_BUSY_INTERVAL_MS = 180_000;
548
+ const JOB_POLL_BUSY_THRESHOLD = 4;
549
+ const JOB_POLL_MAX_ATTEMPTS = 40;
550
+ const JOB_POLL_GLOBAL_MIN_GAP_MS = 1_500;
551
+ const JOB_POLL_MIN_TIMER_MS = 250;
552
+ function tryReadJobStatusPayload(value) {
553
+ if (!value || typeof value !== "object") {
554
+ return null;
555
+ }
556
+ const record = value;
557
+ if (typeof record.job_id !== "string" || typeof record.status !== "string") {
558
+ return null;
559
+ }
560
+ return {
561
+ job_id: record.job_id,
562
+ tracer: typeof record.tracer === "string" ? record.tracer : undefined,
563
+ status: record.status,
564
+ primitive_id: typeof record.primitive_id === "string" ? record.primitive_id : undefined,
565
+ primitive_type: typeof record.primitive_type === "string" ? record.primitive_type : undefined,
566
+ operation_name: typeof record.operation_name === "string" ? record.operation_name : undefined,
567
+ progress: typeof record.progress === "number" ? record.progress : null,
568
+ result: record.result,
569
+ artifacts: normalizeJobArtifacts(record.artifacts),
570
+ error: record.error && typeof record.error === "object"
571
+ ? {
572
+ message: typeof record.error.message === "string"
573
+ ? record.error.message
574
+ : undefined,
575
+ type: typeof record.error.type === "string"
576
+ ? record.error.type
577
+ : undefined
578
+ }
579
+ : null
580
+ };
581
+ }
582
+ function isPrimitiveRestUrl(value) {
583
+ if (!value) {
584
+ return false;
585
+ }
586
+ try {
587
+ const base = typeof window !== "undefined" ? window.location.origin : "http://localhost";
588
+ return new URL(value, base).pathname.startsWith("/api/v1/primitives");
589
+ }
590
+ catch {
591
+ return false;
592
+ }
593
+ }
594
+ function isTerminalJobStatus(status) {
595
+ return status === "succeeded" || status === "failed" || status === "cancelled";
596
+ }
597
+ function makeJobPollerKey(threadId, assistantMessageId, jobId) {
598
+ return `${threadId}::${assistantMessageId}::${jobId}`;
599
+ }
600
+ function buildAuthHeaders(apiKey, contentType) {
601
+ const headers = new Headers();
602
+ if (apiKey) {
603
+ headers.set("vidfarm-api-key", apiKey);
604
+ }
605
+ if (contentType) {
606
+ headers.set("content-type", contentType);
607
+ }
608
+ return headers;
609
+ }
610
+ async function parseJson(response) {
611
+ return await response.json();
612
+ }
613
+ async function fileToDataUrl(file) {
614
+ return await new Promise((resolve, reject) => {
615
+ const reader = new FileReader();
616
+ reader.onload = () => {
617
+ if (typeof reader.result === "string") {
618
+ resolve(reader.result);
619
+ return;
620
+ }
621
+ reject(new Error("Unable to read attachment contents."));
622
+ };
623
+ reader.onerror = () => reject(reader.error ?? new Error("Unable to read attachment contents."));
624
+ reader.readAsDataURL(file);
625
+ });
626
+ }
627
+ async function blobToDataUrl(blob) {
628
+ return await new Promise((resolve, reject) => {
629
+ const reader = new FileReader();
630
+ reader.onload = () => {
631
+ if (typeof reader.result === "string") {
632
+ resolve(reader.result);
633
+ return;
634
+ }
635
+ reject(new Error("Unable to read compressed attachment contents."));
636
+ };
637
+ reader.onerror = () => reject(reader.error ?? new Error("Unable to read compressed attachment contents."));
638
+ reader.readAsDataURL(blob);
639
+ });
640
+ }
641
+ function estimateDataUrlBytes(value) {
642
+ if (!value) {
643
+ return 0;
644
+ }
645
+ const marker = ";base64,";
646
+ const markerIndex = value.indexOf(marker);
647
+ if (markerIndex < 0) {
648
+ return value.length;
649
+ }
650
+ const base64 = value.slice(markerIndex + marker.length).replace(/\s/g, "");
651
+ const padding = base64.endsWith("==") ? 2 : base64.endsWith("=") ? 1 : 0;
652
+ return Math.max(0, Math.floor(base64.length * 3 / 4) - padding);
653
+ }
654
+ function estimateInlineAttachmentBytes(attachments) {
655
+ return attachments.reduce((total, attachment) => total + estimateDataUrlBytes(attachment.transportData), 0);
656
+ }
657
+ async function canvasToBlob(canvas, contentType, quality) {
658
+ return await new Promise((resolve) => {
659
+ canvas.toBlob((blob) => resolve(blob), contentType, quality);
660
+ });
661
+ }
662
+ async function decodeImageForCanvas(file) {
663
+ if (typeof createImageBitmap === "function") {
664
+ try {
665
+ const bitmap = await createImageBitmap(file);
666
+ return {
667
+ source: bitmap,
668
+ width: bitmap.width,
669
+ height: bitmap.height,
670
+ close: () => bitmap.close()
671
+ };
672
+ }
673
+ catch {
674
+ // Fall back to HTMLImageElement decoding below.
675
+ }
676
+ }
677
+ return await new Promise((resolve) => {
678
+ const image = new Image();
679
+ const objectUrl = URL.createObjectURL(file);
680
+ image.onload = () => {
681
+ URL.revokeObjectURL(objectUrl);
682
+ resolve({
683
+ source: image,
684
+ width: image.naturalWidth || image.width,
685
+ height: image.naturalHeight || image.height
686
+ });
687
+ };
688
+ image.onerror = () => {
689
+ URL.revokeObjectURL(objectUrl);
690
+ resolve(null);
691
+ };
692
+ image.src = objectUrl;
693
+ });
694
+ }
695
+ async function createCompressedImageDataUrl(file, remainingBudgetBytes) {
696
+ if (!file.type.startsWith("image/") || remainingBudgetBytes <= 0) {
697
+ return undefined;
698
+ }
699
+ const hardLimitBytes = Math.min(CHAT_INLINE_IMAGE_HARD_LIMIT_BYTES, remainingBudgetBytes);
700
+ const targetBytes = Math.min(CHAT_INLINE_IMAGE_TARGET_BYTES, hardLimitBytes);
701
+ if (file.size <= targetBytes) {
702
+ return fileToDataUrl(file);
703
+ }
704
+ const decoded = await decodeImageForCanvas(file);
705
+ if (!decoded || decoded.width <= 0 || decoded.height <= 0) {
706
+ return file.size <= hardLimitBytes ? fileToDataUrl(file) : undefined;
707
+ }
708
+ try {
709
+ let bestBlob = null;
710
+ const maxEdges = [CHAT_INLINE_IMAGE_MAX_EDGE, 1600, 1280, 1024];
711
+ const qualities = [0.86, 0.78, 0.7, 0.62, 0.54];
712
+ for (const maxEdge of maxEdges) {
713
+ const scale = Math.min(1, maxEdge / Math.max(decoded.width, decoded.height));
714
+ const width = Math.max(1, Math.round(decoded.width * scale));
715
+ const height = Math.max(1, Math.round(decoded.height * scale));
716
+ const canvas = document.createElement("canvas");
717
+ canvas.width = width;
718
+ canvas.height = height;
719
+ const context = canvas.getContext("2d");
720
+ if (!context) {
721
+ continue;
722
+ }
723
+ context.fillStyle = "#fff";
724
+ context.fillRect(0, 0, width, height);
725
+ context.drawImage(decoded.source, 0, 0, width, height);
726
+ for (const quality of qualities) {
727
+ const blob = await canvasToBlob(canvas, "image/jpeg", quality);
728
+ if (!blob) {
729
+ continue;
730
+ }
731
+ if (blob.size <= hardLimitBytes && (!bestBlob || blob.size < bestBlob.size)) {
732
+ bestBlob = blob;
733
+ }
734
+ if (blob.size <= targetBytes) {
735
+ return blobToDataUrl(blob);
736
+ }
737
+ }
738
+ }
739
+ if (bestBlob && (file.size > hardLimitBytes || bestBlob.size < file.size)) {
740
+ return blobToDataUrl(bestBlob);
741
+ }
742
+ return file.size <= hardLimitBytes ? fileToDataUrl(file) : undefined;
743
+ }
744
+ finally {
745
+ decoded.close?.();
746
+ }
747
+ }
748
+ async function addInlinePreviewForChatUpload(input) {
749
+ const remainingBudgetBytes = CHAT_INLINE_TOTAL_BUDGET_BYTES - input.currentInlineBytes;
750
+ const transportData = await createCompressedImageDataUrl(input.file, remainingBudgetBytes).catch(() => undefined);
751
+ return transportData ? { ...input.attachment, transportData } : input.attachment;
752
+ }
753
+ async function uploadAttachment(input) {
754
+ const maxBytes = input.maxBytes ?? MY_FILES_UPLOAD_LIMIT_BYTES;
755
+ const limitLabel = input.limitLabel ?? "50 MB";
756
+ if (input.file.size > maxBytes) {
757
+ throw new Error(`File uploads can be at most ${limitLabel}.`);
758
+ }
759
+ const uploadFormData = new FormData();
760
+ uploadFormData.append("file", input.file);
761
+ uploadFormData.append("folder_path", input.folderPath || "");
762
+ debugLog("chat.upload.attempt", {
763
+ file_name: input.file.name,
764
+ size_bytes: input.file.size,
765
+ folder_path: input.folderPath
766
+ });
767
+ const uploadResponse = await fetch("/api/v1/user/me/attachments/upload", {
768
+ method: "POST",
769
+ headers: buildAuthHeaders(input.apiKey),
770
+ body: uploadFormData
771
+ });
772
+ const uploadPayload = await parseJson(uploadResponse);
773
+ if (!uploadResponse.ok || !uploadPayload.attachment) {
774
+ debugError("chat.upload.failed", {
775
+ file_name: input.file.name,
776
+ status: uploadResponse.status,
777
+ error: uploadPayload.error
778
+ });
779
+ throw new Error(uploadPayload.error || "Unable to upload attachment.");
780
+ }
781
+ debugLog("chat.upload.succeeded", {
782
+ file_name: input.file.name,
783
+ attachment_id: uploadPayload.attachment.id
784
+ });
785
+ return normalizeUploadedAttachment(uploadPayload.attachment) ?? (() => {
786
+ throw new Error("Attachment response was incomplete.");
787
+ })();
788
+ }
789
+ async function listMyFiles(apiKey) {
790
+ const response = await fetch("/api/v1/user/me/attachments", {
791
+ headers: buildAuthHeaders(apiKey)
792
+ });
793
+ const payload = await parseJson(response);
794
+ if (!response.ok) {
795
+ throw new Error(payload.error || "Unable to load My Files.");
796
+ }
797
+ const attachments = [];
798
+ for (const entry of payload.attachments ?? []) {
799
+ const normalized = normalizeUploadedAttachment(entry);
800
+ if (normalized) {
801
+ attachments.push({ ...normalized, createdAt: entry.createdAt ?? entry.created_at });
802
+ }
803
+ }
804
+ return {
805
+ attachments,
806
+ folders: payload.folders ?? []
807
+ };
808
+ }
809
+ async function uploadTemporaryFile(input) {
810
+ if (input.file.size > 100 * 1024 * 1024) {
811
+ throw new Error("Temporary files can be at most 100 MB.");
812
+ }
813
+ debugLog("chat.temp_upload.attempt", {
814
+ file_name: input.file.name,
815
+ size_bytes: input.file.size,
816
+ content_type: input.file.type
817
+ });
818
+ const presignResponse = await fetch("/api/v1/user/me/temporary-files/presign", {
819
+ method: "POST",
820
+ headers: buildAuthHeaders(input.apiKey, "application/json"),
821
+ body: JSON.stringify({
822
+ file_name: input.file.name,
823
+ content_type: input.file.type || undefined,
824
+ size_bytes: input.file.size,
825
+ folder_path: input.folderPath || undefined
826
+ })
827
+ });
828
+ const presignPayload = await parseJson(presignResponse);
829
+ if (!presignResponse.ok || !("transport" in presignPayload)) {
830
+ throw new Error(("error" in presignPayload && presignPayload.error) || "Unable to prepare temporary upload.");
831
+ }
832
+ if (presignPayload.transport === "server") {
833
+ const uploadFormData = new FormData();
834
+ uploadFormData.append(presignPayload.upload.form_field || "file", input.file);
835
+ uploadFormData.append("folder_path", input.folderPath || "");
836
+ const uploadResponse = await fetch(presignPayload.upload.url, {
837
+ method: presignPayload.upload.method || "POST",
838
+ headers: buildAuthHeaders(input.apiKey),
839
+ body: uploadFormData
840
+ });
841
+ const uploadPayload = await parseJson(uploadResponse);
842
+ if (!uploadResponse.ok || !uploadPayload.file) {
843
+ throw new Error(uploadPayload.error || "Unable to upload temporary file.");
844
+ }
845
+ const normalized = normalizeUploadedAttachment(uploadPayload.file);
846
+ if (!normalized) {
847
+ throw new Error("Temporary file response was incomplete.");
848
+ }
849
+ return {
850
+ ...normalized,
851
+ createdAt: uploadPayload.file.createdAt ?? uploadPayload.file.created_at,
852
+ expiresAt: uploadPayload.file.expiresAt ?? uploadPayload.file.expires_at,
853
+ s3Url: uploadPayload.file.s3Url ?? uploadPayload.file.s3_url
854
+ };
855
+ }
856
+ const uploadResponse = await fetch(presignPayload.upload.url, {
857
+ method: presignPayload.upload.method,
858
+ headers: presignPayload.upload.headers,
859
+ body: input.file
860
+ });
861
+ if (!uploadResponse.ok) {
862
+ throw new Error(`Upload failed with status ${uploadResponse.status}.`);
863
+ }
864
+ const finalizeResponse = await fetch("/api/v1/user/me/temporary-files", {
865
+ method: "POST",
866
+ headers: buildAuthHeaders(input.apiKey, "application/json"),
867
+ body: JSON.stringify({
868
+ file_id: presignPayload.file_id,
869
+ file_name: presignPayload.file_name,
870
+ content_type: presignPayload.content_type,
871
+ size_bytes: input.file.size,
872
+ storage_key: presignPayload.storage_key,
873
+ folder_path: presignPayload.folder_path || input.folderPath || ""
874
+ })
875
+ });
876
+ const finalizePayload = await parseJson(finalizeResponse);
877
+ if (!finalizeResponse.ok || !finalizePayload.file) {
878
+ throw new Error(finalizePayload.error || "Unable to save temporary file.");
879
+ }
880
+ const normalized = normalizeUploadedAttachment(finalizePayload.file);
881
+ if (!normalized) {
882
+ throw new Error("Temporary file response was incomplete.");
883
+ }
884
+ return {
885
+ ...normalized,
886
+ createdAt: finalizePayload.file.createdAt ?? finalizePayload.file.created_at,
887
+ expiresAt: finalizePayload.file.expiresAt ?? finalizePayload.file.expires_at,
888
+ s3Url: finalizePayload.file.s3Url ?? finalizePayload.file.s3_url
889
+ };
890
+ }
891
+ async function listTemporaryFiles(apiKey) {
892
+ const response = await fetch("/api/v1/user/me/temporary-files", {
893
+ headers: buildAuthHeaders(apiKey)
894
+ });
895
+ const payload = await parseJson(response);
896
+ if (!response.ok) {
897
+ throw new Error(payload.error || "Unable to load temporary files.");
898
+ }
899
+ const files = [];
900
+ for (const entry of payload.files ?? []) {
901
+ const normalized = normalizeUploadedAttachment(entry);
902
+ if (normalized) {
903
+ files.push({
904
+ ...normalized,
905
+ createdAt: entry.createdAt ?? entry.created_at,
906
+ expiresAt: entry.expiresAt ?? entry.expires_at,
907
+ s3Url: entry.s3Url ?? entry.s3_url
908
+ });
909
+ }
910
+ }
911
+ return {
912
+ files,
913
+ folders: payload.folders ?? []
914
+ };
915
+ }
916
+ async function listGeneratedMedia(apiKey, cursor) {
917
+ const url = new URL("/api/v1/user/me/generated-media", window.location.origin);
918
+ url.searchParams.set("limit", "24");
919
+ if (cursor) {
920
+ url.searchParams.set("cursor", cursor);
921
+ }
922
+ const response = await fetch(`${url.pathname}${url.search}`, {
923
+ headers: buildAuthHeaders(apiKey)
924
+ });
925
+ const payload = await parseJson(response);
926
+ if (!response.ok) {
927
+ throw new Error(payload.error || "Unable to load recent media.");
928
+ }
929
+ const media = [];
930
+ for (const entry of payload.media ?? []) {
931
+ const normalized = normalizeUploadedAttachment(entry);
932
+ if (normalized) {
933
+ media.push({
934
+ ...normalized,
935
+ createdAt: entry.createdAt ?? entry.created_at,
936
+ jobId: typeof entry.job_id === "string" ? entry.job_id : undefined,
937
+ tracer: typeof entry.tracer === "string" ? entry.tracer : undefined,
938
+ templateId: typeof entry.template_id === "string" ? entry.template_id : undefined
939
+ });
940
+ }
941
+ }
942
+ return {
943
+ media,
944
+ nextCursor: payload.next_cursor ?? null
945
+ };
946
+ }
947
+ function estimateChatMessageChars(messages) {
948
+ return messages.reduce((total, message) => {
949
+ const attachmentChars = message.attachments.reduce((sum, attachment) => (sum + attachment.fileName.length + attachment.contentType.length + 32), 0);
950
+ return total + message.text.length + attachmentChars;
951
+ }, 0);
952
+ }
953
+ function truncateTransportSummaryText(value, maxChars) {
954
+ const normalized = value.trim().replace(/\s+/g, " ");
955
+ if (normalized.length <= maxChars) {
956
+ return normalized;
957
+ }
958
+ return `${normalized.slice(0, Math.max(0, maxChars - 32)).trimEnd()}… [truncated]`;
959
+ }
960
+ function compactTransportHistory(messages) {
961
+ if (messages.length <= CHAT_TRANSPORT_RECENT_MESSAGES
962
+ || estimateChatMessageChars(messages) <= CHAT_TRANSPORT_COMPACT_CHAR_THRESHOLD) {
963
+ return messages;
964
+ }
965
+ const recentMessages = messages.slice(-CHAT_TRANSPORT_RECENT_MESSAGES);
966
+ const oldMessages = messages.slice(0, -CHAT_TRANSPORT_RECENT_MESSAGES);
967
+ const summaryLines = [];
968
+ let summaryChars = 0;
969
+ for (const message of oldMessages) {
970
+ const attachmentsText = message.attachments.length
971
+ ? ` Attachments: ${message.attachments.map((attachment) => `${attachment.fileName} (${attachment.contentType})`).join(", ")}.`
972
+ : "";
973
+ const line = `[${message.role}]: ${truncateTransportSummaryText(`${message.text}${attachmentsText}`, CHAT_TRANSPORT_MAX_MESSAGE_CHARS)}`;
974
+ if (summaryChars + line.length > CHAT_TRANSPORT_MAX_SUMMARY_CHARS) {
975
+ summaryLines.push(`[${oldMessages.length - summaryLines.length} earlier messages omitted]`);
976
+ break;
977
+ }
978
+ summaryLines.push(line);
979
+ summaryChars += line.length;
980
+ }
981
+ return [
982
+ {
983
+ id: makeMessageId("assistant"),
984
+ role: "assistant",
985
+ text: [
986
+ "[Earlier in this conversation - compacted summary]",
987
+ "Older turns were summarized before sending this request so the chat can continue efficiently.",
988
+ "",
989
+ summaryLines.join("\n\n")
990
+ ].join("\n"),
991
+ attachments: []
992
+ },
993
+ ...recentMessages
994
+ ];
995
+ }
996
+ function buildAttachmentTransportText(attachments) {
997
+ if (!attachments.length) {
998
+ return null;
999
+ }
1000
+ const lines = attachments.map((attachment) => (`- ${attachment.fileName} (${attachment.contentType}): ${attachment.viewUrl}`));
1001
+ return [
1002
+ "Attached file URLs for REST payloads:",
1003
+ ...lines,
1004
+ "Use these exact URLs as prompt_attachments for primitive image generation, source_image_url for primitive image edits, media_url for primitive video slides, or reference attachment URLs when calling template routes."
1005
+ ].join("\n");
1006
+ }
1007
+ function toTransportMessages(messages) {
1008
+ const latestIndex = messages.length - 1;
1009
+ return messages.map((message, messageIndex) => {
1010
+ const attachmentText = buildAttachmentTransportText(message.attachments);
1011
+ return {
1012
+ role: message.role,
1013
+ content: [
1014
+ ...(message.text ? [{ type: "text", text: message.text }] : []),
1015
+ ...(attachmentText ? [{ type: "text", text: attachmentText }] : []),
1016
+ ...message.attachments.map((attachment) => ({
1017
+ type: "file",
1018
+ data: messageIndex === latestIndex ? attachment.transportData || attachment.viewUrl : attachment.viewUrl,
1019
+ mediaType: attachment.contentType
1020
+ }))
1021
+ ]
1022
+ };
1023
+ });
1024
+ }
1025
+ function readCachedScheduleChannels() {
1026
+ const cache = window.__vidfarmFlockPosterCache;
1027
+ const snapshot = cache?.readSnapshot?.();
1028
+ if (!snapshot) {
1029
+ return {};
1030
+ }
1031
+ const channels = Array.isArray(snapshot?.channels)
1032
+ ? snapshot.channels
1033
+ .map((channel) => channel && typeof channel === "object" ? channel : null)
1034
+ .filter((channel) => typeof channel?.id === "string" && Boolean(channel.id.trim()))
1035
+ .map((channel) => ({
1036
+ id: String(channel?.id || ""),
1037
+ name: typeof channel?.name === "string" ? channel.name : null,
1038
+ title: typeof channel?.title === "string" ? channel.title : null,
1039
+ handle: typeof channel?.handle === "string" ? channel.handle : null,
1040
+ platform: typeof channel?.platform === "string" ? channel.platform : null,
1041
+ status: typeof channel?.status === "string" ? channel.status : null
1042
+ }))
1043
+ : [];
1044
+ return channels.length || snapshot?.channelsUpdatedAt
1045
+ ? {
1046
+ scheduleChannels: channels,
1047
+ scheduleChannelsUpdatedAt: snapshot?.channelsUpdatedAt ?? null
1048
+ }
1049
+ : {};
1050
+ }
1051
+ function readSelectedLibraryPosts() {
1052
+ const snapshot = window.__vidfarmLibrarySelection?.readSelectedPosts?.();
1053
+ const posts = Array.isArray(snapshot?.posts)
1054
+ ? snapshot.posts
1055
+ .map((post) => post && typeof post === "object" ? post : null)
1056
+ .filter((post) => typeof post?.post_id === "string" && Boolean(post.post_id.trim()))
1057
+ .map((post) => ({
1058
+ post_id: String(post?.post_id || ""),
1059
+ title: typeof post?.title === "string" ? post.title : null,
1060
+ caption: typeof post?.caption === "string" ? post.caption : null,
1061
+ tracer: typeof post?.tracer === "string" ? post.tracer : null,
1062
+ status: typeof post?.status === "string" ? post.status : null,
1063
+ share_url: typeof post?.share_url === "string" ? post.share_url : null,
1064
+ media: Array.isArray(post?.media)
1065
+ ? post.media.slice(0, 8).map((asset) => asset && typeof asset === "object" ? asset : null).filter(Boolean).map((asset) => ({
1066
+ url: typeof asset?.url === "string" ? asset.url : null,
1067
+ kind: typeof asset?.kind === "string" ? asset.kind : null,
1068
+ role: typeof asset?.role === "string" ? asset.role : null,
1069
+ content_type: typeof asset?.content_type === "string" ? asset.content_type : null
1070
+ }))
1071
+ : []
1072
+ }))
1073
+ : [];
1074
+ return {
1075
+ selectedPosts: posts,
1076
+ selectedPostsUpdatedAt: snapshot?.updatedAt ?? null
1077
+ };
1078
+ }
1079
+ async function readStreamText(response, handlers) {
1080
+ if (!response.body) {
1081
+ throw new Error("Chat response did not include a body.");
1082
+ }
1083
+ const reader = response.body.getReader();
1084
+ const decoder = new TextDecoder();
1085
+ let buffer = "";
1086
+ while (true) {
1087
+ const { done, value } = await reader.read();
1088
+ if (done) {
1089
+ break;
1090
+ }
1091
+ buffer += decoder.decode(value, { stream: true });
1092
+ const events = buffer.split("\n\n");
1093
+ buffer = events.pop() ?? "";
1094
+ for (const event of events) {
1095
+ const line = event
1096
+ .split("\n")
1097
+ .map((entry) => entry.trim())
1098
+ .find((entry) => entry.startsWith("data:"));
1099
+ if (!line) {
1100
+ continue;
1101
+ }
1102
+ const payload = line.slice(5).trim();
1103
+ if (payload === "[DONE]") {
1104
+ continue;
1105
+ }
1106
+ let parsed;
1107
+ try {
1108
+ parsed = JSON.parse(payload);
1109
+ }
1110
+ catch {
1111
+ continue;
1112
+ }
1113
+ if (!parsed || typeof parsed !== "object") {
1114
+ continue;
1115
+ }
1116
+ const chunk = parsed;
1117
+ if (chunk.type === "text-delta" && typeof chunk.delta === "string") {
1118
+ handlers.onDelta(chunk.delta);
1119
+ }
1120
+ else if (chunk.type === "status") {
1121
+ const statusMessage = typeof chunk.status === "string"
1122
+ ? chunk.status
1123
+ : typeof chunk.message === "string"
1124
+ ? chunk.message
1125
+ : null;
1126
+ if (statusMessage && handlers.onStatus) {
1127
+ handlers.onStatus(statusMessage);
1128
+ }
1129
+ }
1130
+ else if (chunk.type === "tool-call"
1131
+ && typeof chunk.toolCallId === "string"
1132
+ && typeof chunk.toolName === "string"
1133
+ && handlers.onToolCall) {
1134
+ handlers.onToolCall({
1135
+ toolCallId: chunk.toolCallId,
1136
+ toolName: chunk.toolName,
1137
+ args: chunk.args && typeof chunk.args === "object" ? chunk.args : undefined
1138
+ });
1139
+ }
1140
+ else if (chunk.type === "tool-result"
1141
+ && typeof chunk.toolCallId === "string"
1142
+ && typeof chunk.toolName === "string"
1143
+ && handlers.onToolResult) {
1144
+ handlers.onToolResult({
1145
+ toolCallId: chunk.toolCallId,
1146
+ toolName: chunk.toolName,
1147
+ result: chunk.result
1148
+ });
1149
+ }
1150
+ else if (chunk.type === "error" && handlers.onError) {
1151
+ handlers.onError?.(typeof chunk.errorText === "string"
1152
+ ? chunk.errorText
1153
+ : typeof chunk.error?.message === "string"
1154
+ ? chunk.error.message
1155
+ : "Unable to read assistant response.");
1156
+ }
1157
+ }
1158
+ }
1159
+ }
1160
+ function makeStorageKeys(templateId) {
1161
+ return {
1162
+ threads: `vidfarm-editor-chat-threads:${templateId}`,
1163
+ activeThread: `vidfarm-editor-chat-active-thread:${templateId}`
1164
+ };
1165
+ }
1166
+ function getChatStorage() {
1167
+ if (typeof window === "undefined") {
1168
+ return null;
1169
+ }
1170
+ try {
1171
+ return window.sessionStorage;
1172
+ }
1173
+ catch {
1174
+ try {
1175
+ return window.localStorage;
1176
+ }
1177
+ catch {
1178
+ return null;
1179
+ }
1180
+ }
1181
+ }
1182
+ function makeThreadId() {
1183
+ return `thread-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
1184
+ }
1185
+ function makeMessageId(prefix) {
1186
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
1187
+ return `${prefix}-${crypto.randomUUID()}`;
1188
+ }
1189
+ return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
1190
+ }
1191
+ function normalizeTracerList(values) {
1192
+ const seen = new Set();
1193
+ const tracers = [];
1194
+ for (const value of values) {
1195
+ const tracer = value.trim();
1196
+ if (!tracer || seen.has(tracer)) {
1197
+ continue;
1198
+ }
1199
+ seen.add(tracer);
1200
+ tracers.push(tracer);
1201
+ }
1202
+ return tracers;
1203
+ }
1204
+ function mergeTracerLists(...groups) {
1205
+ return normalizeTracerList(groups.flat());
1206
+ }
1207
+ function archiveTracerValue(tracer) {
1208
+ const normalized = tracer.trim();
1209
+ if (!normalized || normalized.startsWith("archived_")) {
1210
+ return normalized;
1211
+ }
1212
+ return `archived_${normalized}`;
1213
+ }
1214
+ function makeTracerSuffix() {
1215
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
1216
+ return crypto.randomUUID().replace(/-/g, "").slice(-8);
1217
+ }
1218
+ return Math.random().toString(16).slice(2, 10).padEnd(8, "0").slice(0, 8);
1219
+ }
1220
+ function slugifyTracerPrefix(value) {
1221
+ const normalized = value
1222
+ .trim()
1223
+ .toLowerCase()
1224
+ .replace(/[^a-z0-9]+/g, "-")
1225
+ .replace(/^-+|-+$/g, "");
1226
+ return (normalized || "tracer").slice(0, 28);
1227
+ }
1228
+ function makeTracerId(templateSlug, existing) {
1229
+ const prefix = slugifyTracerPrefix(templateSlug);
1230
+ let candidate = "";
1231
+ do {
1232
+ candidate = `${prefix}-${makeTracerSuffix()}`;
1233
+ } while (existing.includes(candidate));
1234
+ return candidate;
1235
+ }
1236
+ function ensureTracerHasRandomSuffix(tracer, existing) {
1237
+ const normalized = tracer.trim();
1238
+ if (!normalized) {
1239
+ return "";
1240
+ }
1241
+ if (/-[a-z0-9]{8}$/i.test(normalized)) {
1242
+ return normalized;
1243
+ }
1244
+ let candidate = `${normalized}-${makeTracerSuffix()}`;
1245
+ while (existing.includes(candidate)) {
1246
+ candidate = `${normalized}-${makeTracerSuffix()}`;
1247
+ }
1248
+ return candidate;
1249
+ }
1250
+ function readTracersFromLocation() {
1251
+ const params = new URLSearchParams(window.location.search);
1252
+ const values = [...params.getAll("tracers"), ...params.getAll("tracer")];
1253
+ const output = [];
1254
+ for (const rawValue of values) {
1255
+ const normalized = rawValue.trim();
1256
+ if (!normalized) {
1257
+ continue;
1258
+ }
1259
+ const inner = normalized.startsWith("[") && normalized.endsWith("]")
1260
+ ? normalized.slice(1, -1)
1261
+ : normalized;
1262
+ output.push(...inner.split(",").map((value) => value.trim().replace(/^["']|["']$/g, "")));
1263
+ }
1264
+ return normalizeTracerList(output);
1265
+ }
1266
+ function readThreadIdFromLocation() {
1267
+ const params = new URLSearchParams(window.location.search);
1268
+ return (params.get("thread_id") ||
1269
+ params.get("threadId") ||
1270
+ params.get("chat_id") ||
1271
+ params.get("chatId") ||
1272
+ "").trim();
1273
+ }
1274
+ function formatThreadTitle(text) {
1275
+ const normalized = text.trim().replace(/\s+/g, " ");
1276
+ if (!normalized) {
1277
+ return "New chat";
1278
+ }
1279
+ return normalized.length > 42 ? `${normalized.slice(0, 42).trimEnd()}…` : normalized;
1280
+ }
1281
+ function serializeThreads(threads, activeThreadId) {
1282
+ return threads.map((thread) => ({
1283
+ id: thread.id,
1284
+ templateId: thread.templateId,
1285
+ title: thread.title,
1286
+ createdAt: thread.createdAt,
1287
+ updatedAt: thread.updatedAt,
1288
+ lastMessageAt: thread.lastMessageAt ?? null,
1289
+ messageCount: Math.max(thread.messageCount ?? 0, thread.messages.length),
1290
+ tracers: thread.tracers,
1291
+ hiddenFromView: thread.hiddenFromView,
1292
+ messages: (thread.id === activeThreadId ? thread.messages.slice(-LOCAL_THREAD_MESSAGE_CACHE_LIMIT) : []).map((message) => ({
1293
+ id: message.id,
1294
+ role: message.role,
1295
+ text: message.text,
1296
+ attachments: message.attachments.map((attachment) => ({
1297
+ id: attachment.id,
1298
+ fileName: attachment.fileName,
1299
+ contentType: attachment.contentType,
1300
+ viewUrl: attachment.viewUrl
1301
+ })),
1302
+ toolExchanges: message.toolExchanges?.map((exchange) => ({
1303
+ ...exchange,
1304
+ args: sanitizeChatPayload(exchange.args),
1305
+ result: sanitizeToolResult(exchange.result)
1306
+ })),
1307
+ httpExchanges: message.httpExchanges?.map(sanitizeHttpExchange)
1308
+ }))
1309
+ }));
1310
+ }
1311
+ function hydrateThreads(raw, fallbackTemplateId) {
1312
+ if (!raw) {
1313
+ return [];
1314
+ }
1315
+ try {
1316
+ const parsed = JSON.parse(raw);
1317
+ if (!Array.isArray(parsed)) {
1318
+ return [];
1319
+ }
1320
+ return parsed
1321
+ .filter((thread) => thread && typeof thread.id === "string")
1322
+ .map((thread) => ({
1323
+ id: thread.id,
1324
+ templateId: typeof thread.templateId === "string" && thread.templateId.trim() ? thread.templateId : fallbackTemplateId,
1325
+ title: typeof thread.title === "string" && thread.title.trim() ? thread.title : "New chat",
1326
+ createdAt: typeof thread.createdAt === "string" ? thread.createdAt : new Date().toISOString(),
1327
+ updatedAt: typeof thread.updatedAt === "string" ? thread.updatedAt : new Date().toISOString(),
1328
+ lastMessageAt: typeof thread.lastMessageAt === "string" ? thread.lastMessageAt : null,
1329
+ messageCount: Math.max(typeof thread.messageCount === "number" ? thread.messageCount : 0, Array.isArray(thread.messages) ? thread.messages.length : 0),
1330
+ tracers: normalizeTracerList(Array.isArray(thread.tracers) ? thread.tracers.filter((value) => typeof value === "string") : []),
1331
+ hiddenFromView: thread.hiddenFromView === true,
1332
+ messages: Array.isArray(thread.messages)
1333
+ ? thread.messages.map((message) => ({
1334
+ id: message.id,
1335
+ role: message.role,
1336
+ text: message.text,
1337
+ attachments: Array.isArray(message.attachments) ? message.attachments : [],
1338
+ toolExchanges: Array.isArray(message.toolExchanges) ? message.toolExchanges : [],
1339
+ httpExchanges: Array.isArray(message.httpExchanges) ? message.httpExchanges : []
1340
+ }))
1341
+ : []
1342
+ }));
1343
+ }
1344
+ catch {
1345
+ return [];
1346
+ }
1347
+ }
1348
+ function hydrateThreadRecord(thread, fallbackTemplateId) {
1349
+ if (!thread || typeof thread.id !== "string") {
1350
+ return null;
1351
+ }
1352
+ return {
1353
+ id: thread.id,
1354
+ templateId: typeof thread.templateId === "string" && thread.templateId.trim() ? thread.templateId : fallbackTemplateId,
1355
+ title: typeof thread.title === "string" && thread.title.trim() ? thread.title : "New chat",
1356
+ createdAt: typeof thread.createdAt === "string" ? thread.createdAt : new Date().toISOString(),
1357
+ updatedAt: typeof thread.updatedAt === "string" ? thread.updatedAt : new Date().toISOString(),
1358
+ lastMessageAt: typeof thread.lastMessageAt === "string" ? thread.lastMessageAt : null,
1359
+ messageCount: Math.max(typeof thread.messageCount === "number" ? thread.messageCount : 0, Array.isArray(thread.messages) ? thread.messages.length : 0),
1360
+ tracers: normalizeTracerList(Array.isArray(thread.tracers) ? thread.tracers.filter((value) => typeof value === "string") : []),
1361
+ hiddenFromView: thread.hiddenFromView === true,
1362
+ messages: Array.isArray(thread.messages)
1363
+ ? thread.messages.map((message) => ({
1364
+ id: message.id,
1365
+ role: message.role,
1366
+ text: message.text,
1367
+ attachments: Array.isArray(message.attachments) ? message.attachments : [],
1368
+ toolExchanges: Array.isArray(message.toolExchanges) ? message.toolExchanges : [],
1369
+ httpExchanges: Array.isArray(message.httpExchanges) ? message.httpExchanges : []
1370
+ }))
1371
+ : []
1372
+ };
1373
+ }
1374
+ function mergeThreadSets(localThreads, remoteThreads) {
1375
+ const byId = new Map();
1376
+ for (const thread of remoteThreads) {
1377
+ const localThread = localThreads.find((entry) => entry.id === thread.id);
1378
+ byId.set(thread.id, {
1379
+ ...thread,
1380
+ lastMessageAt: thread.lastMessageAt ?? localThread?.lastMessageAt ?? null,
1381
+ messageCount: Math.max(thread.messageCount ?? 0, localThread?.messageCount ?? 0, thread.messages.length, localThread?.messages.length ?? 0),
1382
+ messages: thread.messages.length ? thread.messages : (localThread?.messages ?? [])
1383
+ });
1384
+ }
1385
+ for (const thread of localThreads) {
1386
+ if (!byId.has(thread.id)) {
1387
+ byId.set(thread.id, thread);
1388
+ }
1389
+ }
1390
+ return [...byId.values()].sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt));
1391
+ }
1392
+ function sortThreadsByUpdatedAt(threads) {
1393
+ return [...threads].sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt));
1394
+ }
1395
+ function threadMatchesTracers(thread, selectedTracers) {
1396
+ if (!selectedTracers.length) {
1397
+ return true;
1398
+ }
1399
+ return thread.tracers.some((threadTracer) => selectedTracers.some((selectedTracer) => doesTracerMatchSelection(threadTracer, selectedTracer)));
1400
+ }
1401
+ function doesTracerMatchSelection(threadTracer, selectedTracer) {
1402
+ const normalizedThreadTracer = threadTracer.trim();
1403
+ const normalizedSelectedTracer = selectedTracer.trim();
1404
+ if (!normalizedThreadTracer || !normalizedSelectedTracer) {
1405
+ return false;
1406
+ }
1407
+ if (normalizedThreadTracer === normalizedSelectedTracer) {
1408
+ return true;
1409
+ }
1410
+ return normalizedThreadTracer.startsWith(`${normalizedSelectedTracer}-`) && /-[a-z0-9]{8}$/i.test(normalizedThreadTracer);
1411
+ }
1412
+ function summarizeThread(thread) {
1413
+ const lastMessage = [...thread.messages].reverse().find((message) => message.text.trim());
1414
+ return lastMessage?.text.trim() || "No messages yet";
1415
+ }
1416
+ function summarizeThreadPreview(thread) {
1417
+ const preview = summarizeThread(thread);
1418
+ if (!thread.messages.length) {
1419
+ return "No messages yet";
1420
+ }
1421
+ const normalizedTitle = thread.title.trim().toLowerCase();
1422
+ const normalizedPreview = preview.trim().toLowerCase();
1423
+ if (!preview || normalizedPreview === normalizedTitle) {
1424
+ return `${thread.messages.length} message${thread.messages.length === 1 ? "" : "s"}`;
1425
+ }
1426
+ return preview;
1427
+ }
1428
+ function AttachmentChip(props) {
1429
+ return (_jsxs("div", { className: "vf-editor-chat-attachment", children: [_jsx("span", { className: "vf-editor-chat-attachment-name", children: props.attachment.fileName }), props.onRemove ? (_jsx("button", { type: "button", className: "vf-editor-chat-attachment-remove", "aria-label": "Remove attachment", onClick: props.onRemove, children: "\u00D7" })) : null] }));
1430
+ }
1431
+ function DownloadIcon() {
1432
+ return (_jsxs("svg", { viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: "1.8", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", focusable: "false", children: [_jsx("path", { d: "M10 3v9" }), _jsx("path", { d: "m6.5 8.5 3.5 3.5 3.5-3.5" }), _jsx("path", { d: "M4 16h12" })] }));
1433
+ }
1434
+ function PaintBrushIcon() {
1435
+ return (_jsxs("svg", { viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: "1.8", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", focusable: "false", children: [_jsx("path", { d: "M4.2 15.8c1.6.5 3.1-.1 3.7-1.7.6-1.4 1.8-2.1 3.5-2.1" }), _jsx("path", { d: "m10.7 11.6 5.2-5.2a1.9 1.9 0 0 0-2.7-2.7L8 8.9" }), _jsx("path", { d: "m7.9 9 3.1 3.1" }), _jsx("path", { d: "M3.5 16.5c1.4.5 3.6.5 4.9-.8" })] }));
1436
+ }
1437
+ function LinkIcon() {
1438
+ return (_jsxs("svg", { viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: "1.8", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", focusable: "false", children: [_jsx("path", { d: "M8.4 11.6a3.2 3.2 0 0 0 4.5 0l2.2-2.2a3.2 3.2 0 0 0-4.5-4.5L9.4 6.1" }), _jsx("path", { d: "M11.6 8.4a3.2 3.2 0 0 0-4.5 0L4.9 10.6a3.2 3.2 0 0 0 4.5 4.5l1.2-1.2" })] }));
1439
+ }
1440
+ function CopyIcon() {
1441
+ return (_jsxs("svg", { viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: "1.7", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", focusable: "false", children: [_jsx("rect", { x: "7", y: "7", width: "9", height: "9", rx: "2" }), _jsx("path", { d: "M5 12H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v1" })] }));
1442
+ }
1443
+ function ChevronIcon(props) {
1444
+ return (_jsx("svg", { viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: "1.8", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", focusable: "false", style: { transform: props.open ? "rotate(90deg)" : "rotate(0deg)" }, children: _jsx("path", { d: "m7 4 6 6-6 6" }) }));
1445
+ }
1446
+ function mediaDownloadName(preview) {
1447
+ const cleanLabel = preview.label.trim().replace(/[\\/:*?"<>|]+/g, "-");
1448
+ if (cleanLabel) {
1449
+ return cleanLabel;
1450
+ }
1451
+ try {
1452
+ const pathname = new URL(preview.url, window.location.href).pathname;
1453
+ const name = pathname.split("/").filter(Boolean).pop();
1454
+ return name || "media";
1455
+ }
1456
+ catch {
1457
+ return "media";
1458
+ }
1459
+ }
1460
+ function mediaInpaintHref(preview, ensureTracer = false) {
1461
+ if (typeof window === "undefined" || preview.kind !== "image" || !preview.url) {
1462
+ return "";
1463
+ }
1464
+ const pageUrl = new URL("/inpaint", window.location.origin);
1465
+ const bridge = window.__vidfarmEditorTracers;
1466
+ const existingTracers = bridge?.get() ?? readTracersFromLocation();
1467
+ const tracer = existingTracers[0] || (ensureTracer ? bridge?.add() ?? "" : "");
1468
+ if (tracer) {
1469
+ pageUrl.searchParams.set("tracer", tracer);
1470
+ }
1471
+ pageUrl.searchParams.set("source_image_url", preview.url);
1472
+ pageUrl.searchParams.set("source_image_name", preview.label || mediaDownloadName(preview));
1473
+ return pageUrl.toString();
1474
+ }
1475
+ function MediaPreviewModal(props) {
1476
+ const [isMounted, setIsMounted] = useState(false);
1477
+ const [copyStatus, setCopyStatus] = useState("");
1478
+ const { onClose } = props;
1479
+ const gallery = props.gallery?.length ? props.gallery : [props.preview];
1480
+ const initialIndex = Math.max(0, gallery.findIndex((preview) => preview.url === props.preview.url));
1481
+ const [activeIndex, setActiveIndex] = useState(initialIndex);
1482
+ const activePreview = gallery[activeIndex] || props.preview;
1483
+ const canNavigate = gallery.length > 1;
1484
+ const canInpaint = activePreview.kind === "image";
1485
+ const initialInpaintHref = canInpaint ? mediaInpaintHref(activePreview, false) || "/inpaint" : "/inpaint";
1486
+ useEffect(() => {
1487
+ setIsMounted(true);
1488
+ }, []);
1489
+ useEffect(() => {
1490
+ setCopyStatus("");
1491
+ }, [activePreview.url]);
1492
+ const handleCopyLink = async () => {
1493
+ try {
1494
+ if (navigator.clipboard?.writeText) {
1495
+ await navigator.clipboard.writeText(activePreview.url);
1496
+ }
1497
+ else {
1498
+ const field = document.createElement("textarea");
1499
+ field.value = activePreview.url;
1500
+ field.style.position = "fixed";
1501
+ field.style.opacity = "0";
1502
+ document.body.appendChild(field);
1503
+ field.focus();
1504
+ field.select();
1505
+ document.execCommand("copy");
1506
+ document.body.removeChild(field);
1507
+ }
1508
+ setCopyStatus("done");
1509
+ }
1510
+ catch {
1511
+ setCopyStatus("error");
1512
+ }
1513
+ window.setTimeout(() => setCopyStatus(""), 1600);
1514
+ };
1515
+ const handleOpenInpaint = (event) => {
1516
+ const href = mediaInpaintHref(activePreview, true);
1517
+ if (!href) {
1518
+ event.preventDefault();
1519
+ return;
1520
+ }
1521
+ event.preventDefault();
1522
+ window.open(href, "_blank", "noopener,noreferrer");
1523
+ };
1524
+ useEffect(() => {
1525
+ const handleKeyDown = (event) => {
1526
+ if (event.key === "Escape") {
1527
+ onClose();
1528
+ return;
1529
+ }
1530
+ if (!canNavigate) {
1531
+ return;
1532
+ }
1533
+ if (event.key === "ArrowLeft") {
1534
+ event.preventDefault();
1535
+ setActiveIndex((current) => (current - 1 + gallery.length) % gallery.length);
1536
+ }
1537
+ else if (event.key === "ArrowRight") {
1538
+ event.preventDefault();
1539
+ setActiveIndex((current) => (current + 1) % gallery.length);
1540
+ }
1541
+ };
1542
+ window.addEventListener("keydown", handleKeyDown);
1543
+ return () => window.removeEventListener("keydown", handleKeyDown);
1544
+ }, [canNavigate, gallery.length, onClose]);
1545
+ if (!isMounted) {
1546
+ return null;
1547
+ }
1548
+ return createPortal(_jsx("div", { className: "vf-editor-chat-media-modal-backdrop", onClick: props.onClose, children: _jsxs("div", { className: "vf-editor-chat-media-modal", onClick: (event) => event.stopPropagation(), children: [_jsxs("div", { className: "vf-editor-chat-media-modal-header", children: [_jsx("a", { className: "vf-editor-chat-media-modal-link", href: activePreview.url, target: "_blank", rel: "noopener noreferrer", children: canNavigate ? `${activePreview.label} (${activeIndex + 1} of ${gallery.length})` : activePreview.label }), _jsxs("div", { className: "vf-editor-chat-media-modal-actions", children: [canNavigate ? (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", className: "vf-editor-chat-media-modal-control", onClick: () => setActiveIndex((current) => (current - 1 + gallery.length) % gallery.length), "aria-label": "Previous image", title: "Previous image", children: "\u2039" }), _jsx("button", { type: "button", className: "vf-editor-chat-media-modal-control", onClick: () => setActiveIndex((current) => (current + 1) % gallery.length), "aria-label": "Next image", title: "Next image", children: "\u203A" })] })) : null, canInpaint ? (_jsx("a", { className: "vf-editor-chat-media-modal-control", href: initialInpaintHref, target: "_blank", rel: "noopener noreferrer", onClick: handleOpenInpaint, "aria-label": "Open image editor", title: "Image edit", children: _jsx(PaintBrushIcon, {}) })) : null, _jsx("a", { className: "vf-editor-chat-media-modal-control", href: activePreview.url, download: mediaDownloadName(activePreview), "aria-label": "Download media", title: "Download", children: _jsx(DownloadIcon, {}) }), _jsx("button", { type: "button", className: "vf-editor-chat-media-modal-control", "data-state": copyStatus, onClick: () => void handleCopyLink(), "aria-label": copyStatus === "done" ? "Media URL copied" : copyStatus === "error" ? "Unable to copy media URL" : "Copy media URL", title: copyStatus === "done" ? "Copied" : copyStatus === "error" ? "Copy failed" : "Copy link", children: _jsx(LinkIcon, {}) }), _jsx("button", { type: "button", className: "vf-editor-chat-media-modal-control", onClick: props.onClose, "aria-label": "Close media preview", title: "Close", children: "\u00D7" })] })] }), _jsx("div", { className: "vf-editor-chat-media-modal-body", children: activePreview.kind === "image" ? (_jsx("img", { src: activePreview.url, alt: activePreview.label, className: "vf-editor-chat-media-modal-image" })) : (_jsx("video", { src: activePreview.url, className: "vf-editor-chat-media-modal-video", controls: true, autoPlay: true, playsInline: true })) })] }) }), document.body);
1549
+ }
1550
+ function ImagePreviewButton(props) {
1551
+ const [hasError, setHasError] = useState(false);
1552
+ return (_jsx("button", { type: "button", className: "vf-editor-chat-message-thumb-button is-image", onClick: () => props.onOpenModal(props.preview, props.previews), "aria-label": `Preview ${props.preview.label}`, title: props.preview.label, "data-error": hasError ? "true" : "false", children: hasError ? (_jsx("span", { className: "vf-editor-chat-message-thumb-fallback", children: "Preview unavailable" })) : (_jsx("img", { src: props.preview.url, alt: props.preview.label, className: "vf-editor-chat-message-attachment-thumb", loading: "lazy", onError: () => setHasError(true) })) }));
1553
+ }
1554
+ function VideoPreviewCard(props) {
1555
+ return (_jsx("div", { className: "vf-editor-chat-message-attachment-row", children: _jsxs("button", { type: "button", className: "vf-editor-chat-message-thumb-button", onClick: () => props.onOpenModal(props.preview), "aria-label": `Preview ${props.preview.label}`, children: [_jsx("video", { src: props.preview.url, className: "vf-editor-chat-message-attachment-thumb vf-editor-chat-message-video-thumb", preload: "metadata", muted: true, playsInline: true }), _jsx("span", { className: "vf-editor-chat-message-video-play", "aria-hidden": "true" })] }) }));
1556
+ }
1557
+ function ImagePreviewRail(props) {
1558
+ if (!props.previews.length) {
1559
+ return null;
1560
+ }
1561
+ return (_jsx("div", { className: "vf-editor-chat-message-image-rail", "aria-label": "Image previews", children: props.previews.map((preview) => (_jsx(ImagePreviewButton, { preview: preview, previews: props.previews, onOpenModal: props.onOpenModal }, preview.url))) }));
1562
+ }
1563
+ function ComposerAttachmentItem(props) {
1564
+ return (_jsxs("div", { className: "vf-editor-chat-composer-attachment-item", children: [isImageAttachment(props.attachment) ? (_jsx("img", { src: props.attachment.viewUrl, alt: props.attachment.fileName, className: "vf-editor-chat-composer-attachment-thumb" })) : (_jsx("div", { className: "vf-editor-chat-composer-attachment-file", "aria-hidden": "true", children: props.attachment.fileName.slice(0, 1).toUpperCase() })), _jsx("div", { className: "vf-editor-chat-composer-attachment-meta", children: _jsx("span", { className: "vf-editor-chat-composer-attachment-name", children: props.attachment.fileName }) }), _jsx("button", { type: "button", className: "vf-editor-chat-composer-attachment-remove", "aria-label": `Remove ${props.attachment.fileName}`, onClick: props.onRemove, children: "\u00D7" })] }));
1565
+ }
1566
+ function HttpExchangeCard(props) {
1567
+ const [isOpen, setIsOpen] = useState(false);
1568
+ const [isMounted, setIsMounted] = useState(false);
1569
+ const requestBody = formatJsonBody(props.exchange.request.body);
1570
+ const responseBody = formatJsonBody(props.exchange.result.body);
1571
+ const requestUrl = props.exchange.request.url;
1572
+ const displayUrl = requestUrl.length > 52
1573
+ ? `${requestUrl.slice(0, 52).trimEnd()}...`
1574
+ : requestUrl;
1575
+ const isError = props.exchange.result.status >= 400 || Boolean(props.exchange.result.error);
1576
+ const isSuccess = props.exchange.result.status >= 200 && props.exchange.result.status < 300;
1577
+ useEffect(() => {
1578
+ setIsMounted(true);
1579
+ }, []);
1580
+ return (_jsxs(_Fragment, { children: [_jsx("div", { className: "vf-editor-chat-http-card", children: _jsxs("button", { type: "button", className: "vf-editor-chat-http-card-header", onClick: () => setIsOpen(true), "aria-label": `Open HTTP details for ${props.exchange.request.method} ${requestUrl}`, children: [_jsx("span", { className: "vf-editor-chat-http-pill", children: props.exchange.request.method }), _jsx("span", { className: "vf-editor-chat-http-url", title: requestUrl, children: displayUrl }), _jsxs("span", { className: "vf-editor-chat-http-status", "data-error": isError ? "true" : "false", "data-success": isSuccess ? "true" : "false", children: [props.exchange.result.status, props.exchange.result.statusText ? ` ${props.exchange.result.statusText}` : ""] }), _jsx("span", { className: "vf-editor-chat-http-expand", "aria-hidden": "true", children: "+" })] }) }), isMounted && isOpen ? createPortal(_jsx("div", { className: "vf-editor-chat-http-modal-backdrop", onClick: () => setIsOpen(false), children: _jsxs("div", { className: "vf-editor-chat-http-modal", onClick: (event) => event.stopPropagation(), children: [_jsxs("div", { className: "vf-editor-chat-http-modal-header", children: [_jsxs("div", { className: "vf-editor-chat-http-modal-header-main", children: [_jsx("span", { className: "vf-editor-chat-http-pill", children: props.exchange.request.method }), _jsx("span", { className: "vf-editor-chat-http-modal-url", children: requestUrl }), _jsxs("span", { className: "vf-editor-chat-http-status", "data-error": isError ? "true" : "false", "data-success": isSuccess ? "true" : "false", children: [props.exchange.result.status, props.exchange.result.statusText ? ` ${props.exchange.result.statusText}` : ""] })] }), _jsx("button", { type: "button", className: "vf-editor-chat-http-modal-close", onClick: () => setIsOpen(false), "aria-label": "Close HTTP details", children: "\u00D7" })] }), _jsxs("div", { className: "vf-editor-chat-http-card-body is-modal", children: [props.exchange.explanation ? (_jsx("div", { className: "vf-editor-chat-http-note", children: props.exchange.explanation })) : null, _jsxs("div", { className: "vf-editor-chat-http-block", children: [_jsx("div", { className: "vf-editor-chat-http-label", children: "URL" }), _jsx("pre", { className: "vf-editor-chat-http-pre", children: requestUrl })] }), requestBody ? (_jsxs("div", { className: "vf-editor-chat-http-block", children: [_jsx("div", { className: "vf-editor-chat-http-label", children: "Request" }), _jsx("pre", { className: "vf-editor-chat-http-pre", children: requestBody })] })) : null, _jsxs("div", { className: "vf-editor-chat-http-block", children: [_jsx("div", { className: "vf-editor-chat-http-label", children: "Response" }), _jsx("pre", { className: "vf-editor-chat-http-pre", children: props.exchange.result.error || responseBody || "(empty)" })] }), Object.keys(props.exchange.result.headers).length ? (_jsxs("div", { className: "vf-editor-chat-http-block", children: [_jsx("div", { className: "vf-editor-chat-http-label", children: "Headers" }), _jsx("pre", { className: "vf-editor-chat-http-pre", children: formatJsonBody(props.exchange.result.headers) })] })) : null] })] }) }), document.body) : null] }));
1581
+ }
1582
+ function formatJsonInlineValue(value) {
1583
+ if (typeof value === "string") {
1584
+ return JSON.stringify(value);
1585
+ }
1586
+ if (typeof value === "number" || typeof value === "boolean") {
1587
+ return String(value);
1588
+ }
1589
+ if (value === null) {
1590
+ return "null";
1591
+ }
1592
+ return JSON.stringify(value);
1593
+ }
1594
+ function copyPlainText(text) {
1595
+ if (navigator.clipboard?.writeText) {
1596
+ return navigator.clipboard.writeText(text);
1597
+ }
1598
+ const field = document.createElement("textarea");
1599
+ field.value = text;
1600
+ field.style.position = "fixed";
1601
+ field.style.opacity = "0";
1602
+ document.body.appendChild(field);
1603
+ field.focus();
1604
+ field.select();
1605
+ document.execCommand("copy");
1606
+ document.body.removeChild(field);
1607
+ return Promise.resolve();
1608
+ }
1609
+ function JsonViewerLine(props) {
1610
+ return (_jsxs("div", { className: "vf-editor-chat-json-line", style: { paddingLeft: `${props.depth * 18 + 12}px` }, children: [_jsx("button", { type: "button", className: "vf-editor-chat-json-copy", "aria-label": "Copy line", title: "Copy line", onClick: () => props.onCopy(props.text), children: _jsx(CopyIcon, {}) }), _jsx("span", { className: "vf-editor-chat-json-line-text", children: props.text })] }));
1611
+ }
1612
+ function JsonViewerNode(props) {
1613
+ const value = props.value;
1614
+ const suffix = props.isLast ? "" : ",";
1615
+ if (Array.isArray(value)) {
1616
+ const path = props.path;
1617
+ const isCollapsed = props.collapsedPaths.has(path);
1618
+ const headerText = `${props.linePrefix ?? ""}[`;
1619
+ if (!value.length) {
1620
+ return _jsx(JsonViewerLine, { text: `${props.linePrefix ?? ""}[]${suffix}`, depth: props.depth, onCopy: props.onCopy });
1621
+ }
1622
+ if (isCollapsed) {
1623
+ return (_jsxs("div", { className: "vf-editor-chat-json-toggle-row", children: [_jsx("button", { type: "button", className: "vf-editor-chat-json-toggle-icon", onClick: () => props.togglePath(path), "aria-label": "Expand JSON block", children: _jsx(ChevronIcon, { open: false }) }), _jsx(JsonViewerLine, { text: `${props.linePrefix ?? ""}[ … ]${suffix}`, depth: props.depth, onCopy: props.onCopy })] }));
1624
+ }
1625
+ return (_jsxs("div", { children: [_jsxs("div", { className: "vf-editor-chat-json-toggle-row", children: [_jsx("button", { type: "button", className: "vf-editor-chat-json-toggle-icon", onClick: () => props.togglePath(path), "aria-label": "Collapse JSON block", children: _jsx(ChevronIcon, { open: true }) }), _jsx(JsonViewerLine, { text: headerText, depth: props.depth, onCopy: props.onCopy })] }), value.map((entry, index) => (_jsx(JsonViewerNode, { value: entry, depth: props.depth + 1, path: `${path}.${index}`, isLast: index === value.length - 1, collapsedPaths: props.collapsedPaths, togglePath: props.togglePath, onCopy: props.onCopy }, `${path}:${index}`))), _jsx(JsonViewerLine, { text: `]${suffix}`, depth: props.depth, onCopy: props.onCopy })] }));
1626
+ }
1627
+ if (value && typeof value === "object") {
1628
+ const entries = Object.entries(value);
1629
+ const path = props.path;
1630
+ const isCollapsed = props.collapsedPaths.has(path);
1631
+ const headerText = `${props.linePrefix ?? ""}{`;
1632
+ if (!entries.length) {
1633
+ return _jsx(JsonViewerLine, { text: `${props.linePrefix ?? ""}{}${suffix}`, depth: props.depth, onCopy: props.onCopy });
1634
+ }
1635
+ if (isCollapsed) {
1636
+ return (_jsxs("div", { className: "vf-editor-chat-json-toggle-row", children: [_jsx("button", { type: "button", className: "vf-editor-chat-json-toggle-icon", onClick: () => props.togglePath(path), "aria-label": "Expand JSON block", children: _jsx(ChevronIcon, { open: false }) }), _jsx(JsonViewerLine, { text: `${props.linePrefix ?? ""}{ … }${suffix}`, depth: props.depth, onCopy: props.onCopy })] }));
1637
+ }
1638
+ return (_jsxs("div", { children: [_jsxs("div", { className: "vf-editor-chat-json-toggle-row", children: [_jsx("button", { type: "button", className: "vf-editor-chat-json-toggle-icon", onClick: () => props.togglePath(path), "aria-label": "Collapse JSON block", children: _jsx(ChevronIcon, { open: true }) }), _jsx(JsonViewerLine, { text: headerText, depth: props.depth, onCopy: props.onCopy })] }), entries.map(([key, entry], index) => {
1639
+ const childPrefix = `${JSON.stringify(key)}: `;
1640
+ if (entry && typeof entry === "object") {
1641
+ return (_jsx(JsonViewerNode, { value: entry, depth: props.depth + 1, linePrefix: childPrefix, path: `${path}.${key}`, isLast: index === entries.length - 1, collapsedPaths: props.collapsedPaths, togglePath: props.togglePath, onCopy: props.onCopy }, `${path}:${key}`));
1642
+ }
1643
+ return (_jsx(JsonViewerLine, { text: `${childPrefix}${formatJsonInlineValue(entry)}${index === entries.length - 1 ? "" : ","}`, depth: props.depth + 1, onCopy: props.onCopy }, `${path}:${key}`));
1644
+ }), _jsx(JsonViewerLine, { text: `}${suffix}`, depth: props.depth, onCopy: props.onCopy })] }));
1645
+ }
1646
+ return (_jsx(JsonViewerLine, { text: `${props.linePrefix ?? ""}${formatJsonInlineValue(value)}${suffix}`, depth: props.depth, onCopy: props.onCopy }));
1647
+ }
1648
+ function JsonViewer(props) {
1649
+ const [collapsedPaths, setCollapsedPaths] = useState(new Set());
1650
+ const [copyState, setCopyState] = useState("");
1651
+ const togglePath = (path) => {
1652
+ setCollapsedPaths((current) => {
1653
+ const next = new Set(current);
1654
+ if (next.has(path)) {
1655
+ next.delete(path);
1656
+ }
1657
+ else {
1658
+ next.add(path);
1659
+ }
1660
+ return next;
1661
+ });
1662
+ };
1663
+ const handleCopy = async (text) => {
1664
+ try {
1665
+ await copyPlainText(text);
1666
+ setCopyState("done");
1667
+ }
1668
+ catch {
1669
+ setCopyState("error");
1670
+ }
1671
+ window.setTimeout(() => setCopyState(""), 1400);
1672
+ };
1673
+ return (_jsxs("div", { className: "vf-editor-chat-json-viewer", children: [_jsxs("div", { className: "vf-editor-chat-json-toolbar", children: [_jsx("span", { className: "vf-editor-chat-json-file", children: props.storageKey }), _jsx("span", { className: "vf-editor-chat-json-copy-state", "data-state": copyState, children: copyState === "done" ? "Copied line" : copyState === "error" ? "Copy failed" : "Hover a line to copy" })] }), _jsx("div", { className: "vf-editor-chat-json-scroll", children: _jsx(JsonViewerNode, { value: props.value, depth: 0, path: "root", collapsedPaths: collapsedPaths, togglePath: togglePath, onCopy: handleCopy }) })] }));
1674
+ }
1675
+ function JsonArtifactModal(props) {
1676
+ const [isMounted, setIsMounted] = useState(false);
1677
+ const [copyStatus, setCopyStatus] = useState("");
1678
+ const downloadName = downloadNameFromUrl(props.artifact.url, `${props.artifact.label}.json`);
1679
+ const formattedData = formatJsonBody(props.data);
1680
+ useEffect(() => {
1681
+ setIsMounted(true);
1682
+ }, []);
1683
+ const handleCopy = async () => {
1684
+ try {
1685
+ await copyPlainText(formattedData);
1686
+ setCopyStatus("done");
1687
+ }
1688
+ catch {
1689
+ setCopyStatus("error");
1690
+ }
1691
+ window.setTimeout(() => setCopyStatus(""), 1600);
1692
+ };
1693
+ useEffect(() => {
1694
+ const handleKeyDown = (event) => {
1695
+ if (event.key === "Escape") {
1696
+ props.onClose();
1697
+ }
1698
+ };
1699
+ window.addEventListener("keydown", handleKeyDown);
1700
+ return () => window.removeEventListener("keydown", handleKeyDown);
1701
+ }, [props]);
1702
+ if (!isMounted) {
1703
+ return null;
1704
+ }
1705
+ return createPortal(_jsx("div", { className: "vf-editor-chat-media-modal-backdrop", onClick: props.onClose, children: _jsxs("div", { className: "vf-editor-chat-json-modal", onClick: (event) => event.stopPropagation(), children: [_jsxs("div", { className: "vf-editor-chat-media-modal-header", children: [_jsx("a", { className: "vf-editor-chat-media-modal-link", href: props.artifact.url, target: "_blank", rel: "noopener noreferrer", children: props.artifact.label }), _jsxs("div", { className: "vf-editor-chat-media-modal-actions", children: [_jsx("button", { type: "button", className: "vf-editor-chat-media-modal-control", "data-state": copyStatus, onClick: () => void handleCopy(), title: copyStatus === "done" ? "Copied" : copyStatus === "error" ? "Copy failed" : "Copy JSON", "aria-label": copyStatus === "done" ? "JSON copied" : copyStatus === "error" ? "Unable to copy JSON" : "Copy JSON", children: _jsx(CopyIcon, {}) }), _jsx("a", { className: "vf-editor-chat-media-modal-control", href: props.artifact.url, download: downloadName, title: "Download JSON", "aria-label": "Download JSON", children: _jsx(DownloadIcon, {}) }), _jsx("a", { className: "vf-editor-chat-media-modal-control", href: props.artifact.url, target: "_blank", rel: "noopener noreferrer", title: "Open raw JSON", "aria-label": "Open raw JSON", children: _jsx(LinkIcon, {}) }), _jsx("button", { type: "button", className: "vf-editor-chat-media-modal-control", onClick: props.onClose, "aria-label": "Close JSON preview", title: "Close", children: "\u00D7" })] })] }), _jsx("div", { className: "vf-editor-chat-json-modal-body", children: _jsx(JsonViewer, { value: props.data, storageKey: props.artifact.label }) })] }) }), document.body);
1706
+ }
1707
+ function JsonArtifactCard(props) {
1708
+ const [isExpanded, setIsExpanded] = useState(false);
1709
+ const [isModalOpen, setIsModalOpen] = useState(false);
1710
+ const [data, setData] = useState(null);
1711
+ const [error, setError] = useState(null);
1712
+ const [loading, setLoading] = useState(false);
1713
+ const ensureLoaded = async () => {
1714
+ if (data !== null || loading) {
1715
+ return;
1716
+ }
1717
+ setLoading(true);
1718
+ setError(null);
1719
+ try {
1720
+ const response = await fetch(props.artifact.url, { credentials: "omit" });
1721
+ const payload = await response.json();
1722
+ setData(payload);
1723
+ }
1724
+ catch (nextError) {
1725
+ setError(nextError instanceof Error ? nextError.message : "Unable to load JSON artifact.");
1726
+ }
1727
+ finally {
1728
+ setLoading(false);
1729
+ }
1730
+ };
1731
+ const handleToggleExpand = () => {
1732
+ const next = !isExpanded;
1733
+ setIsExpanded(next);
1734
+ if (next) {
1735
+ void ensureLoaded();
1736
+ }
1737
+ };
1738
+ const handleOpenModal = () => {
1739
+ void ensureLoaded();
1740
+ setIsModalOpen(true);
1741
+ };
1742
+ return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "vf-editor-chat-json-card", children: [_jsxs("div", { className: "vf-editor-chat-json-card-head", children: [_jsxs("div", { children: [_jsx("div", { className: "vf-editor-chat-json-card-kicker", children: "JSON result" }), _jsx("div", { className: "vf-editor-chat-json-card-title", children: props.artifact.label })] }), _jsxs("div", { className: "vf-editor-chat-json-card-actions", children: [_jsx("button", { type: "button", className: "vf-editor-chat-json-card-action", onClick: handleToggleExpand, children: isExpanded ? "Hide preview" : "Preview JSON" }), _jsx("button", { type: "button", className: "vf-editor-chat-json-card-action is-strong", onClick: handleOpenModal, children: "Open" })] })] }), isExpanded ? (_jsx("div", { className: "vf-editor-chat-json-card-body", children: loading ? (_jsx("div", { className: "vf-editor-chat-json-state", children: "Loading JSON\u2026" })) : error ? (_jsx("div", { className: "vf-editor-chat-json-state is-error", children: error })) : data !== null ? (_jsx(JsonViewer, { value: data, storageKey: props.artifact.label })) : null })) : null] }), isModalOpen && data !== null ? (_jsx(JsonArtifactModal, { artifact: props.artifact, data: data, onClose: () => setIsModalOpen(false) })) : null] }));
1743
+ }
1744
+ function TextArtifactViewer(props) {
1745
+ if (props.format === "markdown") {
1746
+ return (_jsx("div", { className: "vf-editor-chat-doc-markdown", dangerouslySetInnerHTML: { __html: renderMarkdownToHtml(props.text) } }));
1747
+ }
1748
+ return _jsx("pre", { className: "vf-editor-chat-doc-pre", children: props.text });
1749
+ }
1750
+ function DocumentArtifactModal(props) {
1751
+ const [isMounted, setIsMounted] = useState(false);
1752
+ const [copyStatus, setCopyStatus] = useState("");
1753
+ const fallbackExtension = props.artifact.format === "markdown" ? ".md" : ".txt";
1754
+ const downloadName = downloadNameFromUrl(props.artifact.url, `${props.artifact.label}${fallbackExtension}`);
1755
+ useEffect(() => {
1756
+ setIsMounted(true);
1757
+ }, []);
1758
+ const handleCopy = async () => {
1759
+ try {
1760
+ await copyPlainText(props.text);
1761
+ setCopyStatus("done");
1762
+ }
1763
+ catch {
1764
+ setCopyStatus("error");
1765
+ }
1766
+ window.setTimeout(() => setCopyStatus(""), 1600);
1767
+ };
1768
+ useEffect(() => {
1769
+ const handleKeyDown = (event) => {
1770
+ if (event.key === "Escape") {
1771
+ props.onClose();
1772
+ }
1773
+ };
1774
+ window.addEventListener("keydown", handleKeyDown);
1775
+ return () => window.removeEventListener("keydown", handleKeyDown);
1776
+ }, [props]);
1777
+ if (!isMounted) {
1778
+ return null;
1779
+ }
1780
+ return createPortal(_jsx("div", { className: "vf-editor-chat-media-modal-backdrop", onClick: props.onClose, children: _jsxs("div", { className: "vf-editor-chat-json-modal", onClick: (event) => event.stopPropagation(), children: [_jsxs("div", { className: "vf-editor-chat-media-modal-header", children: [_jsx("a", { className: "vf-editor-chat-media-modal-link", href: props.artifact.url, target: "_blank", rel: "noopener noreferrer", children: props.artifact.label }), _jsxs("div", { className: "vf-editor-chat-media-modal-actions", children: [_jsx("button", { type: "button", className: "vf-editor-chat-media-modal-control", "data-state": copyStatus, onClick: () => void handleCopy(), title: copyStatus === "done" ? "Copied" : copyStatus === "error" ? "Copy failed" : props.artifact.format === "markdown" ? "Copy markdown" : "Copy text", "aria-label": copyStatus === "done" ? "Document copied" : copyStatus === "error" ? "Unable to copy document" : props.artifact.format === "markdown" ? "Copy markdown" : "Copy text", children: _jsx(CopyIcon, {}) }), _jsx("a", { className: "vf-editor-chat-media-modal-control", href: props.artifact.url, download: downloadName, title: props.artifact.format === "markdown" ? "Download markdown" : "Download text", "aria-label": props.artifact.format === "markdown" ? "Download markdown" : "Download text", children: _jsx(DownloadIcon, {}) }), _jsx("a", { className: "vf-editor-chat-media-modal-control", href: props.artifact.url, target: "_blank", rel: "noopener noreferrer", title: "Open raw file", "aria-label": "Open raw file", children: _jsx(LinkIcon, {}) }), _jsx("button", { type: "button", className: "vf-editor-chat-media-modal-control", onClick: props.onClose, "aria-label": "Close document preview", title: "Close", children: "\u00D7" })] })] }), _jsx("div", { className: "vf-editor-chat-json-modal-body", children: _jsx("div", { className: "vf-editor-chat-doc-viewer", children: _jsx(TextArtifactViewer, { text: props.text, format: props.artifact.format }) }) })] }) }), document.body);
1781
+ }
1782
+ function DocumentArtifactCard(props) {
1783
+ const [isExpanded, setIsExpanded] = useState(false);
1784
+ const [isModalOpen, setIsModalOpen] = useState(false);
1785
+ const [text, setText] = useState(null);
1786
+ const [error, setError] = useState(null);
1787
+ const [loading, setLoading] = useState(false);
1788
+ const ensureLoaded = async () => {
1789
+ if (text !== null || loading) {
1790
+ return;
1791
+ }
1792
+ setLoading(true);
1793
+ setError(null);
1794
+ try {
1795
+ const response = await fetch(props.artifact.url, { credentials: "omit" });
1796
+ const payload = await response.text();
1797
+ setText(payload);
1798
+ }
1799
+ catch (nextError) {
1800
+ setError(nextError instanceof Error ? nextError.message : "Unable to load document artifact.");
1801
+ }
1802
+ finally {
1803
+ setLoading(false);
1804
+ }
1805
+ };
1806
+ const handleToggleExpand = () => {
1807
+ const next = !isExpanded;
1808
+ setIsExpanded(next);
1809
+ if (next) {
1810
+ void ensureLoaded();
1811
+ }
1812
+ };
1813
+ const handleOpenModal = () => {
1814
+ void ensureLoaded();
1815
+ setIsModalOpen(true);
1816
+ };
1817
+ return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "vf-editor-chat-json-card", children: [_jsxs("div", { className: "vf-editor-chat-json-card-head", children: [_jsxs("div", { children: [_jsx("div", { className: "vf-editor-chat-json-card-kicker", children: props.artifact.format === "markdown" ? "Markdown result" : "Text result" }), _jsx("div", { className: "vf-editor-chat-json-card-title", children: props.artifact.label })] }), _jsxs("div", { className: "vf-editor-chat-json-card-actions", children: [_jsx("button", { type: "button", className: "vf-editor-chat-json-card-action", onClick: handleToggleExpand, children: isExpanded ? "Hide preview" : "Preview" }), _jsx("button", { type: "button", className: "vf-editor-chat-json-card-action is-strong", onClick: handleOpenModal, children: "Open" })] })] }), isExpanded ? (_jsx("div", { className: "vf-editor-chat-json-card-body", children: loading ? (_jsx("div", { className: "vf-editor-chat-json-state", children: "Loading document\u2026" })) : error ? (_jsx("div", { className: "vf-editor-chat-json-state is-error", children: error })) : text !== null ? (_jsx("div", { className: "vf-editor-chat-doc-viewer is-inline", children: _jsx(TextArtifactViewer, { text: text, format: props.artifact.format }) })) : null })) : null] }), isModalOpen && text !== null ? (_jsx(DocumentArtifactModal, { artifact: props.artifact, text: text, onClose: () => setIsModalOpen(false) })) : null] }));
1818
+ }
1819
+ const ChatBubble = memo(function ChatBubble(props) {
1820
+ const isAssistant = props.message.role === "assistant";
1821
+ const hasVisibleText = props.message.text.trim().length > 0;
1822
+ const renderedText = hasVisibleText ? renderMarkdownToHtml(props.message.text) : "";
1823
+ const [mediaModalState, setMediaModalState] = useState(null);
1824
+ const imageAttachments = props.message.attachments.filter(isImageAttachment);
1825
+ const videoAttachments = props.message.attachments.filter(isVideoAttachment);
1826
+ const fileAttachments = props.message.attachments.filter((attachment) => !isImageAttachment(attachment) && !isVideoAttachment(attachment));
1827
+ const inlineMediaPreviews = hasVisibleText
1828
+ ? extractInlineMediaPreviews(props.message.text, props.message.attachments.map((attachment) => attachment.viewUrl))
1829
+ : [];
1830
+ const inlineImagePreviews = inlineMediaPreviews.filter((preview) => preview.kind === "image");
1831
+ const inlineVideoPreviews = inlineMediaPreviews.filter((preview) => preview.kind === "video");
1832
+ const brainstormJsonArtifacts = (props.message.httpExchanges ?? []).flatMap((exchange) => {
1833
+ const job = tryReadJobStatusPayload(exchange.result.body);
1834
+ return job ? extractBrainstormJsonArtifactsFromJob(job) : [];
1835
+ }).filter((artifact, index, items) => items.findIndex((entry) => entry.url === artifact.url) === index);
1836
+ const brainstormDocumentArtifacts = (props.message.httpExchanges ?? []).flatMap((exchange) => {
1837
+ const job = tryReadJobStatusPayload(exchange.result.body);
1838
+ return job ? extractBrainstormDocumentArtifactsFromJob(job) : [];
1839
+ }).filter((artifact, index, items) => items.findIndex((entry) => entry.url === artifact.url) === index);
1840
+ return (_jsxs("div", { className: `vf-editor-chat-message ${isAssistant ? "is-assistant" : "is-user"}`, children: [_jsx("div", { className: "vf-editor-chat-message-meta", children: isAssistant ? "Copilot" : "You" }), _jsxs("div", { className: "vf-editor-chat-bubble", "data-pending": props.message.isPending ? "true" : "false", children: [props.message.attachments.length ? (_jsxs("div", { className: "vf-editor-chat-message-attachments", children: [fileAttachments.map((attachment) => (_jsx("div", { className: "vf-editor-chat-message-attachment-row", children: _jsx("a", { className: "vf-editor-chat-message-preview-link", href: attachment.viewUrl, target: "_blank", rel: "noopener noreferrer", children: _jsx(AttachmentChip, { attachment: attachment }) }) }, attachment.id))), _jsx(ImagePreviewRail, { previews: imageAttachments.map((attachment) => ({
1841
+ kind: "image",
1842
+ url: attachment.viewUrl,
1843
+ label: attachment.fileName
1844
+ })), onOpenModal: (preview, gallery) => setMediaModalState({ preview, gallery }) }), videoAttachments.map((attachment) => (_jsx(VideoPreviewCard, { preview: {
1845
+ kind: "video",
1846
+ url: attachment.viewUrl,
1847
+ label: attachment.fileName
1848
+ }, onOpenModal: (preview) => setMediaModalState({ preview, gallery: [preview] }) }, attachment.id)))] })) : null, props.message.statusText ? (_jsxs("div", { className: "vf-editor-chat-message-status", "aria-live": "polite", children: [_jsx("span", { className: "vf-editor-chat-status-dot" }), props.message.statusText] })) : null, props.message.httpExchanges?.length ? (_jsx("div", { className: "vf-editor-chat-http-list", children: props.message.httpExchanges.map((exchange, index) => (_jsx(HttpExchangeCard, { exchange: exchange }, `${exchange.request.method}:${exchange.request.url}:${index}`))) })) : null, hasVisibleText ? (_jsx("div", { className: "vf-editor-chat-message-body", dangerouslySetInnerHTML: { __html: renderedText } })) : props.message.isPending ? (_jsxs("div", { className: "vf-editor-chat-message-loading", "aria-hidden": "true", children: [_jsx("span", {}), _jsx("span", {}), _jsx("span", {})] })) : null, inlineMediaPreviews.length ? (_jsxs("div", { className: "vf-editor-chat-message-attachments", children: [_jsx(ImagePreviewRail, { previews: inlineImagePreviews, onOpenModal: (preview, gallery) => setMediaModalState({ preview, gallery }) }), inlineVideoPreviews.map((preview) => (_jsx(VideoPreviewCard, { preview: preview, onOpenModal: (activePreview) => setMediaModalState({ preview: activePreview, gallery: [activePreview] }) }, preview.url)))] })) : null, brainstormJsonArtifacts.length ? (_jsx("div", { className: "vf-editor-chat-json-list", children: brainstormJsonArtifacts.map((artifact) => (_jsx(JsonArtifactCard, { artifact: artifact }, artifact.key))) })) : null, brainstormDocumentArtifacts.length ? (_jsx("div", { className: "vf-editor-chat-json-list", children: brainstormDocumentArtifacts.map((artifact) => (_jsx(DocumentArtifactCard, { artifact: artifact }, artifact.key))) })) : null] }), mediaModalState ? (_jsx(MediaPreviewModal, { preview: mediaModalState.preview, gallery: mediaModalState.gallery, onClose: () => setMediaModalState(null) })) : null] }));
1849
+ });
1850
+ function formatBytes(bytes) {
1851
+ if (!bytes || bytes <= 0) {
1852
+ return "Unknown size";
1853
+ }
1854
+ const units = ["B", "KB", "MB", "GB"];
1855
+ let value = bytes;
1856
+ let unitIndex = 0;
1857
+ while (value >= 1024 && unitIndex < units.length - 1) {
1858
+ value /= 1024;
1859
+ unitIndex += 1;
1860
+ }
1861
+ return `${value >= 10 || unitIndex === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[unitIndex]}`;
1862
+ }
1863
+ function FilePreview(props) {
1864
+ if (isImageAttachment(props.file)) {
1865
+ return _jsx("img", { className: "vf-editor-upload-file-thumb", src: props.file.viewUrl, alt: props.file.fileName });
1866
+ }
1867
+ if (isVideoAttachment(props.file)) {
1868
+ return _jsx("video", { className: "vf-editor-upload-file-thumb", src: props.file.viewUrl, muted: true, playsInline: true });
1869
+ }
1870
+ return _jsx("div", { className: "vf-editor-upload-file-icon", "aria-hidden": "true", children: props.file.fileName.slice(0, 1).toUpperCase() });
1871
+ }
1872
+ function normalizeFolderPath(value) {
1873
+ return value
1874
+ .split("/")
1875
+ .map((segment) => segment.trim())
1876
+ .filter(Boolean)
1877
+ .join("/");
1878
+ }
1879
+ function joinFolderPath(parent, name) {
1880
+ return normalizeFolderPath([parent, name].filter(Boolean).join("/"));
1881
+ }
1882
+ function parentFolderPath(value) {
1883
+ const parts = normalizeFolderPath(value).split("/").filter(Boolean);
1884
+ parts.pop();
1885
+ return parts.join("/");
1886
+ }
1887
+ function folderDisplayName(value) {
1888
+ const parts = normalizeFolderPath(value).split("/").filter(Boolean);
1889
+ return parts.at(-1) || "All files";
1890
+ }
1891
+ function getImmediateFolders(input) {
1892
+ const current = normalizeFolderPath(input.currentFolder);
1893
+ const prefix = current ? `${current}/` : "";
1894
+ const names = new Map();
1895
+ for (const folder of input.folders) {
1896
+ const normalized = normalizeFolderPath(folder);
1897
+ if (!normalized || (current && normalized === current) || !normalized.startsWith(prefix)) {
1898
+ continue;
1899
+ }
1900
+ const rest = normalized.slice(prefix.length);
1901
+ const childName = rest.split("/")[0];
1902
+ if (childName) {
1903
+ names.set(childName, joinFolderPath(current, childName));
1904
+ }
1905
+ }
1906
+ for (const file of input.files) {
1907
+ const normalized = normalizeFolderPath(file.folderPath ?? "");
1908
+ if (!normalized || (current && normalized === current) || !normalized.startsWith(prefix)) {
1909
+ continue;
1910
+ }
1911
+ const rest = normalized.slice(prefix.length);
1912
+ const childName = rest.split("/")[0];
1913
+ if (childName) {
1914
+ names.set(childName, joinFolderPath(current, childName));
1915
+ }
1916
+ }
1917
+ return Array.from(names.entries())
1918
+ .map(([name, path]) => ({ name, path }))
1919
+ .sort((a, b) => a.name.localeCompare(b.name));
1920
+ }
1921
+ function UploadActionIcon(props) {
1922
+ if (props.name === "share") {
1923
+ return (_jsxs("svg", { viewBox: "0 0 24 24", "aria-hidden": "true", focusable: "false", children: [_jsx("path", { d: "M14 4h6v6" }), _jsx("path", { d: "M10 14 20 4" }), _jsx("path", { d: "M20 14v4a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h4" })] }));
1924
+ }
1925
+ if (props.name === "link") {
1926
+ return (_jsxs("svg", { viewBox: "0 0 24 24", "aria-hidden": "true", focusable: "false", children: [_jsx("path", { d: "M10 13a5 5 0 0 0 7.07 0l2.12-2.12a5 5 0 0 0-7.07-7.07L11 4.93" }), _jsx("path", { d: "M14 11a5 5 0 0 0-7.07 0L4.81 13.12a5 5 0 0 0 7.07 7.07L13 19.07" })] }));
1927
+ }
1928
+ return (_jsxs("svg", { viewBox: "0 0 24 24", "aria-hidden": "true", focusable: "false", children: [_jsx("path", { d: "M3 6h18" }), _jsx("path", { d: "M8 6V4h8v2" }), _jsx("path", { d: "M19 6 18 20H6L5 6" }), _jsx("path", { d: "M10 11v5" }), _jsx("path", { d: "M14 11v5" })] }));
1929
+ }
1930
+ function UploadDrawer(props) {
1931
+ const [tab, setTab] = useState("my-files");
1932
+ const [uploadSelected, setUploadSelected] = useState([]);
1933
+ const [myFiles, setMyFiles] = useState([]);
1934
+ const [myFolders, setMyFolders] = useState([]);
1935
+ const [mySelectedIds, setMySelectedIds] = useState(() => new Set());
1936
+ const [recentMedia, setRecentMedia] = useState([]);
1937
+ const [recentTemporaryFiles, setRecentTemporaryFiles] = useState([]);
1938
+ const [recentSelectedIds, setRecentSelectedIds] = useState(() => new Set());
1939
+ const [recentNextCursor, setRecentNextCursor] = useState(null);
1940
+ const [recentLoading, setRecentLoading] = useState(false);
1941
+ const [myFolderPath, setMyFolderPath] = useState("");
1942
+ const [openFolderMenu, setOpenFolderMenu] = useState(null);
1943
+ const [folderDialog, setFolderDialog] = useState(null);
1944
+ const [status, setStatus] = useState(null);
1945
+ const [error, setError] = useState(null);
1946
+ const [confirmDelete, setConfirmDelete] = useState(null);
1947
+ const uploadInputRef = useRef(null);
1948
+ const myInputRef = useRef(null);
1949
+ const recentFiles = useMemo(() => {
1950
+ const generated = recentMedia.map((file) => ({ ...file, recentSource: "generated" }));
1951
+ const temporary = recentTemporaryFiles.map((file) => ({ ...file, recentSource: "temporary" }));
1952
+ return [...temporary, ...generated].sort((a, b) => {
1953
+ const aTime = a.createdAt ? Date.parse(a.createdAt) : 0;
1954
+ const bTime = b.createdAt ? Date.parse(b.createdAt) : 0;
1955
+ return bTime - aTime;
1956
+ });
1957
+ }, [recentMedia, recentTemporaryFiles]);
1958
+ const confirmAttachments = tab === "upload"
1959
+ ? uploadSelected
1960
+ : tab === "my-files"
1961
+ ? myFiles.filter((file) => mySelectedIds.has(file.id))
1962
+ : tab === "recents"
1963
+ ? recentFiles.filter((file) => recentSelectedIds.has(file.id))
1964
+ : [];
1965
+ async function refreshMyFiles() {
1966
+ const next = await listMyFiles(props.apiKey);
1967
+ setMyFiles(next.attachments);
1968
+ setMyFolders(next.folders);
1969
+ }
1970
+ async function refreshRecentMedia(cursor) {
1971
+ setRecentLoading(true);
1972
+ try {
1973
+ const [next, temporary] = cursor
1974
+ ? [await listGeneratedMedia(props.apiKey, cursor), null]
1975
+ : await Promise.all([
1976
+ listGeneratedMedia(props.apiKey),
1977
+ listTemporaryFiles(props.apiKey)
1978
+ ]);
1979
+ setRecentMedia((current) => cursor ? [...current, ...next.media] : next.media);
1980
+ if (temporary) {
1981
+ setRecentTemporaryFiles(temporary.files);
1982
+ }
1983
+ setRecentNextCursor(next.nextCursor);
1984
+ if (!cursor) {
1985
+ setRecentSelectedIds(new Set());
1986
+ }
1987
+ }
1988
+ finally {
1989
+ setRecentLoading(false);
1990
+ }
1991
+ }
1992
+ useEffect(() => {
1993
+ if (!props.open) {
1994
+ return;
1995
+ }
1996
+ setError(null);
1997
+ setStatus(null);
1998
+ void refreshMyFiles().catch((nextError) => setError(nextError instanceof Error ? nextError.message : "Unable to load My Files."));
1999
+ void refreshRecentMedia().catch((nextError) => setError(nextError instanceof Error ? nextError.message : "Unable to load recent media."));
2000
+ }, [props.open]);
2001
+ function resetAndClose() {
2002
+ setUploadSelected([]);
2003
+ setMySelectedIds(new Set());
2004
+ setRecentSelectedIds(new Set());
2005
+ setStatus(null);
2006
+ setError(null);
2007
+ setOpenFolderMenu(null);
2008
+ setFolderDialog(null);
2009
+ props.onClose();
2010
+ }
2011
+ async function copyText(value) {
2012
+ try {
2013
+ if (navigator.clipboard?.writeText) {
2014
+ await navigator.clipboard.writeText(value);
2015
+ }
2016
+ else {
2017
+ const textarea = document.createElement("textarea");
2018
+ textarea.value = value;
2019
+ textarea.style.position = "fixed";
2020
+ textarea.style.opacity = "0";
2021
+ document.body.appendChild(textarea);
2022
+ textarea.focus();
2023
+ textarea.select();
2024
+ document.execCommand("copy");
2025
+ document.body.removeChild(textarea);
2026
+ }
2027
+ setStatus("File URL copied.");
2028
+ }
2029
+ catch {
2030
+ setError("Unable to copy file URL.");
2031
+ }
2032
+ }
2033
+ async function uploadFiles(files, scope) {
2034
+ if (!files.length) {
2035
+ return;
2036
+ }
2037
+ setError(null);
2038
+ const uploaded = [];
2039
+ for (const file of files) {
2040
+ try {
2041
+ setStatus(`Uploading ${file.name}...`);
2042
+ if (scope === "upload") {
2043
+ const temporary = await uploadTemporaryFile({
2044
+ apiKey: props.apiKey,
2045
+ file,
2046
+ folderPath: CHAT_TEMPORARY_UPLOAD_FOLDER
2047
+ });
2048
+ const attachment = await addInlinePreviewForChatUpload({
2049
+ attachment: temporary,
2050
+ file,
2051
+ currentInlineBytes: estimateInlineAttachmentBytes([...uploadSelected, ...uploaded])
2052
+ });
2053
+ uploaded.push(attachment);
2054
+ continue;
2055
+ }
2056
+ const attachment = await uploadAttachment({
2057
+ apiKey: props.apiKey,
2058
+ file,
2059
+ folderPath: myFolderPath,
2060
+ maxBytes: MY_FILES_UPLOAD_LIMIT_BYTES,
2061
+ limitLabel: "50 MB"
2062
+ });
2063
+ uploaded.push(attachment);
2064
+ }
2065
+ catch (nextError) {
2066
+ setError(nextError instanceof Error ? nextError.message : "Upload failed.");
2067
+ }
2068
+ }
2069
+ if (scope === "upload") {
2070
+ setUploadSelected((current) => [...current, ...uploaded]);
2071
+ await refreshRecentMedia().catch((nextError) => setError(nextError instanceof Error ? nextError.message : "Unable to refresh recent files."));
2072
+ }
2073
+ else if (scope === "my-files") {
2074
+ await refreshMyFiles();
2075
+ setMySelectedIds((current) => new Set([...current, ...uploaded.map((file) => file.id)]));
2076
+ }
2077
+ if (uploaded.length) {
2078
+ setStatus(`${uploaded.length} file${uploaded.length === 1 ? "" : "s"} uploaded.`);
2079
+ }
2080
+ }
2081
+ async function deleteFile(file, scope) {
2082
+ const url = scope === "my-files"
2083
+ ? `/api/v1/user/me/attachments/${encodeURIComponent(file.id)}`
2084
+ : `/api/v1/user/me/temporary-files/${encodeURIComponent(file.id)}`;
2085
+ const response = await fetch(url, {
2086
+ method: "DELETE",
2087
+ headers: buildAuthHeaders(props.apiKey, "application/json")
2088
+ });
2089
+ const payload = await response.json().catch(() => ({}));
2090
+ if (!response.ok) {
2091
+ throw new Error(payload.error || "Unable to delete file.");
2092
+ }
2093
+ if (scope === "my-files") {
2094
+ setMyFiles((current) => current.filter((entry) => entry.id !== file.id));
2095
+ setMySelectedIds((current) => {
2096
+ const next = new Set(current);
2097
+ next.delete(file.id);
2098
+ return next;
2099
+ });
2100
+ }
2101
+ setConfirmDelete(null);
2102
+ setStatus("File deleted.");
2103
+ }
2104
+ async function createFolder(scope) {
2105
+ if (scope !== "my-files" || !folderDialog || folderDialog.mode !== "create") {
2106
+ return;
2107
+ }
2108
+ const folderPath = joinFolderPath(folderDialog.baseFolder, folderDialog.value);
2109
+ if (!folderPath) {
2110
+ setError("Choose a folder name.");
2111
+ return;
2112
+ }
2113
+ const response = await fetch("/api/v1/user/me/attachments/folders", {
2114
+ method: "POST",
2115
+ headers: buildAuthHeaders(props.apiKey, "application/json"),
2116
+ body: JSON.stringify({ folder_path: folderPath })
2117
+ });
2118
+ const payload = await response.json().catch(() => ({}));
2119
+ if (!response.ok) {
2120
+ setError(payload.error || "Unable to create folder.");
2121
+ return;
2122
+ }
2123
+ setMyFolders((current) => Array.from(new Set([...current, folderPath])).sort());
2124
+ setFolderDialog(null);
2125
+ setStatus("Folder created.");
2126
+ }
2127
+ async function renameFolder(scope, fromFolder) {
2128
+ if (scope !== "my-files" || !folderDialog || folderDialog.mode !== "rename") {
2129
+ return;
2130
+ }
2131
+ const renamedLeaf = folderDialog.value.trim();
2132
+ const toFolder = joinFolderPath(parentFolderPath(fromFolder), renamedLeaf);
2133
+ if (!toFolder || toFolder === fromFolder) {
2134
+ return;
2135
+ }
2136
+ const response = await fetch("/api/v1/user/me/attachments/folders", {
2137
+ method: "PATCH",
2138
+ headers: buildAuthHeaders(props.apiKey, "application/json"),
2139
+ body: JSON.stringify({ from_folder_path: fromFolder, to_folder_path: toFolder })
2140
+ });
2141
+ const payload = await response.json().catch(() => ({}));
2142
+ if (!response.ok) {
2143
+ setError(payload.error || "Unable to rename folder.");
2144
+ return;
2145
+ }
2146
+ setMyFolderPath((current) => current === fromFolder || current.startsWith(`${fromFolder}/`)
2147
+ ? `${toFolder}${current.slice(fromFolder.length)}`
2148
+ : current);
2149
+ await refreshMyFiles();
2150
+ setOpenFolderMenu(null);
2151
+ setFolderDialog(null);
2152
+ setStatus("Folder renamed.");
2153
+ }
2154
+ async function deleteFolder(scope, folderPath) {
2155
+ if (scope !== "my-files") {
2156
+ return;
2157
+ }
2158
+ const response = await fetch("/api/v1/user/me/attachments/folders", {
2159
+ method: "DELETE",
2160
+ headers: buildAuthHeaders(props.apiKey, "application/json"),
2161
+ body: JSON.stringify({ folder_path: folderPath })
2162
+ });
2163
+ const payload = await response.json().catch(() => ({}));
2164
+ if (!response.ok) {
2165
+ setError(payload.error || "Unable to delete folder.");
2166
+ return;
2167
+ }
2168
+ setMyFolderPath((current) => current === folderPath || current.startsWith(`${folderPath}/`) ? parentFolderPath(folderPath) : current);
2169
+ await refreshMyFiles();
2170
+ setOpenFolderMenu(null);
2171
+ setFolderDialog(null);
2172
+ setStatus("Folder deleted.");
2173
+ }
2174
+ function renderFolderBrowser(scope) {
2175
+ const folders = myFolders;
2176
+ const files = myFiles;
2177
+ const currentFolder = myFolderPath;
2178
+ const setCurrentFolder = setMyFolderPath;
2179
+ const childFolders = getImmediateFolders({ folders, files, currentFolder });
2180
+ const parts = normalizeFolderPath(currentFolder).split("/").filter(Boolean);
2181
+ return (_jsxs("div", { className: "vf-editor-upload-browser", children: [_jsxs("div", { className: "vf-editor-upload-browser-toolbar", children: [_jsxs("div", { className: "vf-editor-upload-breadcrumb", "aria-label": "Current folder", children: [_jsx("button", { type: "button", "data-current": !currentFolder, onClick: () => setCurrentFolder(""), children: "files" }), parts.map((part, index) => {
2182
+ const path = parts.slice(0, index + 1).join("/");
2183
+ return (_jsxs("span", { className: "vf-editor-upload-breadcrumb-segment", children: [_jsx("span", { "aria-hidden": "true", children: "/" }), _jsx("button", { type: "button", "data-current": path === currentFolder, onClick: () => setCurrentFolder(path), children: part })] }, path));
2184
+ })] }), _jsx("button", { type: "button", onClick: () => setFolderDialog({ mode: "create", scope: "my-files", baseFolder: currentFolder, value: "" }), children: "New folder" })] }), childFolders.length ? (_jsx("div", { className: "vf-editor-upload-folder-grid", children: childFolders.map((folder) => (_jsxs("article", { className: "vf-editor-upload-folder-row", children: [_jsxs("button", { type: "button", className: "vf-editor-upload-folder-open", onClick: () => setCurrentFolder(folder.path), children: [_jsx("span", { className: "vf-editor-upload-folder-icon", "aria-hidden": "true" }), _jsx("span", { children: folder.name })] }), _jsx("button", { type: "button", className: "vf-editor-upload-folder-menu-button", "aria-label": `Folder actions for ${folder.name}`, onClick: () => setOpenFolderMenu(openFolderMenu === folder.path ? null : folder.path), children: "..." }), openFolderMenu === folder.path ? (_jsxs("div", { className: "vf-editor-upload-folder-menu", children: [_jsx("button", { type: "button", onClick: () => setFolderDialog({ mode: "rename", scope: "my-files", folderPath: folder.path, value: folderDisplayName(folder.path) }), children: "Rename" }), _jsx("button", { type: "button", "data-danger": "true", onClick: () => setFolderDialog({ mode: "delete", scope: "my-files", folderPath: folder.path, value: "" }), children: "Delete" })] })) : null] }, folder.path))) })) : null] }));
2185
+ }
2186
+ function renderFileCard(file, scope) {
2187
+ const selected = scope === "my-files"
2188
+ ? mySelectedIds.has(file.id)
2189
+ : scope === "recents"
2190
+ ? recentSelectedIds.has(file.id)
2191
+ : false;
2192
+ const copyUrl = "s3Url" in file && file.s3Url ? file.s3Url : file.viewUrl;
2193
+ return (_jsxs("article", { className: "vf-editor-upload-file-card", "data-selected": selected ? "true" : "false", children: [_jsxs("button", { type: "button", className: "vf-editor-upload-file-select", onClick: () => {
2194
+ if (scope === "temporary") {
2195
+ void copyText(copyUrl);
2196
+ return;
2197
+ }
2198
+ const setSelectedIds = scope === "recents" ? setRecentSelectedIds : setMySelectedIds;
2199
+ setSelectedIds((current) => {
2200
+ const next = new Set(current);
2201
+ if (next.has(file.id)) {
2202
+ next.delete(file.id);
2203
+ }
2204
+ else {
2205
+ next.add(file.id);
2206
+ }
2207
+ return next;
2208
+ });
2209
+ }, children: [_jsx(FilePreview, { file: file }), _jsxs("span", { className: "vf-editor-upload-file-main", children: [_jsx("span", { className: "vf-editor-upload-file-name", children: file.fileName }), _jsxs("span", { className: "vf-editor-upload-file-meta", children: [formatBytes(file.sizeBytes), file.folderPath ? ` · ${file.folderPath}` : ""] })] })] }), _jsxs("div", { className: "vf-editor-upload-file-actions", children: [scope === "my-files" || scope === "recents" ? (_jsx("span", { className: "vf-editor-upload-selection-indicator", "data-selected": selected ? "true" : "false", "aria-hidden": "true", children: selected ? "✓" : "" })) : null, _jsx("a", { href: file.viewUrl, target: "_blank", rel: "noreferrer", "aria-label": `View ${file.fileName}`, children: _jsx(UploadActionIcon, { name: "share" }) }), _jsx("button", { type: "button", "aria-label": `Copy URL for ${file.fileName}`, onClick: () => void copyText(copyUrl), children: _jsx(UploadActionIcon, { name: "link" }) }), scope === "recents" ? null : (_jsx("button", { type: "button", "aria-label": `Delete ${file.fileName}`, onClick: () => setConfirmDelete(confirmDelete === file.id ? null : file.id), children: _jsx(UploadActionIcon, { name: "trash" }) }))] }), scope !== "recents" && confirmDelete === file.id ? (_jsxs("div", { className: "vf-editor-upload-popconfirm", children: [_jsx("span", { children: "Delete this file?" }), _jsx("button", { type: "button", onClick: () => setConfirmDelete(null), children: "Cancel" }), _jsx("button", { type: "button", "data-danger": "true", onClick: () => void deleteFile(file, scope), children: "Delete" })] })) : null] }, file.id));
2210
+ }
2211
+ function renderFolderDialog() {
2212
+ if (!folderDialog) {
2213
+ return null;
2214
+ }
2215
+ const title = folderDialog.mode === "create"
2216
+ ? "New folder"
2217
+ : folderDialog.mode === "rename"
2218
+ ? "Rename folder"
2219
+ : "Delete folder";
2220
+ const targetPath = folderDialog.mode === "delete" ? folderDialog.folderPath : "";
2221
+ return (_jsxs("div", { className: "vf-editor-upload-folder-dialog", role: "dialog", "aria-label": title, children: [_jsx("div", { className: "vf-editor-upload-folder-dialog-title", children: title }), folderDialog.mode === "delete" ? (_jsxs("div", { className: "vf-editor-upload-folder-dialog-copy", children: ["Delete ", _jsx("strong", { children: targetPath }), " and all files inside it?"] })) : (_jsx("input", { value: folderDialog.value, autoFocus: true, placeholder: "folder-name", onChange: (event) => setFolderDialog({ ...folderDialog, value: event.target.value }), onKeyDown: (event) => {
2222
+ if (event.key === "Enter") {
2223
+ event.preventDefault();
2224
+ if (folderDialog.mode === "create") {
2225
+ void createFolder(folderDialog.scope);
2226
+ }
2227
+ else {
2228
+ void renameFolder(folderDialog.scope, folderDialog.folderPath);
2229
+ }
2230
+ }
2231
+ } })), _jsxs("div", { className: "vf-editor-upload-folder-dialog-actions", children: [_jsx("button", { type: "button", onClick: () => setFolderDialog(null), children: "Cancel" }), folderDialog.mode === "create" ? (_jsx("button", { type: "button", className: "vf-editor-upload-confirm", onClick: () => void createFolder(folderDialog.scope), children: "Create" })) : folderDialog.mode === "rename" ? (_jsx("button", { type: "button", className: "vf-editor-upload-confirm", onClick: () => void renameFolder(folderDialog.scope, folderDialog.folderPath), children: "Rename" })) : (_jsx("button", { type: "button", "data-danger": "true", onClick: () => void deleteFolder(folderDialog.scope, folderDialog.folderPath), children: "Delete" }))] })] }));
2232
+ }
2233
+ if (!props.open) {
2234
+ return null;
2235
+ }
2236
+ const visibleMyFiles = myFiles.filter((file) => normalizeFolderPath(file.folderPath ?? "") === normalizeFolderPath(myFolderPath));
2237
+ return createPortal(_jsx("div", { className: "vf-editor-upload-drawer-backdrop", children: _jsxs("aside", { className: "vf-editor-upload-drawer", "aria-label": "Upload drawer", children: [_jsxs("div", { className: "vf-editor-upload-header", children: [_jsxs("div", { children: [_jsx("div", { className: "vf-editor-upload-title", children: "Upload" }), _jsx("div", { className: "vf-editor-upload-subtitle", children: "Upload, attach, and copy file URLs." })] }), _jsx("button", { type: "button", className: "vf-editor-upload-close", "aria-label": "Close upload drawer", onClick: resetAndClose, children: "\u00D7" })] }), _jsxs("div", { className: "vf-editor-upload-tabs", role: "tablist", children: [_jsx("button", { type: "button", "data-active": tab === "upload", onClick: () => setTab("upload"), children: "Upload file" }), _jsx("button", { type: "button", "data-active": tab === "my-files", onClick: () => setTab("my-files"), children: "My Files" }), _jsx("button", { type: "button", "data-active": tab === "recents", onClick: () => setTab("recents"), children: "Recents" })] }), _jsxs("div", { className: "vf-editor-upload-body", children: [error ? _jsx("div", { className: "vf-editor-upload-status", "data-tone": "error", children: error }) : null, status ? _jsx("div", { className: "vf-editor-upload-status", children: status }) : null, renderFolderDialog(), tab === "upload" ? (_jsxs(_Fragment, { children: [_jsxs("button", { type: "button", className: "vf-editor-upload-drop", onClick: () => uploadInputRef.current?.click(), children: ["Upload files to chat", _jsx("span", { children: "Stored in Recents and attached after Confirm. Images get compressed inline previews when possible; other files attach by URL. Max 100 MB each." })] }), _jsx("input", { ref: uploadInputRef, type: "file", multiple: true, hidden: true, onChange: (event) => {
2238
+ void uploadFiles(Array.from(event.target.files ?? []), "upload");
2239
+ event.target.value = "";
2240
+ } }), _jsx("div", { className: "vf-editor-upload-list", children: uploadSelected.map((file) => (_jsxs("article", { className: "vf-editor-upload-file-card", "data-selected": "true", children: [_jsx(FilePreview, { file: file }), _jsxs("span", { className: "vf-editor-upload-file-main", children: [_jsx("span", { className: "vf-editor-upload-file-name", children: file.fileName }), _jsx("span", { className: "vf-editor-upload-file-meta", children: formatBytes(file.sizeBytes) })] }), _jsx("button", { type: "button", onClick: () => setUploadSelected((current) => current.filter((entry) => entry.id !== file.id)), children: "\u00D7" })] }, file.id))) })] })) : tab === "my-files" ? (_jsxs(_Fragment, { children: [_jsxs("button", { type: "button", className: "vf-editor-upload-drop", onClick: () => myInputRef.current?.click(), children: ["Add to My Files", _jsxs("span", { children: ["Uploads into ", folderDisplayName(myFolderPath), " and auto-selects. Max 50 MB each."] })] }), _jsx("input", { ref: myInputRef, type: "file", multiple: true, hidden: true, onChange: (event) => {
2241
+ void uploadFiles(Array.from(event.target.files ?? []), "my-files");
2242
+ event.target.value = "";
2243
+ } }), renderFolderBrowser("my-files"), _jsx("div", { className: "vf-editor-upload-list", children: visibleMyFiles.length ? visibleMyFiles.map((file) => renderFileCard(file, "my-files")) : _jsx("div", { className: "vf-editor-upload-empty", children: "No files in this folder." }) })] })) : tab === "recents" ? (_jsxs(_Fragment, { children: [_jsx("div", { className: "vf-editor-upload-list", children: recentFiles.length ? recentFiles.map((file) => renderFileCard(file, "recents")) : (_jsx("div", { className: "vf-editor-upload-empty", children: recentLoading ? "Loading recent files..." : "No recent files yet." })) }), recentNextCursor ? (_jsx("button", { type: "button", className: "vf-editor-upload-more", disabled: recentLoading, onClick: () => void refreshRecentMedia(recentNextCursor).catch((nextError) => setError(nextError instanceof Error ? nextError.message : "Unable to load more recent media.")), children: recentLoading ? "Loading..." : "Load more generated media" })) : null] })) : null] }), _jsxs("div", { className: "vf-editor-upload-footer", children: [_jsx("button", { type: "button", onClick: resetAndClose, children: "Cancel" }), _jsxs("div", { className: "vf-editor-upload-footer-confirm", children: [_jsxs("span", { children: [confirmAttachments.length, " file", confirmAttachments.length === 1 ? "" : "s", " selected"] }), _jsx("button", { type: "button", className: "vf-editor-upload-confirm", disabled: !confirmAttachments.length, onClick: () => {
2244
+ props.onConfirm(confirmAttachments);
2245
+ resetAndClose();
2246
+ }, children: "Confirm" })] })] })] }) }), document.body);
2247
+ }
2248
+ export function TemplateEditorChat({ boot }) {
2249
+ const isBrainstormSurface = boot.template.templateId === "chat-brainstorm";
2250
+ const showPersistentSidebar = isBrainstormSurface && boot.template.persistentHistorySidebar === true;
2251
+ const storageKeys = useMemo(() => makeStorageKeys(boot.template.templateId), [boot.template.templateId]);
2252
+ const chatStorage = useMemo(() => getChatStorage(), []);
2253
+ const fileInputRef = useRef(null);
2254
+ const viewportRef = useRef(null);
2255
+ const shouldStickToChatBottomRef = useRef(true);
2256
+ const forceChatBottomOnNextRenderRef = useRef(false);
2257
+ const previousActiveThreadIdRef = useRef(null);
2258
+ const activeSendRef = useRef(null);
2259
+ const activeJobPollersRef = useRef(new Map());
2260
+ const jobPollGateRef = useRef({ nextAvailableAt: 0 });
2261
+ const threadsRef = useRef([]);
2262
+ const loadedThreadDetailsRef = useRef(new Set());
2263
+ const [threads, setThreads] = useState([]);
2264
+ const [activeThreadId, setActiveThreadId] = useState(null);
2265
+ const [loadingThreadId, setLoadingThreadId] = useState(null);
2266
+ const [openThreadMenu, setOpenThreadMenu] = useState(null);
2267
+ const [draft, setDraft] = useState("");
2268
+ const [pendingAttachments, setPendingAttachments] = useState([]);
2269
+ const [lastError, setLastError] = useState(null);
2270
+ const [sendingThreadId, setSendingThreadId] = useState(null);
2271
+ const [loadingStatus, setLoadingStatus] = useState(null);
2272
+ const [hasHydrated, setHasHydrated] = useState(false);
2273
+ const [isDropTarget, setIsDropTarget] = useState(false);
2274
+ const [isMobileChatOpen, setIsMobileChatOpen] = useState(false);
2275
+ const [showHistoryPanel, setShowHistoryPanel] = useState(false);
2276
+ const [showBranchesPanel, setShowBranchesPanel] = useState(false);
2277
+ const [branches, setBranches] = useState(null);
2278
+ const [branchesLoading, setBranchesLoading] = useState(false);
2279
+ const [branchesError, setBranchesError] = useState(null);
2280
+ const [isUploadDrawerOpen, setIsUploadDrawerOpen] = useState(false);
2281
+ const [tracers, setTracers] = useState(() => {
2282
+ const bridge = typeof window !== "undefined" ? window.__vidfarmEditorTracers : null;
2283
+ if (bridge) {
2284
+ return bridge.get();
2285
+ }
2286
+ if (typeof window !== "undefined") {
2287
+ const fromUrl = readTracersFromLocation();
2288
+ if (fromUrl.length) {
2289
+ return fromUrl;
2290
+ }
2291
+ }
2292
+ return normalizeTracerList(boot.template.tracers || []);
2293
+ });
2294
+ const threadListUrl = useMemo(() => {
2295
+ if (!boot.threadsUrl) {
2296
+ return null;
2297
+ }
2298
+ const url = new URL(boot.threadsUrl, window.location.origin);
2299
+ if (!isBrainstormSurface) {
2300
+ url.searchParams.set("template_id", boot.template.templateId);
2301
+ }
2302
+ url.searchParams.set("include_messages", "false");
2303
+ url.searchParams.set("limit", isBrainstormSurface ? "200" : "50");
2304
+ return url.toString();
2305
+ }, [boot.template.templateId, boot.threadsUrl, isBrainstormSurface]);
2306
+ const threadDetailUrl = (threadId) => (boot.threadsUrl
2307
+ ? new URL(`${boot.threadsUrl.replace(/\/$/, "")}/${encodeURIComponent(threadId)}`, window.location.origin).toString()
2308
+ : null);
2309
+ useEffect(() => {
2310
+ const hydratedThreads = hydrateThreads(chatStorage?.getItem(storageKeys.threads) ?? null, boot.template.templateId);
2311
+ const savedActiveThread = chatStorage?.getItem(storageKeys.activeThread) ?? null;
2312
+ const requestedThreadId = readThreadIdFromLocation();
2313
+ const currentTracers = window.__vidfarmEditorTracers?.get() ?? readTracersFromLocation();
2314
+ const nextHydratedThreads = requestedThreadId && !hydratedThreads.some((thread) => thread.id === requestedThreadId)
2315
+ ? [
2316
+ {
2317
+ id: requestedThreadId,
2318
+ templateId: boot.template.templateId,
2319
+ title: "Loading chat",
2320
+ createdAt: new Date().toISOString(),
2321
+ updatedAt: new Date().toISOString(),
2322
+ lastMessageAt: null,
2323
+ messageCount: 0,
2324
+ tracers: currentTracers,
2325
+ hiddenFromView: false,
2326
+ messages: []
2327
+ },
2328
+ ...hydratedThreads
2329
+ ]
2330
+ : hydratedThreads;
2331
+ const visibleHydratedThreads = nextHydratedThreads.filter((thread) => !thread.hiddenFromView);
2332
+ const newestMatchingThread = visibleHydratedThreads.find((thread) => threadMatchesTracers(thread, currentTracers));
2333
+ threadsRef.current = nextHydratedThreads;
2334
+ setThreads(nextHydratedThreads);
2335
+ if (requestedThreadId
2336
+ && visibleHydratedThreads.some((thread) => thread.id === requestedThreadId)) {
2337
+ setActiveThreadId(requestedThreadId);
2338
+ }
2339
+ else if (savedActiveThread
2340
+ && visibleHydratedThreads.some((thread) => thread.id === savedActiveThread)
2341
+ && (!currentTracers.length || visibleHydratedThreads.some((thread) => thread.id === savedActiveThread && threadMatchesTracers(thread, currentTracers)))) {
2342
+ setActiveThreadId(savedActiveThread);
2343
+ }
2344
+ else {
2345
+ setActiveThreadId(newestMatchingThread?.id ?? visibleHydratedThreads[0]?.id ?? null);
2346
+ }
2347
+ setHasHydrated(true);
2348
+ }, [boot.template.templateId, chatStorage, storageKeys]);
2349
+ useEffect(() => {
2350
+ if (!boot.vidfarmApiKey || !threadListUrl) {
2351
+ return;
2352
+ }
2353
+ const abortController = new AbortController();
2354
+ void (async () => {
2355
+ try {
2356
+ const remoteThreads = [];
2357
+ let nextCursor = null;
2358
+ const seenCursors = new Set();
2359
+ do {
2360
+ const pageUrl = new URL(threadListUrl);
2361
+ if (nextCursor) {
2362
+ pageUrl.searchParams.set("cursor", nextCursor);
2363
+ }
2364
+ const response = await fetch(pageUrl.toString(), {
2365
+ method: "GET",
2366
+ headers: buildAuthHeaders(boot.vidfarmApiKey),
2367
+ signal: abortController.signal
2368
+ });
2369
+ if (!response.ok) {
2370
+ return;
2371
+ }
2372
+ const payload = await response.json().catch(() => null);
2373
+ if (Array.isArray(payload?.threads)) {
2374
+ remoteThreads.push(...payload.threads
2375
+ .map((thread) => hydrateThreadRecord(thread, boot.template.templateId))
2376
+ .filter((thread) => Boolean(thread)));
2377
+ }
2378
+ const candidateCursor = typeof payload?.next_cursor === "string" && payload.next_cursor ? payload.next_cursor : null;
2379
+ if (!candidateCursor || seenCursors.has(candidateCursor)) {
2380
+ nextCursor = null;
2381
+ }
2382
+ else {
2383
+ seenCursors.add(candidateCursor);
2384
+ nextCursor = candidateCursor;
2385
+ }
2386
+ } while (nextCursor && !abortController.signal.aborted);
2387
+ if (!remoteThreads.length) {
2388
+ return;
2389
+ }
2390
+ setThreads((current) => {
2391
+ const nextThreads = mergeThreadSets(current, remoteThreads);
2392
+ threadsRef.current = nextThreads;
2393
+ return nextThreads;
2394
+ });
2395
+ setActiveThreadId((current) => {
2396
+ // Keep whatever the client is showing as long as it still exists
2397
+ // somewhere in the merged set. Otherwise a freshly-created local
2398
+ // "New chat" (not yet persisted to the server) gets swapped out for
2399
+ // the server's most-recent thread on every remote fetch.
2400
+ if (current && threadsRef.current.some((thread) => thread.id === current)) {
2401
+ return current;
2402
+ }
2403
+ return remoteThreads[0]?.id ?? current;
2404
+ });
2405
+ }
2406
+ catch (error) {
2407
+ if (error instanceof DOMException && error.name === "AbortError") {
2408
+ return;
2409
+ }
2410
+ }
2411
+ })();
2412
+ return () => {
2413
+ abortController.abort();
2414
+ };
2415
+ }, [boot.template.templateId, boot.vidfarmApiKey, threadListUrl]);
2416
+ useEffect(() => {
2417
+ threadsRef.current = threads;
2418
+ }, [threads]);
2419
+ const [currentForkId, setCurrentForkId] = useState(() => {
2420
+ if (typeof window === "undefined") {
2421
+ return null;
2422
+ }
2423
+ try {
2424
+ return new URL(window.location.href).searchParams.get("fork");
2425
+ }
2426
+ catch {
2427
+ return null;
2428
+ }
2429
+ });
2430
+ useEffect(() => {
2431
+ const syncForkFromLocation = () => {
2432
+ try {
2433
+ setCurrentForkId(new URL(window.location.href).searchParams.get("fork"));
2434
+ }
2435
+ catch {
2436
+ setCurrentForkId(null);
2437
+ }
2438
+ };
2439
+ window.addEventListener("popstate", syncForkFromLocation);
2440
+ return () => {
2441
+ window.removeEventListener("popstate", syncForkFromLocation);
2442
+ };
2443
+ }, []);
2444
+ async function loadBranches(options) {
2445
+ if (!boot.vidfarmApiKey) {
2446
+ setBranchesError("Sign in to see forks.");
2447
+ return;
2448
+ }
2449
+ if (branchesLoading) {
2450
+ return;
2451
+ }
2452
+ if (!options?.force && branches !== null) {
2453
+ return;
2454
+ }
2455
+ setBranchesLoading(true);
2456
+ setBranchesError(null);
2457
+ try {
2458
+ const response = await fetch("/api/v1/compositions", {
2459
+ method: "GET",
2460
+ headers: buildAuthHeaders(boot.vidfarmApiKey)
2461
+ });
2462
+ if (!response.ok) {
2463
+ setBranchesError(response.status === 401 ? "Sign in to see forks." : `Failed to load forks (${response.status}).`);
2464
+ setBranches([]);
2465
+ return;
2466
+ }
2467
+ const payload = await response.json().catch(() => null);
2468
+ const raw = Array.isArray(payload?.forks) ? payload.forks : [];
2469
+ const summaries = raw
2470
+ .map((entry) => {
2471
+ if (!entry || typeof entry !== "object") {
2472
+ return null;
2473
+ }
2474
+ const record = entry;
2475
+ const forkId = typeof record.fork_id === "string" ? record.fork_id : null;
2476
+ if (!forkId) {
2477
+ return null;
2478
+ }
2479
+ return {
2480
+ forkId,
2481
+ templateId: typeof record.template_id === "string" ? record.template_id : null,
2482
+ parentForkId: typeof record.parent_fork_id === "string" ? record.parent_fork_id : null,
2483
+ parentVersion: typeof record.parent_version === "number" ? record.parent_version : null,
2484
+ title: typeof record.title === "string" ? record.title : null,
2485
+ visibility: typeof record.visibility === "string" ? record.visibility : null,
2486
+ latestVersion: typeof record.latest_version === "number" ? record.latest_version : null,
2487
+ ownerCustomerId: typeof record.owner_customer_id === "string" ? record.owner_customer_id : null,
2488
+ createdAt: typeof record.created_at === "string" ? record.created_at : null,
2489
+ updatedAt: typeof record.updated_at === "string" ? record.updated_at : null
2490
+ };
2491
+ })
2492
+ .filter((entry) => Boolean(entry))
2493
+ .filter((entry) => entry.templateId === boot.template.templateId)
2494
+ .sort((a, b) => String(b.updatedAt ?? "").localeCompare(String(a.updatedAt ?? "")));
2495
+ setBranches(summaries);
2496
+ }
2497
+ catch (error) {
2498
+ setBranchesError("Could not load forks. Please try again.");
2499
+ setBranches([]);
2500
+ }
2501
+ finally {
2502
+ setBranchesLoading(false);
2503
+ }
2504
+ }
2505
+ useEffect(() => {
2506
+ if (showBranchesPanel && branches === null && !branchesLoading) {
2507
+ void loadBranches();
2508
+ }
2509
+ }, [showBranchesPanel]);
2510
+ useEffect(() => {
2511
+ const bridge = window.__vidfarmEditorTracers;
2512
+ if (bridge) {
2513
+ setTracers(bridge.get());
2514
+ return bridge.subscribe((nextTracers) => setTracers(nextTracers));
2515
+ }
2516
+ const syncFromLocation = () => {
2517
+ const fromUrl = readTracersFromLocation();
2518
+ setTracers(fromUrl.length ? fromUrl : normalizeTracerList(boot.template.tracers || []));
2519
+ };
2520
+ syncFromLocation();
2521
+ window.addEventListener("popstate", syncFromLocation);
2522
+ return () => {
2523
+ window.removeEventListener("popstate", syncFromLocation);
2524
+ };
2525
+ }, [boot.template.tracers]);
2526
+ // Debounce localStorage persistence so streamed deltas (fires on every
2527
+ // token) don't JSON.stringify the whole thread bag per keystroke. Reads
2528
+ // the latest state via refs so the closure never captures stale data.
2529
+ const persistThreadsRef = useRef(threads);
2530
+ const persistActiveThreadIdRef = useRef(activeThreadId);
2531
+ useEffect(() => {
2532
+ persistThreadsRef.current = threads;
2533
+ persistActiveThreadIdRef.current = activeThreadId;
2534
+ }, [threads, activeThreadId]);
2535
+ useEffect(() => {
2536
+ if (!hasHydrated) {
2537
+ return;
2538
+ }
2539
+ const timer = window.setTimeout(() => {
2540
+ try {
2541
+ chatStorage?.setItem(storageKeys.threads, JSON.stringify(serializeThreads(persistThreadsRef.current, persistActiveThreadIdRef.current)));
2542
+ }
2543
+ catch (error) {
2544
+ console.warn("Unable to persist editor chat threads.", error);
2545
+ }
2546
+ }, 300);
2547
+ return () => window.clearTimeout(timer);
2548
+ }, [activeThreadId, chatStorage, hasHydrated, storageKeys, threads]);
2549
+ useEffect(() => {
2550
+ if (!hasHydrated) {
2551
+ return;
2552
+ }
2553
+ try {
2554
+ if (activeThreadId) {
2555
+ chatStorage?.setItem(storageKeys.activeThread, activeThreadId);
2556
+ }
2557
+ else {
2558
+ chatStorage?.removeItem(storageKeys.activeThread);
2559
+ }
2560
+ }
2561
+ catch (error) {
2562
+ console.warn("Unable to persist active editor chat thread.", error);
2563
+ }
2564
+ }, [activeThreadId, chatStorage, hasHydrated, storageKeys]);
2565
+ // Keep the URL's thread_id in sync with the client's active thread so a
2566
+ // reload, back-nav, or shared link always resolves to the same conversation
2567
+ // — even for a "New chat" the user hasn't sent a message in yet.
2568
+ useEffect(() => {
2569
+ if (!hasHydrated || typeof window === "undefined") {
2570
+ return;
2571
+ }
2572
+ const url = new URL(window.location.href);
2573
+ const currentThreadParam = url.searchParams.get("thread_id");
2574
+ if (activeThreadId) {
2575
+ if (currentThreadParam === activeThreadId)
2576
+ return;
2577
+ url.searchParams.set("thread_id", activeThreadId);
2578
+ }
2579
+ else {
2580
+ if (!currentThreadParam)
2581
+ return;
2582
+ url.searchParams.delete("thread_id");
2583
+ }
2584
+ window.history.replaceState({}, "", url.toString());
2585
+ }, [activeThreadId, hasHydrated]);
2586
+ const activeThread = useMemo(() => threads.find((thread) => thread.id === activeThreadId) ?? null, [activeThreadId, threads]);
2587
+ const openThreadMenuThread = useMemo(() => threads.find((thread) => thread.id === openThreadMenu?.threadId) ?? null, [openThreadMenu?.threadId, threads]);
2588
+ useEffect(() => {
2589
+ if (!hasHydrated || !boot.vidfarmApiKey || !activeThreadId) {
2590
+ return;
2591
+ }
2592
+ const existingThread = threadsRef.current.find((thread) => thread.id === activeThreadId);
2593
+ if (loadedThreadDetailsRef.current.has(activeThreadId)) {
2594
+ return;
2595
+ }
2596
+ const requestedThreadId = readThreadIdFromLocation();
2597
+ const isUnsentLocalThread = Boolean(existingThread
2598
+ && (existingThread.messageCount ?? 0) === 0
2599
+ && existingThread.messages.length === 0);
2600
+ if (isUnsentLocalThread) {
2601
+ loadedThreadDetailsRef.current.add(activeThreadId);
2602
+ return;
2603
+ }
2604
+ const url = threadDetailUrl(activeThreadId);
2605
+ if (!url) {
2606
+ return;
2607
+ }
2608
+ const abortController = new AbortController();
2609
+ const shouldShowLoadingState = !existingThread?.messages.length;
2610
+ if (shouldShowLoadingState) {
2611
+ setLoadingThreadId(activeThreadId);
2612
+ }
2613
+ void (async () => {
2614
+ try {
2615
+ const response = await fetch(url, {
2616
+ method: "GET",
2617
+ headers: buildAuthHeaders(boot.vidfarmApiKey),
2618
+ signal: abortController.signal
2619
+ });
2620
+ if (!response.ok) {
2621
+ const isLocalOnlyThread = Boolean(existingThread && activeThreadId !== requestedThreadId);
2622
+ const isLikelyUnsentThread = Boolean(existingThread && (existingThread.messageCount ?? 0) === 0 && existingThread.messages.length === 0);
2623
+ if (response.status === 404 && !isLocalOnlyThread && !isLikelyUnsentThread) {
2624
+ setLastError(`Chat ID ${activeThreadId} was not found for this account.`);
2625
+ }
2626
+ loadedThreadDetailsRef.current.add(activeThreadId);
2627
+ return;
2628
+ }
2629
+ const payload = await response.json().catch(() => null);
2630
+ const remoteThread = payload?.thread ? hydrateThreadRecord(payload.thread, boot.template.templateId) : null;
2631
+ if (!remoteThread) {
2632
+ return;
2633
+ }
2634
+ loadedThreadDetailsRef.current.add(remoteThread.id);
2635
+ setLastError(null);
2636
+ setThreads((current) => {
2637
+ const nextThreads = mergeThreadSets(current, [remoteThread]);
2638
+ threadsRef.current = nextThreads;
2639
+ return nextThreads;
2640
+ });
2641
+ }
2642
+ catch (error) {
2643
+ if (error instanceof DOMException && error.name === "AbortError") {
2644
+ return;
2645
+ }
2646
+ }
2647
+ finally {
2648
+ if (!abortController.signal.aborted) {
2649
+ setLoadingThreadId((current) => (current === activeThreadId ? null : current));
2650
+ }
2651
+ }
2652
+ })();
2653
+ return () => {
2654
+ abortController.abort();
2655
+ setLoadingThreadId((current) => (current === activeThreadId ? null : current));
2656
+ };
2657
+ }, [activeThreadId, boot.vidfarmApiKey, boot.threadsUrl, hasHydrated]);
2658
+ useEffect(() => {
2659
+ if (!hasHydrated || activeThread && !activeThread.hiddenFromView) {
2660
+ return;
2661
+ }
2662
+ const newestMatchingThread = threads.find((thread) => !thread.hiddenFromView && threadMatchesTracers(thread, tracers));
2663
+ setActiveThreadId(newestMatchingThread?.id ?? threads.find((thread) => !thread.hiddenFromView)?.id ?? null);
2664
+ }, [activeThread, hasHydrated, threads, tracers]);
2665
+ const requestTracers = useMemo(() => activeThread?.tracers.length ? activeThread.tracers : tracers, [activeThread?.tracers, tracers]);
2666
+ const visibleThreads = useMemo(() => (threads.filter((thread) => !thread.hiddenFromView).sort((a, b) => {
2667
+ const aMatches = threadMatchesTracers(a, tracers);
2668
+ const bMatches = threadMatchesTracers(b, tracers);
2669
+ if (aMatches !== bMatches) {
2670
+ return aMatches ? -1 : 1;
2671
+ }
2672
+ return Date.parse(b.updatedAt) - Date.parse(a.updatedAt);
2673
+ })), [threads, tracers]);
2674
+ const defaultRequestTracer = requestTracers[0] ?? null;
2675
+ const messages = activeThread?.messages ?? [];
2676
+ const isAnyThreadSending = sendingThreadId !== null;
2677
+ const isActiveThreadSending = Boolean(activeThreadId && sendingThreadId === activeThreadId);
2678
+ const canSend = useMemo(() => (draft.trim() || pendingAttachments.length) && !isAnyThreadSending, [draft, pendingAttachments.length, isAnyThreadSending]);
2679
+ useEffect(() => {
2680
+ if (!hasHydrated || !boot.vidfarmApiKey || !activeThread || !activeThread.messages.length) {
2681
+ return;
2682
+ }
2683
+ resumePersistedJobPolling(activeThread);
2684
+ }, [activeThread, boot.vidfarmApiKey, hasHydrated]);
2685
+ async function updateRemoteThreadState(thread, overrides) {
2686
+ if (!boot.vidfarmApiKey || !boot.threadsUrl || !thread.messages.length) {
2687
+ return;
2688
+ }
2689
+ const url = threadDetailUrl(thread.id);
2690
+ if (!url) {
2691
+ return;
2692
+ }
2693
+ await fetch(url, {
2694
+ method: "PATCH",
2695
+ headers: buildAuthHeaders(boot.vidfarmApiKey, "application/json"),
2696
+ body: JSON.stringify({
2697
+ template_id: boot.template.templateId,
2698
+ title: overrides?.title ?? thread.title,
2699
+ tracers: overrides?.tracers ?? thread.tracers,
2700
+ hidden_from_view: overrides?.hiddenFromView ?? (thread.hiddenFromView === true),
2701
+ archived: overrides?.archived
2702
+ })
2703
+ }).catch(() => { });
2704
+ }
2705
+ async function appendRemoteThreadExchange(input) {
2706
+ if (!boot.vidfarmApiKey || !boot.threadsUrl || !input.messages.length) {
2707
+ return;
2708
+ }
2709
+ const url = threadDetailUrl(input.threadId);
2710
+ if (!url) {
2711
+ return;
2712
+ }
2713
+ await fetch(`${url}/exchanges`, {
2714
+ method: "POST",
2715
+ headers: buildAuthHeaders(boot.vidfarmApiKey, "application/json"),
2716
+ body: JSON.stringify({
2717
+ template_id: input.templateId ?? boot.template.templateId,
2718
+ title: input.title || "New chat",
2719
+ tracers: input.tracers,
2720
+ messages: input.messages.map((message) => ({
2721
+ id: message.id,
2722
+ role: message.role,
2723
+ text: message.text,
2724
+ attachments: message.attachments.map((attachment) => ({
2725
+ id: attachment.id,
2726
+ fileName: attachment.fileName,
2727
+ contentType: attachment.contentType,
2728
+ viewUrl: attachment.viewUrl
2729
+ })),
2730
+ toolExchanges: message.toolExchanges,
2731
+ httpExchanges: message.httpExchanges?.map(sanitizeHttpExchange)
2732
+ }))
2733
+ })
2734
+ }).catch(() => { });
2735
+ }
2736
+ async function deleteRemoteThreadState(threadId) {
2737
+ if (!boot.vidfarmApiKey || !boot.threadsUrl) {
2738
+ return;
2739
+ }
2740
+ const url = threadDetailUrl(threadId);
2741
+ if (!url) {
2742
+ return;
2743
+ }
2744
+ await fetch(url, {
2745
+ method: "DELETE",
2746
+ headers: buildAuthHeaders(boot.vidfarmApiKey)
2747
+ }).catch(() => { });
2748
+ }
2749
+ function syncTracerUrl(nextTracers) {
2750
+ const url = new URL(window.location.href);
2751
+ if (nextTracers.length) {
2752
+ url.searchParams.set("tracers", nextTracers.join(","));
2753
+ }
2754
+ else {
2755
+ url.searchParams.delete("tracers");
2756
+ }
2757
+ window.history.replaceState({}, "", url.toString());
2758
+ window.dispatchEvent(new CustomEvent("vidfarm:editor-tracers-changed", {
2759
+ detail: {
2760
+ tracers: nextTracers
2761
+ }
2762
+ }));
2763
+ }
2764
+ function applyTracerList(nextTracers) {
2765
+ const normalized = normalizeTracerList(nextTracers);
2766
+ const bridge = window.__vidfarmEditorTracers;
2767
+ if (bridge) {
2768
+ bridge.set(normalized);
2769
+ return;
2770
+ }
2771
+ setTracers(normalized);
2772
+ syncTracerUrl(normalized);
2773
+ }
2774
+ function createTracer() {
2775
+ const bridge = window.__vidfarmEditorTracers;
2776
+ if (bridge) {
2777
+ return bridge.add();
2778
+ }
2779
+ const tracer = makeTracerId(boot.template.templateSlug, tracers);
2780
+ applyTracerList([tracer, ...tracers]);
2781
+ return tracer;
2782
+ }
2783
+ function addAiTracer(tracer) {
2784
+ const existing = window.__vidfarmEditorTracers?.get() ?? tracers;
2785
+ const normalized = tracer ? ensureTracerHasRandomSuffix(tracer, existing) : null;
2786
+ const bridge = window.__vidfarmEditorTracers;
2787
+ if (bridge) {
2788
+ return bridge.add(normalized ?? undefined);
2789
+ }
2790
+ if (normalized) {
2791
+ applyTracerList([normalized, ...existing.filter((entry) => entry !== normalized)]);
2792
+ return normalized;
2793
+ }
2794
+ return createTracer();
2795
+ }
2796
+ function setDefaultRequestTracerSelection(tracer) {
2797
+ const normalized = tracer.trim();
2798
+ if (!normalized) {
2799
+ return;
2800
+ }
2801
+ const bridge = window.__vidfarmEditorTracers;
2802
+ if (bridge) {
2803
+ bridge.add(normalized);
2804
+ }
2805
+ else if (!tracers.includes(normalized)) {
2806
+ applyTracerList([normalized, ...tracers]);
2807
+ }
2808
+ if (activeThread) {
2809
+ updateThread(activeThread.id, (thread) => ({
2810
+ ...thread,
2811
+ tracers: [normalized, ...thread.tracers.filter((entry) => entry !== normalized)],
2812
+ updatedAt: new Date().toISOString()
2813
+ }));
2814
+ }
2815
+ }
2816
+ function removeTracer(tracer) {
2817
+ const bridge = window.__vidfarmEditorTracers;
2818
+ if (bridge) {
2819
+ bridge.remove(tracer);
2820
+ return;
2821
+ }
2822
+ const remaining = tracers.filter((entry) => entry !== tracer);
2823
+ applyTracerList(remaining);
2824
+ }
2825
+ useEffect(() => {
2826
+ syncTracerUrl(tracers);
2827
+ }, [tracers]);
2828
+ useEffect(() => {
2829
+ const viewport = viewportRef.current;
2830
+ if (!viewport) {
2831
+ return;
2832
+ }
2833
+ const handleScroll = () => {
2834
+ shouldStickToChatBottomRef.current = isScrolledNearBottom(viewport);
2835
+ if (!shouldStickToChatBottomRef.current) {
2836
+ forceChatBottomOnNextRenderRef.current = false;
2837
+ }
2838
+ };
2839
+ handleScroll();
2840
+ viewport.addEventListener("scroll", handleScroll, { passive: true });
2841
+ return () => {
2842
+ viewport.removeEventListener("scroll", handleScroll);
2843
+ };
2844
+ }, []);
2845
+ useLayoutEffect(() => {
2846
+ const viewport = viewportRef.current;
2847
+ if (!viewport) {
2848
+ return;
2849
+ }
2850
+ const activeThreadChanged = previousActiveThreadIdRef.current !== activeThreadId;
2851
+ previousActiveThreadIdRef.current = activeThreadId;
2852
+ if (activeThreadChanged || forceChatBottomOnNextRenderRef.current || shouldStickToChatBottomRef.current) {
2853
+ viewport.scrollTo({ top: viewport.scrollHeight, behavior: "auto" });
2854
+ shouldStickToChatBottomRef.current = true;
2855
+ forceChatBottomOnNextRenderRef.current = false;
2856
+ }
2857
+ }, [messages, activeThreadId]);
2858
+ useEffect(() => {
2859
+ return () => {
2860
+ activeSendRef.current?.abortController.abort();
2861
+ activeSendRef.current = null;
2862
+ for (const poller of activeJobPollersRef.current.values()) {
2863
+ if (poller.timeoutId !== null) {
2864
+ window.clearTimeout(poller.timeoutId);
2865
+ }
2866
+ poller.abortController?.abort();
2867
+ }
2868
+ activeJobPollersRef.current.clear();
2869
+ };
2870
+ }, []);
2871
+ useEffect(() => {
2872
+ if (!openThreadMenu) {
2873
+ return;
2874
+ }
2875
+ const closeMenu = () => setOpenThreadMenu(null);
2876
+ document.addEventListener("click", closeMenu);
2877
+ window.addEventListener("resize", closeMenu);
2878
+ window.addEventListener("scroll", closeMenu, true);
2879
+ return () => {
2880
+ document.removeEventListener("click", closeMenu);
2881
+ window.removeEventListener("resize", closeMenu);
2882
+ window.removeEventListener("scroll", closeMenu, true);
2883
+ };
2884
+ }, [openThreadMenu]);
2885
+ useEffect(() => {
2886
+ const resumePolling = () => {
2887
+ if (document.visibilityState !== "visible" || !navigator.onLine) {
2888
+ return;
2889
+ }
2890
+ const now = Date.now();
2891
+ for (const poller of activeJobPollersRef.current.values()) {
2892
+ if (poller.inFlight) {
2893
+ continue;
2894
+ }
2895
+ if (poller.nextPollAt !== null && poller.nextPollAt <= now && poller.runPoll) {
2896
+ scheduleNextJobPollForPoller(poller, poller.runPoll, poller.nextPollAt - now);
2897
+ }
2898
+ }
2899
+ };
2900
+ window.addEventListener("online", resumePolling);
2901
+ document.addEventListener("visibilitychange", resumePolling);
2902
+ return () => {
2903
+ window.removeEventListener("online", resumePolling);
2904
+ document.removeEventListener("visibilitychange", resumePolling);
2905
+ };
2906
+ }, []);
2907
+ function ensureActiveThread(seedText) {
2908
+ if (activeThread) {
2909
+ return activeThread.id;
2910
+ }
2911
+ const now = new Date().toISOString();
2912
+ const threadId = makeThreadId();
2913
+ const nextThread = {
2914
+ id: threadId,
2915
+ templateId: boot.template.templateId,
2916
+ title: formatThreadTitle(seedText || ""),
2917
+ createdAt: now,
2918
+ updatedAt: now,
2919
+ lastMessageAt: null,
2920
+ messageCount: 0,
2921
+ tracers: normalizeTracerList(tracers),
2922
+ messages: []
2923
+ };
2924
+ threadsRef.current = [nextThread, ...threadsRef.current];
2925
+ setThreads((current) => [nextThread, ...current]);
2926
+ setActiveThreadId(threadId);
2927
+ return threadId;
2928
+ }
2929
+ function createNewThread(options) {
2930
+ const now = new Date().toISOString();
2931
+ const threadTracers = normalizeTracerList(options?.tracers ?? tracers);
2932
+ const threadId = makeThreadId();
2933
+ const nextThread = {
2934
+ id: threadId,
2935
+ templateId: boot.template.templateId,
2936
+ title: options?.title || "New chat",
2937
+ createdAt: now,
2938
+ updatedAt: now,
2939
+ lastMessageAt: null,
2940
+ messageCount: 0,
2941
+ tracers: threadTracers,
2942
+ messages: []
2943
+ };
2944
+ threadsRef.current = [nextThread, ...threadsRef.current];
2945
+ setThreads((current) => [nextThread, ...current]);
2946
+ if (options?.activate !== false) {
2947
+ setActiveThreadId(threadId);
2948
+ setDraft("");
2949
+ setPendingAttachments([]);
2950
+ setLastError(null);
2951
+ }
2952
+ setOpenThreadMenu(null);
2953
+ return threadId;
2954
+ }
2955
+ function createNewThreadFromClick() {
2956
+ createNewThread();
2957
+ setShowHistoryPanel(false);
2958
+ }
2959
+ function deleteThread(threadId) {
2960
+ const thread = threads.find((entry) => entry.id === threadId);
2961
+ if (!thread) {
2962
+ return;
2963
+ }
2964
+ if (!window.confirm(`Delete conversation "${thread.title}"?`)) {
2965
+ return;
2966
+ }
2967
+ const remainingThreads = threads.filter((entry) => entry.id !== threadId);
2968
+ threadsRef.current = threadsRef.current.filter((entry) => entry.id !== threadId);
2969
+ for (const [pollerKey, poller] of activeJobPollersRef.current.entries()) {
2970
+ if (poller.threadId === threadId) {
2971
+ stopJobPoller(pollerKey);
2972
+ }
2973
+ }
2974
+ setThreads(remainingThreads);
2975
+ if (activeThreadId === threadId) {
2976
+ setActiveThreadId(remainingThreads[0]?.id ?? null);
2977
+ }
2978
+ setLastError(null);
2979
+ setOpenThreadMenu(null);
2980
+ void deleteRemoteThreadState(threadId);
2981
+ }
2982
+ function updateThread(threadId, updater) {
2983
+ const nextThreads = sortThreadsByUpdatedAt(threadsRef.current.map((thread) => (thread.id === threadId ? updater(thread) : thread)));
2984
+ threadsRef.current = nextThreads;
2985
+ setThreads(nextThreads);
2986
+ }
2987
+ function attachTracersToThread(threadId, nextTracers) {
2988
+ const normalized = normalizeTracerList(nextTracers);
2989
+ updateThread(threadId, (thread) => ({
2990
+ ...thread,
2991
+ tracers: mergeTracerLists(thread.tracers, normalized),
2992
+ updatedAt: new Date().toISOString()
2993
+ }));
2994
+ const thread = threadsRef.current.find((entry) => entry.id === threadId);
2995
+ if (thread) {
2996
+ void updateRemoteThreadState({
2997
+ ...thread,
2998
+ tracers: mergeTracerLists(thread.tracers, normalized),
2999
+ updatedAt: new Date().toISOString()
3000
+ }, {
3001
+ tracers: mergeTracerLists(thread.tracers, normalized)
3002
+ });
3003
+ }
3004
+ }
3005
+ function findThreadIdForTracer(tracer, exceptThreadId) {
3006
+ const normalized = tracer.trim();
3007
+ if (!normalized) {
3008
+ return null;
3009
+ }
3010
+ return threadsRef.current.find((thread) => (!thread.hiddenFromView && thread.id !== exceptThreadId && thread.tracers.includes(normalized)))?.id ?? null;
3011
+ }
3012
+ function ensureThreadForTracer(tracer, options) {
3013
+ const existing = findThreadIdForTracer(tracer);
3014
+ if (existing) {
3015
+ return existing;
3016
+ }
3017
+ return createNewThread({
3018
+ tracers: [tracer],
3019
+ title: options?.title || `Tracer ${tracer}`,
3020
+ activate: options?.activate ?? false
3021
+ });
3022
+ }
3023
+ function userRequestedSeparateTracerThreads(text) {
3024
+ const normalized = text.toLowerCase();
3025
+ return /(?:own|separate|different|new)\s+(?:tracer\s+and\s+)?chat\s+threads?/.test(normalized)
3026
+ || /chat\s+threads?.{0,80}(?:own|separate|different|new)\s+tracers?/.test(normalized)
3027
+ || /each.{0,80}(?:own|separate|different|new).{0,80}chat\s+threads?/.test(normalized)
3028
+ || /(?:own|separate|different|new)\s+tracers?/.test(normalized)
3029
+ || /(?:each|every|per|one\s+per).{0,80}(?:own|separate|different|new).{0,80}tracers?/.test(normalized)
3030
+ || /(?:own|separate|different|new)\s+tracers?.{0,80}(?:each|every|per|outputs?|pieces?|posts?|jobs?|generations?)/.test(normalized)
3031
+ || /(?:outputs?|pieces?|posts?|jobs?|generations?).{0,80}(?:own|separate|different|new)\s+tracers?/.test(normalized);
3032
+ }
3033
+ function mirrorJobToTracerThread(input) {
3034
+ const existingThreadId = findThreadIdForTracer(input.tracer, input.sourceThreadId);
3035
+ if (!existingThreadId && !input.shouldCreateThread) {
3036
+ return null;
3037
+ }
3038
+ const targetThreadId = existingThreadId ?? ensureThreadForTracer(input.tracer, {
3039
+ title: `Tracer ${input.tracer}`,
3040
+ activate: false
3041
+ });
3042
+ if (!targetThreadId || targetThreadId === input.sourceThreadId) {
3043
+ return null;
3044
+ }
3045
+ const messageId = makeMessageId("assistant");
3046
+ const now = new Date().toISOString();
3047
+ const messageText = input.job.status === "succeeded" || input.job.status === "failed" || input.job.status === "cancelled"
3048
+ ? buildJobCompletionMessage(input.job)
3049
+ : buildJobQueuedMessage(input.job);
3050
+ const statusText = input.job.status === "succeeded" || input.job.status === "failed" || input.job.status === "cancelled"
3051
+ ? null
3052
+ : `Job ${input.job.job_id} is being tracked. ${getJobPollCadenceText()}`;
3053
+ const mirroredMessage = {
3054
+ id: messageId,
3055
+ role: "assistant",
3056
+ text: messageText,
3057
+ attachments: [],
3058
+ httpExchanges: input.httpExchange ? [input.httpExchange] : [],
3059
+ statusText,
3060
+ isPending: false
3061
+ };
3062
+ updateThread(targetThreadId, (thread) => ({
3063
+ ...thread,
3064
+ updatedAt: now,
3065
+ messages: [
3066
+ ...thread.messages,
3067
+ mirroredMessage
3068
+ ]
3069
+ }));
3070
+ const targetThread = threadsRef.current.find((thread) => thread.id === targetThreadId);
3071
+ if (targetThread) {
3072
+ void appendRemoteThreadExchange({
3073
+ threadId: targetThreadId,
3074
+ templateId: targetThread.templateId,
3075
+ title: targetThread.title,
3076
+ tracers: targetThread.tracers,
3077
+ messages: [mirroredMessage]
3078
+ });
3079
+ }
3080
+ return { threadId: targetThreadId, messageId };
3081
+ }
3082
+ function editThreadTracers(threadId) {
3083
+ const thread = threads.find((entry) => entry.id === threadId);
3084
+ if (!thread) {
3085
+ return;
3086
+ }
3087
+ const value = window.prompt("Thread tracers, comma separated", thread.tracers.join(", "));
3088
+ if (value === null) {
3089
+ return;
3090
+ }
3091
+ const nextTracers = normalizeTracerList(value.split(","));
3092
+ updateThread(threadId, (current) => ({
3093
+ ...current,
3094
+ tracers: nextTracers,
3095
+ updatedAt: new Date().toISOString()
3096
+ }));
3097
+ void updateRemoteThreadState(thread, { tracers: nextTracers });
3098
+ setOpenThreadMenu(null);
3099
+ }
3100
+ function archiveThread(threadId) {
3101
+ const thread = threads.find((entry) => entry.id === threadId);
3102
+ if (!thread) {
3103
+ return;
3104
+ }
3105
+ const confirmed = window.confirm(`Archive conversation "${thread.title}"? Its tracer links will be prefixed with archived_.`);
3106
+ if (!confirmed) {
3107
+ return;
3108
+ }
3109
+ const nextTracers = normalizeTracerList(thread.tracers.map(archiveTracerValue));
3110
+ updateThread(threadId, (current) => ({
3111
+ ...current,
3112
+ tracers: nextTracers,
3113
+ updatedAt: new Date().toISOString()
3114
+ }));
3115
+ void updateRemoteThreadState(thread, { tracers: nextTracers, archived: true });
3116
+ setOpenThreadMenu(null);
3117
+ }
3118
+ function removeThreadFromView(threadId) {
3119
+ const thread = threads.find((entry) => entry.id === threadId);
3120
+ if (!thread) {
3121
+ return;
3122
+ }
3123
+ const confirmed = window.confirm(`Remove conversation "${thread.title}" from this frontend view? The thread and tracer links stay available; Delete removes the thread completely.`);
3124
+ if (!confirmed) {
3125
+ return;
3126
+ }
3127
+ for (const [pollerKey, poller] of activeJobPollersRef.current.entries()) {
3128
+ if (poller.threadId === threadId) {
3129
+ stopJobPoller(pollerKey);
3130
+ }
3131
+ }
3132
+ updateThread(threadId, (current) => ({
3133
+ ...current,
3134
+ hiddenFromView: true,
3135
+ updatedAt: new Date().toISOString()
3136
+ }));
3137
+ if (activeThreadId === threadId) {
3138
+ const nextThread = visibleThreads.find((entry) => entry.id !== threadId);
3139
+ setActiveThreadId(nextThread?.id ?? null);
3140
+ }
3141
+ setOpenThreadMenu(null);
3142
+ setLastError(null);
3143
+ void updateRemoteThreadState(thread, { hiddenFromView: true });
3144
+ }
3145
+ async function copyThreadId(threadId) {
3146
+ try {
3147
+ if (navigator.clipboard?.writeText) {
3148
+ await navigator.clipboard.writeText(threadId);
3149
+ }
3150
+ else {
3151
+ const textarea = document.createElement("textarea");
3152
+ textarea.value = threadId;
3153
+ textarea.setAttribute("readonly", "");
3154
+ textarea.style.position = "fixed";
3155
+ textarea.style.opacity = "0";
3156
+ document.body.appendChild(textarea);
3157
+ textarea.select();
3158
+ document.execCommand("copy");
3159
+ document.body.removeChild(textarea);
3160
+ }
3161
+ setLastError(null);
3162
+ }
3163
+ catch {
3164
+ setLastError("Unable to copy chat ID.");
3165
+ }
3166
+ finally {
3167
+ setOpenThreadMenu(null);
3168
+ }
3169
+ }
3170
+ function toggleThreadMenu(threadId, button) {
3171
+ if (openThreadMenu?.threadId === threadId) {
3172
+ setOpenThreadMenu(null);
3173
+ return;
3174
+ }
3175
+ const rect = button.getBoundingClientRect();
3176
+ const menuWidth = 148;
3177
+ setOpenThreadMenu({
3178
+ threadId,
3179
+ top: rect.bottom + 6,
3180
+ left: Math.max(8, Math.min(window.innerWidth - menuWidth - 8, rect.right - menuWidth))
3181
+ });
3182
+ }
3183
+ function stopJobPoller(pollerKey) {
3184
+ const poller = activeJobPollersRef.current.get(pollerKey);
3185
+ if (!poller) {
3186
+ return;
3187
+ }
3188
+ if (poller.timeoutId !== null) {
3189
+ window.clearTimeout(poller.timeoutId);
3190
+ }
3191
+ poller.abortController?.abort();
3192
+ activeJobPollersRef.current.delete(pollerKey);
3193
+ }
3194
+ function scheduleNextJobPoll(pollerKey, runPoll, delayMs) {
3195
+ const poller = activeJobPollersRef.current.get(pollerKey);
3196
+ if (!poller) {
3197
+ return;
3198
+ }
3199
+ scheduleNextJobPollForPoller(poller, runPoll, delayMs);
3200
+ }
3201
+ function scheduleNextJobPollForPoller(poller, runPoll, delayMs) {
3202
+ if (poller.timeoutId !== null) {
3203
+ window.clearTimeout(poller.timeoutId);
3204
+ }
3205
+ const safeDelayMs = Math.max(JOB_POLL_MIN_TIMER_MS, delayMs);
3206
+ poller.nextPollAt = Date.now() + safeDelayMs;
3207
+ poller.timeoutId = window.setTimeout(() => {
3208
+ poller.timeoutId = null;
3209
+ void runPoll();
3210
+ }, safeDelayMs);
3211
+ }
3212
+ function getJobPollIntervalMs() {
3213
+ return activeJobPollersRef.current.size >= JOB_POLL_BUSY_THRESHOLD
3214
+ ? JOB_POLL_BUSY_INTERVAL_MS
3215
+ : JOB_POLL_INTERVAL_MS;
3216
+ }
3217
+ function formatJobPollInterval(ms = getJobPollIntervalMs()) {
3218
+ if (ms >= 60_000) {
3219
+ const minutes = Math.round(ms / 60_000);
3220
+ return `${minutes} min${minutes === 1 ? "" : "s"}`;
3221
+ }
3222
+ return `${Math.round(ms / 1_000)}s`;
3223
+ }
3224
+ function getJobPollCadenceText(ms = getJobPollIntervalMs()) {
3225
+ return activeJobPollersRef.current.size >= JOB_POLL_BUSY_THRESHOLD
3226
+ ? `Many renders are active, so the next check is in ${formatJobPollInterval(ms)}.`
3227
+ : `Checking again gently every ${formatJobPollInterval(ms)}.`;
3228
+ }
3229
+ function rescheduleActiveJobPollersForLoad() {
3230
+ const intervalMs = getJobPollIntervalMs();
3231
+ if (intervalMs === JOB_POLL_INTERVAL_MS) {
3232
+ return;
3233
+ }
3234
+ const now = Date.now();
3235
+ for (const poller of activeJobPollersRef.current.values()) {
3236
+ if (poller.inFlight || !poller.runPoll) {
3237
+ continue;
3238
+ }
3239
+ const remainingMs = poller.nextPollAt === null ? 0 : poller.nextPollAt - now;
3240
+ if (remainingMs < intervalMs) {
3241
+ scheduleNextJobPollForPoller(poller, poller.runPoll, intervalMs);
3242
+ }
3243
+ }
3244
+ }
3245
+ function reserveJobPollSlot(now) {
3246
+ const slotAt = Math.max(now, jobPollGateRef.current.nextAvailableAt);
3247
+ jobPollGateRef.current.nextAvailableAt = slotAt + JOB_POLL_GLOBAL_MIN_GAP_MS;
3248
+ return slotAt - now;
3249
+ }
3250
+ function scheduleJobPolling(input) {
3251
+ const pollerKey = makeJobPollerKey(input.threadId, input.assistantMessageId, input.jobId);
3252
+ if (activeJobPollersRef.current.has(pollerKey)) {
3253
+ return;
3254
+ }
3255
+ activeJobPollersRef.current.set(pollerKey, {
3256
+ timeoutId: null,
3257
+ abortController: null,
3258
+ runPoll: null,
3259
+ inFlight: false,
3260
+ nextPollAt: null,
3261
+ lastPollAt: null,
3262
+ attempts: 0,
3263
+ threadId: input.threadId
3264
+ });
3265
+ const runPoll = async () => {
3266
+ const poller = activeJobPollersRef.current.get(pollerKey);
3267
+ if (!poller) {
3268
+ return;
3269
+ }
3270
+ if (poller.inFlight) {
3271
+ return;
3272
+ }
3273
+ const now = Date.now();
3274
+ const pollIntervalMs = getJobPollIntervalMs();
3275
+ if (poller.lastPollAt !== null && now - poller.lastPollAt < pollIntervalMs) {
3276
+ scheduleNextJobPoll(pollerKey, runPoll, pollIntervalMs - (now - poller.lastPollAt));
3277
+ return;
3278
+ }
3279
+ if (poller.attempts >= JOB_POLL_MAX_ATTEMPTS) {
3280
+ updateThread(input.threadId, (thread) => ({
3281
+ ...thread,
3282
+ updatedAt: new Date().toISOString(),
3283
+ messages: thread.messages.map((message) => (message.id === input.assistantMessageId
3284
+ ? {
3285
+ ...message,
3286
+ statusText: "Background polling stopped after multiple checks. Open the job route when you want to refresh again."
3287
+ }
3288
+ : message))
3289
+ }));
3290
+ stopJobPoller(pollerKey);
3291
+ return;
3292
+ }
3293
+ if (document.visibilityState !== "visible" || !navigator.onLine) {
3294
+ scheduleNextJobPoll(pollerKey, runPoll, pollIntervalMs);
3295
+ return;
3296
+ }
3297
+ const globalDelayMs = reserveJobPollSlot(Date.now());
3298
+ if (globalDelayMs > 0) {
3299
+ scheduleNextJobPoll(pollerKey, runPoll, globalDelayMs);
3300
+ return;
3301
+ }
3302
+ poller.inFlight = true;
3303
+ poller.lastPollAt = now;
3304
+ poller.nextPollAt = null;
3305
+ poller.attempts += 1;
3306
+ poller.abortController = new AbortController();
3307
+ try {
3308
+ updateThread(input.threadId, (thread) => ({
3309
+ ...thread,
3310
+ updatedAt: new Date().toISOString(),
3311
+ messages: thread.messages.map((message) => (message.id === input.assistantMessageId
3312
+ ? {
3313
+ ...message,
3314
+ statusText: `Job ${input.jobId} is being tracked. ${getJobPollCadenceText(pollIntervalMs)}`
3315
+ }
3316
+ : message))
3317
+ }));
3318
+ const url = input.primitiveId || isPrimitiveRestUrl(input.sourceRequestUrl)
3319
+ ? `/api/v1/primitives/jobs/${encodeURIComponent(input.jobId)}`
3320
+ : `/api/v1/templates/${encodeURIComponent(boot.template.templateId)}/jobs/${encodeURIComponent(input.jobId)}`;
3321
+ const response = await fetch(url, {
3322
+ method: "GET",
3323
+ headers: buildAuthHeaders(boot.vidfarmApiKey),
3324
+ signal: poller.abortController.signal
3325
+ });
3326
+ const responseBody = await response.json().catch(() => null);
3327
+ const responseHeaders = Object.fromEntries(response.headers.entries());
3328
+ const payload = tryReadJobStatusPayload(responseBody);
3329
+ updateThread(input.threadId, (thread) => ({
3330
+ ...thread,
3331
+ updatedAt: new Date().toISOString(),
3332
+ messages: thread.messages.map((message) => {
3333
+ if (message.id !== input.assistantMessageId) {
3334
+ return message;
3335
+ }
3336
+ const nextHttpExchanges = upsertAutomaticPollExchange(message.httpExchanges, sanitizeHttpExchange({
3337
+ explanation: `Automatic ${formatJobPollInterval(pollIntervalMs)} job status check for ${input.jobId}`,
3338
+ pollJobId: input.jobId,
3339
+ pollMode: "automatic",
3340
+ request: {
3341
+ method: "GET",
3342
+ url,
3343
+ headers: Object.fromEntries(buildAuthHeaders(boot.vidfarmApiKey).entries())
3344
+ },
3345
+ result: {
3346
+ status: response.status,
3347
+ statusText: response.statusText,
3348
+ headers: responseHeaders,
3349
+ body: responseBody
3350
+ }
3351
+ }));
3352
+ const nextStatusText = payload
3353
+ ? payload.status === "succeeded"
3354
+ ? `Render ${payload.job_id} succeeded.`
3355
+ : payload.status === "failed" || payload.status === "cancelled"
3356
+ ? `Render ${payload.job_id} ${payload.status}.${payload.error?.message ? ` ${payload.error.message}` : ""}`
3357
+ : `Render ${payload.job_id} is ${payload.status}. Next check in ${formatJobPollInterval(pollIntervalMs)}.`
3358
+ : `Render ${input.jobId} checked. Next check in ${formatJobPollInterval(pollIntervalMs)}.`;
3359
+ return {
3360
+ ...message,
3361
+ httpExchanges: nextHttpExchanges,
3362
+ statusText: nextStatusText,
3363
+ text: payload && (payload.status === "succeeded" || payload.status === "failed" || payload.status === "cancelled")
3364
+ ? appendDistinctMessageText(message.text, buildJobCompletionMessage(payload))
3365
+ : message.text
3366
+ };
3367
+ })
3368
+ }));
3369
+ if (payload && (payload.status === "succeeded" || payload.status === "failed" || payload.status === "cancelled")) {
3370
+ stopJobPoller(pollerKey);
3371
+ return;
3372
+ }
3373
+ }
3374
+ catch (error) {
3375
+ if (error instanceof DOMException && error.name === "AbortError") {
3376
+ return;
3377
+ }
3378
+ updateThread(input.threadId, (thread) => ({
3379
+ ...thread,
3380
+ updatedAt: new Date().toISOString(),
3381
+ messages: thread.messages.map((message) => (message.id === input.assistantMessageId
3382
+ ? {
3383
+ ...message,
3384
+ statusText: `Automatic polling paused after an error. It will retry in ${formatJobPollInterval()}.`
3385
+ }
3386
+ : message))
3387
+ }));
3388
+ }
3389
+ finally {
3390
+ const latestPoller = activeJobPollersRef.current.get(pollerKey);
3391
+ if (!latestPoller) {
3392
+ return;
3393
+ }
3394
+ latestPoller.inFlight = false;
3395
+ latestPoller.abortController = null;
3396
+ if (latestPoller.attempts < JOB_POLL_MAX_ATTEMPTS
3397
+ && latestPoller.timeoutId === null
3398
+ && activeJobPollersRef.current.has(pollerKey)) {
3399
+ scheduleNextJobPoll(pollerKey, runPoll, getJobPollIntervalMs());
3400
+ }
3401
+ }
3402
+ };
3403
+ const initialPoller = activeJobPollersRef.current.get(pollerKey);
3404
+ if (initialPoller) {
3405
+ initialPoller.runPoll = runPoll;
3406
+ }
3407
+ const poller = activeJobPollersRef.current.get(pollerKey);
3408
+ if (poller) {
3409
+ scheduleNextJobPoll(pollerKey, runPoll, input.initialDelayMs ?? getJobPollIntervalMs());
3410
+ rescheduleActiveJobPollersForLoad();
3411
+ }
3412
+ }
3413
+ function resumePersistedJobPolling(thread) {
3414
+ for (const message of thread.messages) {
3415
+ if (message.role !== "assistant" || !message.httpExchanges?.length) {
3416
+ continue;
3417
+ }
3418
+ for (const exchange of message.httpExchanges) {
3419
+ if (exchange.request.method !== "POST") {
3420
+ continue;
3421
+ }
3422
+ const payload = tryReadJobStatusPayload(exchange.result.body);
3423
+ const pollerKey = makeJobPollerKey(thread.id, message.id, payload?.job_id ?? "");
3424
+ if (!payload || isTerminalJobStatus(payload.status) || activeJobPollersRef.current.has(pollerKey)) {
3425
+ continue;
3426
+ }
3427
+ const hasTerminalFollowup = message.httpExchanges.some((candidate) => {
3428
+ const candidatePayload = tryReadJobStatusPayload(candidate.result.body);
3429
+ return candidatePayload?.job_id === payload.job_id && isTerminalJobStatus(candidatePayload.status);
3430
+ });
3431
+ if (hasTerminalFollowup) {
3432
+ continue;
3433
+ }
3434
+ scheduleJobPolling({
3435
+ threadId: thread.id,
3436
+ assistantMessageId: message.id,
3437
+ jobId: payload.job_id,
3438
+ tracer: payload.tracer,
3439
+ initialStatus: payload.status,
3440
+ primitiveId: payload.primitive_id,
3441
+ sourceRequestUrl: exchange.request.url,
3442
+ initialDelayMs: JOB_POLL_MIN_TIMER_MS
3443
+ });
3444
+ }
3445
+ }
3446
+ }
3447
+ async function appendFiles(files) {
3448
+ if (!files.length) {
3449
+ return;
3450
+ }
3451
+ setLastError(null);
3452
+ const uploadedAttachments = [];
3453
+ let lastUploadError = null;
3454
+ for (const file of files) {
3455
+ try {
3456
+ const uploaded = await uploadTemporaryFile({
3457
+ apiKey: boot.vidfarmApiKey,
3458
+ file,
3459
+ folderPath: CHAT_TEMPORARY_UPLOAD_FOLDER
3460
+ });
3461
+ const attachment = await addInlinePreviewForChatUpload({
3462
+ attachment: uploaded,
3463
+ file,
3464
+ currentInlineBytes: estimateInlineAttachmentBytes([...pendingAttachments, ...uploadedAttachments])
3465
+ });
3466
+ uploadedAttachments.push(attachment);
3467
+ }
3468
+ catch (error) {
3469
+ lastUploadError = error instanceof Error ? error.message : "Unable to upload attachment.";
3470
+ }
3471
+ }
3472
+ if (lastUploadError && uploadedAttachments.length === 0) {
3473
+ setLastError(lastUploadError);
3474
+ }
3475
+ if (uploadedAttachments.length) {
3476
+ setPendingAttachments((current) => [...current, ...uploadedAttachments]);
3477
+ if (lastUploadError) {
3478
+ setLastError(lastUploadError);
3479
+ }
3480
+ }
3481
+ }
3482
+ async function handlePickFiles(event) {
3483
+ const files = Array.from(event.target.files ?? []);
3484
+ await appendFiles(files);
3485
+ event.target.value = "";
3486
+ }
3487
+ async function handleComposerPaste(event) {
3488
+ const files = Array.from(event.clipboardData.files ?? []).filter((file) => file.size > 0);
3489
+ if (!files.length) {
3490
+ return;
3491
+ }
3492
+ event.preventDefault();
3493
+ await appendFiles(files);
3494
+ }
3495
+ async function handleComposerDrop(event) {
3496
+ event.preventDefault();
3497
+ setIsDropTarget(false);
3498
+ const files = Array.from(event.dataTransfer.files ?? []).filter((file) => file.size > 0);
3499
+ if (!files.length) {
3500
+ return;
3501
+ }
3502
+ await appendFiles(files);
3503
+ }
3504
+ async function handleSend() {
3505
+ if (!canSend) {
3506
+ return;
3507
+ }
3508
+ forceChatBottomOnNextRenderRef.current = true;
3509
+ const threadId = ensureActiveThread(draft);
3510
+ const now = new Date().toISOString();
3511
+ const threadForSend = threadsRef.current.find((thread) => thread.id === threadId);
3512
+ const userMessage = {
3513
+ id: makeMessageId("user"),
3514
+ role: "user",
3515
+ text: draft.trim(),
3516
+ attachments: pendingAttachments
3517
+ };
3518
+ const assistantMessage = {
3519
+ id: makeMessageId("assistant"),
3520
+ role: "assistant",
3521
+ text: "",
3522
+ attachments: [],
3523
+ statusText: "Waiting for AI response…",
3524
+ isPending: true
3525
+ };
3526
+ const existingMessages = threadForSend?.messages ?? [];
3527
+ const historyForTransport = compactTransportHistory([...existingMessages, userMessage]);
3528
+ updateThread(threadId, (thread) => ({
3529
+ ...thread,
3530
+ title: thread.messages.length === 0 ? formatThreadTitle(userMessage.text) : thread.title,
3531
+ updatedAt: now,
3532
+ messages: [...thread.messages, userMessage, assistantMessage]
3533
+ }));
3534
+ setDraft("");
3535
+ setPendingAttachments([]);
3536
+ setLastError(null);
3537
+ setSendingThreadId(threadId);
3538
+ setLoadingStatus("Waiting for AI response…");
3539
+ const abortController = new AbortController();
3540
+ activeSendRef.current = {
3541
+ abortController,
3542
+ threadId,
3543
+ assistantMessageId: assistantMessage.id
3544
+ };
3545
+ try {
3546
+ const requestBody = JSON.stringify({
3547
+ messages: toTransportMessages(historyForTransport),
3548
+ thread_id: threadId,
3549
+ thread_template_id: threadForSend?.templateId ?? boot.template.templateId,
3550
+ thread_title: existingMessages.length === 0 ? formatThreadTitle(userMessage.text) : (threadForSend?.title ?? formatThreadTitle(userMessage.text)),
3551
+ thread_tracers: requestTracers,
3552
+ client_context: buildBrowserDateContext(),
3553
+ user_message: {
3554
+ id: userMessage.id,
3555
+ role: userMessage.role,
3556
+ text: userMessage.text,
3557
+ attachments: userMessage.attachments.map((attachment) => ({
3558
+ id: attachment.id,
3559
+ fileName: attachment.fileName,
3560
+ contentType: attachment.contentType,
3561
+ viewUrl: attachment.viewUrl
3562
+ }))
3563
+ },
3564
+ assistant_message: {
3565
+ id: assistantMessage.id
3566
+ },
3567
+ template: {
3568
+ ...boot.template,
3569
+ tracers: requestTracers,
3570
+ defaultRequestTracer,
3571
+ activeTracer: defaultRequestTracer
3572
+ },
3573
+ cached_context: {
3574
+ ...boot.cachedContext,
3575
+ ...readCachedScheduleChannels(),
3576
+ ...readSelectedLibraryPosts()
3577
+ }
3578
+ });
3579
+ debugLog("chat.send.attempt", {
3580
+ thread_id: threadId,
3581
+ template_id: boot.template.templateId,
3582
+ message_count: historyForTransport.length,
3583
+ attachment_count: userMessage.attachments.length,
3584
+ text_length: userMessage.text.length
3585
+ });
3586
+ const sendStartedAt = Date.now();
3587
+ const response = await fetch(boot.apiUrl || "/api/v1/editor-chat", {
3588
+ method: "POST",
3589
+ headers: buildAuthHeaders(boot.vidfarmApiKey, "application/json"),
3590
+ signal: abortController.signal,
3591
+ body: requestBody
3592
+ });
3593
+ if (!response.ok) {
3594
+ const payload = await response.json().catch(() => ({}));
3595
+ debugError("chat.send.rejected", {
3596
+ thread_id: threadId,
3597
+ status: response.status,
3598
+ error: typeof payload?.error === "string" ? payload.error : null
3599
+ });
3600
+ throw new Error(typeof payload?.error === "string" ? payload.error : `Chat failed with status ${response.status}.`);
3601
+ }
3602
+ debugLog("chat.send.stream_open", {
3603
+ thread_id: threadId,
3604
+ latency_ms: Date.now() - sendStartedAt
3605
+ });
3606
+ const pendingToolCalls = new Map();
3607
+ const shouldSplitJobsIntoThreads = userRequestedSeparateTracerThreads(userMessage.text);
3608
+ await readStreamText(response, {
3609
+ onDelta: (chunk) => {
3610
+ setLoadingStatus("AI is responding…");
3611
+ updateThread(threadId, (thread) => ({
3612
+ ...thread,
3613
+ updatedAt: new Date().toISOString(),
3614
+ messages: thread.messages.map((message) => (message.id === assistantMessage.id
3615
+ ? {
3616
+ ...message,
3617
+ text: `${message.text}${chunk}`,
3618
+ statusText: "AI is responding…",
3619
+ isPending: false
3620
+ }
3621
+ : message))
3622
+ }));
3623
+ },
3624
+ onStatus: (status) => {
3625
+ setLoadingStatus(status);
3626
+ updateThread(threadId, (thread) => ({
3627
+ ...thread,
3628
+ messages: thread.messages.map((message) => (message.id === assistantMessage.id
3629
+ ? {
3630
+ ...message,
3631
+ statusText: status,
3632
+ isPending: !message.text
3633
+ }
3634
+ : message))
3635
+ }));
3636
+ },
3637
+ onToolCall: (toolCall) => {
3638
+ pendingToolCalls.set(toolCall.toolCallId, {
3639
+ toolName: toolCall.toolName,
3640
+ args: toolCall.args
3641
+ });
3642
+ updateThread(threadId, (thread) => ({
3643
+ ...thread,
3644
+ updatedAt: new Date().toISOString(),
3645
+ messages: thread.messages.map((message) => (message.id === assistantMessage.id
3646
+ ? {
3647
+ ...message,
3648
+ toolExchanges: [
3649
+ ...(message.toolExchanges ?? []),
3650
+ {
3651
+ toolCallId: toolCall.toolCallId,
3652
+ toolName: toolCall.toolName,
3653
+ args: sanitizeChatPayload(toolCall.args),
3654
+ status: "pending"
3655
+ }
3656
+ ]
3657
+ }
3658
+ : message))
3659
+ }));
3660
+ },
3661
+ onToolResult: (toolResult) => {
3662
+ const sanitizedToolResult = sanitizeToolResult(toolResult.result);
3663
+ const pendingCall = pendingToolCalls.get(toolResult.toolCallId);
3664
+ if (toolResult.toolName === "frontend_action") {
3665
+ const actionResult = toolResult.result;
3666
+ if (actionResult?.action === "add_tracer") {
3667
+ addAiTracer(actionResult.tracer ?? undefined);
3668
+ }
3669
+ else if (actionResult?.action === "remove_tracer" && actionResult.tracer) {
3670
+ removeTracer(actionResult.tracer);
3671
+ }
3672
+ else if (actionResult?.action === "activate_tracer" && actionResult.tracer) {
3673
+ setDefaultRequestTracerSelection(actionResult.tracer);
3674
+ }
3675
+ else if (actionResult?.action === "create_thread") {
3676
+ const threadTracer = actionResult.tracer ? addAiTracer(actionResult.tracer) : createTracer();
3677
+ createNewThread({
3678
+ tracers: threadTracer ? [threadTracer] : [],
3679
+ title: threadTracer ? `Tracer ${threadTracer}` : "New chat",
3680
+ activate: false
3681
+ });
3682
+ }
3683
+ }
3684
+ const toolResultRecordForMirror = toolResult.result;
3685
+ const jobStatusPayloadForMirror = tryReadJobStatusPayload(toolResultRecordForMirror?.response?.body);
3686
+ const mirrorHttpExchange = toolResultRecordForMirror?.request && toolResultRecordForMirror?.response
3687
+ ? sanitizeHttpExchange({
3688
+ explanation: toolResultRecordForMirror.explanation ?? null,
3689
+ request: toolResultRecordForMirror.request,
3690
+ result: toolResultRecordForMirror.response
3691
+ })
3692
+ : undefined;
3693
+ const mirroredJobTarget = toolResultRecordForMirror?.request?.method === "POST" && jobStatusPayloadForMirror?.tracer
3694
+ ? mirrorJobToTracerThread({
3695
+ sourceThreadId: threadId,
3696
+ tracer: jobStatusPayloadForMirror.tracer,
3697
+ job: jobStatusPayloadForMirror,
3698
+ httpExchange: mirrorHttpExchange,
3699
+ shouldCreateThread: shouldSplitJobsIntoThreads
3700
+ })
3701
+ : null;
3702
+ if (mirroredJobTarget
3703
+ && jobStatusPayloadForMirror
3704
+ && !isTerminalJobStatus(jobStatusPayloadForMirror.status)) {
3705
+ scheduleJobPolling({
3706
+ threadId: mirroredJobTarget.threadId,
3707
+ assistantMessageId: mirroredJobTarget.messageId,
3708
+ jobId: jobStatusPayloadForMirror.job_id,
3709
+ tracer: jobStatusPayloadForMirror.tracer,
3710
+ initialStatus: jobStatusPayloadForMirror.status,
3711
+ primitiveId: jobStatusPayloadForMirror.primitive_id,
3712
+ sourceRequestUrl: toolResultRecordForMirror?.request?.url
3713
+ });
3714
+ }
3715
+ updateThread(threadId, (thread) => ({
3716
+ ...thread,
3717
+ updatedAt: new Date().toISOString(),
3718
+ messages: thread.messages.map((message) => {
3719
+ if (message.id !== assistantMessage.id) {
3720
+ return message;
3721
+ }
3722
+ const nextToolExchanges = (message.toolExchanges ?? []).map((exchange) => (exchange.toolCallId === toolResult.toolCallId
3723
+ ? {
3724
+ ...exchange,
3725
+ result: sanitizedToolResult,
3726
+ status: (typeof toolResult.result?.error === "string" ? "error" : "complete")
3727
+ }
3728
+ : exchange));
3729
+ const toolResultRecord = toolResult.result;
3730
+ const jobStatusPayload = tryReadJobStatusPayload(toolResultRecord?.response?.body);
3731
+ const nextHttpExchanges = toolResultRecord?.request && toolResultRecord?.response
3732
+ ? [
3733
+ ...(message.httpExchanges ?? []),
3734
+ sanitizeHttpExchange({
3735
+ explanation: toolResultRecord.explanation ?? null,
3736
+ request: toolResultRecord.request,
3737
+ result: toolResultRecord.response
3738
+ })
3739
+ ]
3740
+ : message.httpExchanges;
3741
+ if (pendingCall) {
3742
+ pendingToolCalls.delete(toolResult.toolCallId);
3743
+ }
3744
+ if (toolResultRecord?.request?.method === "POST" && jobStatusPayload?.tracer) {
3745
+ const bridge = window.__vidfarmEditorTracers;
3746
+ if (bridge) {
3747
+ bridge.add(jobStatusPayload.tracer);
3748
+ }
3749
+ else {
3750
+ applyTracerList([jobStatusPayload.tracer, ...tracers.filter((entry) => entry !== jobStatusPayload.tracer)]);
3751
+ }
3752
+ attachTracersToThread(threadId, [jobStatusPayload.tracer]);
3753
+ }
3754
+ if (jobStatusPayload
3755
+ && !mirroredJobTarget
3756
+ && !isTerminalJobStatus(jobStatusPayload.status)) {
3757
+ scheduleJobPolling({
3758
+ threadId,
3759
+ assistantMessageId: assistantMessage.id,
3760
+ jobId: jobStatusPayload.job_id,
3761
+ tracer: jobStatusPayload.tracer,
3762
+ initialStatus: jobStatusPayload.status,
3763
+ primitiveId: jobStatusPayload.primitive_id,
3764
+ sourceRequestUrl: toolResultRecord?.request?.url
3765
+ });
3766
+ }
3767
+ return {
3768
+ ...message,
3769
+ toolExchanges: nextToolExchanges,
3770
+ httpExchanges: nextHttpExchanges,
3771
+ statusText: jobStatusPayload
3772
+ && !isTerminalJobStatus(jobStatusPayload.status)
3773
+ ? `Job ${jobStatusPayload.job_id} is being tracked. ${getJobPollCadenceText()}`
3774
+ : message.statusText,
3775
+ isPending: false,
3776
+ text: jobStatusPayload && toolResultRecord?.request?.method === "POST" && toolResultRecord?.response?.status === 202
3777
+ ? appendDistinctMessageText(message.text, buildJobQueuedMessage(jobStatusPayload))
3778
+ : jobStatusPayload && isTerminalJobStatus(jobStatusPayload.status)
3779
+ ? appendDistinctMessageText(message.text, buildJobCompletionMessage(jobStatusPayload))
3780
+ : jobStatusPayload
3781
+ ? appendDistinctMessageText(message.text, buildJobCompletionMessage(jobStatusPayload))
3782
+ : message.text
3783
+ };
3784
+ })
3785
+ }));
3786
+ },
3787
+ onError: (errorMessage) => {
3788
+ debugError("chat.stream.error", { thread_id: threadId, message: errorMessage });
3789
+ setLastError(errorMessage);
3790
+ setLoadingStatus(null);
3791
+ updateThread(threadId, (thread) => ({
3792
+ ...thread,
3793
+ messages: thread.messages.filter((message) => message.id !== assistantMessage.id)
3794
+ }));
3795
+ }
3796
+ });
3797
+ debugLog("chat.stream.completed", {
3798
+ thread_id: threadId,
3799
+ duration_ms: Date.now() - sendStartedAt
3800
+ });
3801
+ updateThread(threadId, (thread) => ({
3802
+ ...thread,
3803
+ updatedAt: new Date().toISOString(),
3804
+ messages: thread.messages.map((message) => (message.id === assistantMessage.id
3805
+ ? {
3806
+ ...message,
3807
+ statusText: message.statusText?.includes("being tracked") ? message.statusText : null,
3808
+ isPending: false
3809
+ }
3810
+ : message))
3811
+ }));
3812
+ }
3813
+ catch (error) {
3814
+ if (error instanceof DOMException && error.name === "AbortError") {
3815
+ debugWarn("chat.send.aborted", { thread_id: threadId });
3816
+ setLastError(null);
3817
+ setLoadingStatus(null);
3818
+ updateThread(threadId, (thread) => ({
3819
+ ...thread,
3820
+ updatedAt: new Date().toISOString(),
3821
+ messages: thread.messages.map((message) => {
3822
+ if (message.id !== assistantMessage.id) {
3823
+ return message;
3824
+ }
3825
+ const hasContent = Boolean(message.text.trim()
3826
+ || message.toolExchanges?.length
3827
+ || message.httpExchanges?.length);
3828
+ return hasContent
3829
+ ? {
3830
+ ...message,
3831
+ statusText: "Stopped.",
3832
+ isPending: false
3833
+ }
3834
+ : {
3835
+ ...message,
3836
+ text: "Stopped.",
3837
+ statusText: null,
3838
+ isPending: false
3839
+ };
3840
+ })
3841
+ }));
3842
+ return;
3843
+ }
3844
+ debugError("chat.send.failed", {
3845
+ thread_id: threadId,
3846
+ message: error instanceof Error ? error.message : String(error)
3847
+ });
3848
+ setLastError(error instanceof Error ? error.message : "Unable to send chat message.");
3849
+ setLoadingStatus(null);
3850
+ updateThread(threadId, (thread) => ({
3851
+ ...thread,
3852
+ messages: thread.messages.map((message) => (message.id === assistantMessage.id
3853
+ ? {
3854
+ ...message,
3855
+ text: error instanceof Error ? `Error: ${error.message}` : "Error: Unable to send chat message.",
3856
+ statusText: null,
3857
+ isPending: false
3858
+ }
3859
+ : message))
3860
+ }));
3861
+ }
3862
+ finally {
3863
+ if (activeSendRef.current?.assistantMessageId === assistantMessage.id) {
3864
+ activeSendRef.current = null;
3865
+ }
3866
+ setSendingThreadId((current) => (current === threadId ? null : current));
3867
+ setLoadingStatus(null);
3868
+ }
3869
+ }
3870
+ function stopActiveSend() {
3871
+ setLoadingStatus("Stopping…");
3872
+ const activeSend = activeSendRef.current;
3873
+ if (activeSend) {
3874
+ updateThread(activeSend.threadId, (thread) => ({
3875
+ ...thread,
3876
+ updatedAt: new Date().toISOString(),
3877
+ messages: thread.messages.map((message) => (message.id === activeSend.assistantMessageId
3878
+ ? {
3879
+ ...message,
3880
+ statusText: "Stopping…"
3881
+ }
3882
+ : message))
3883
+ }));
3884
+ }
3885
+ activeSendRef.current?.abortController.abort();
3886
+ }
3887
+ useEffect(() => {
3888
+ return () => {
3889
+ activeSendRef.current?.abortController.abort();
3890
+ for (const pollerKey of activeJobPollersRef.current.keys()) {
3891
+ stopJobPoller(pollerKey);
3892
+ }
3893
+ // Flush the debounced persistence so a mid-stream unmount does not
3894
+ // lose the last few deltas before the 300ms timer fires.
3895
+ try {
3896
+ chatStorage?.setItem(storageKeys.threads, JSON.stringify(serializeThreads(persistThreadsRef.current, persistActiveThreadIdRef.current)));
3897
+ }
3898
+ catch { }
3899
+ };
3900
+ }, [chatStorage, storageKeys]);
3901
+ function handleComposerKeyDown(event) {
3902
+ if (event.key === "Enter" && !event.shiftKey) {
3903
+ event.preventDefault();
3904
+ void handleSend();
3905
+ }
3906
+ }
3907
+ const threadListContent = (_jsx("div", { className: `vf-editor-chat-thread-list${isBrainstormSurface ? " vf-editor-chat-thread-list-sidebar" : ""}`, role: "tablist", "aria-label": "Conversation threads", children: visibleThreads.length ? visibleThreads.map((thread) => (_jsxs("div", { role: "tab", "aria-selected": thread.id === activeThreadId, className: "vf-editor-chat-thread-chip", "data-active": thread.id === activeThreadId ? "true" : "false", children: [_jsxs("button", { type: "button", className: "vf-editor-chat-thread-chip-main", onClick: () => {
3908
+ setActiveThreadId(thread.id);
3909
+ setLastError(null);
3910
+ setOpenThreadMenu(null);
3911
+ setShowHistoryPanel(false);
3912
+ if (isBrainstormSurface) {
3913
+ setIsMobileChatOpen(false);
3914
+ }
3915
+ }, children: [_jsx("span", { className: "vf-editor-chat-thread-chip-title", children: thread.title }), thread.tracers.length ? (_jsxs("span", { className: "vf-editor-chat-thread-chip-tracers", children: [thread.tracers.slice(0, 2).join(", "), thread.tracers.length > 2 ? ` +${thread.tracers.length - 2}` : ""] })) : null] }), _jsx("div", { className: "vf-editor-chat-thread-menu-wrap", children: _jsx("button", { type: "button", className: "vf-editor-chat-thread-chip-menu", "aria-label": `Actions for ${thread.title}`, onClick: (event) => {
3916
+ event.stopPropagation();
3917
+ toggleThreadMenu(thread.id, event.currentTarget);
3918
+ }, children: "..." }) })] }, thread.id))) : (_jsx("div", { className: "vf-editor-chat-thread-empty", children: "Start a chat to create your first conversation thread." })) }));
3919
+ return (_jsxs("div", { className: "vf-editor-chat-shell", "data-mobile-open": isMobileChatOpen ? "true" : "false", "data-layout": isBrainstormSurface ? "brainstorm" : "default", children: [_jsx("button", { type: "button", className: "vf-editor-chat-mobile-fab", "aria-label": "Open chat", "aria-expanded": isMobileChatOpen, onClick: () => setIsMobileChatOpen(true), children: _jsx("svg", { viewBox: "0 0 24 24", "aria-hidden": "true", focusable: "false", children: _jsx("path", { d: "M20 11.5a7.5 7.5 0 0 1-9.8 7.1L4 20l1.5-5.3A7.5 7.5 0 1 1 20 11.5Z" }) }) }), lastError ? (_jsx("div", { className: "vf-editor-chat-error", role: "status", children: lastError })) : null, _jsxs("div", { className: `${isBrainstormSurface ? "vf-editor-chat-body vf-editor-chat-brainstorm-layout" : "vf-editor-chat-body"}${showPersistentSidebar ? " has-sidebar" : ""}`, "data-history-open": showHistoryPanel || showBranchesPanel ? "true" : "false", children: [showPersistentSidebar ? (_jsxs("aside", { className: "vf-editor-chat-history-pane", children: [_jsx("div", { className: "vf-editor-chat-history-header", children: _jsxs("div", { className: "vf-editor-chat-history-header-main", children: [_jsxs("a", { className: "vf-editor-chat-back-button", href: "/discover", "aria-label": "Back to Discover", children: [_jsx("svg", { viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: "1.9", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: _jsx("path", { d: "m12 4-6 6 6 6" }) }), "Discover"] }), _jsx("div", { className: "vf-editor-chat-history-kicker", children: "Chat history" })] }) }), _jsx("button", { type: "button", className: "vf-editor-chat-new-thread vf-editor-chat-new-thread-sidebar", onClick: createNewThreadFromClick, children: "New chat" }), threadListContent] })) : null, _jsxs("div", { className: "vf-editor-chat-thread", children: [_jsxs("div", { className: "vf-editor-chat-header", children: [_jsxs("div", { className: "vf-editor-chat-header-row", children: [_jsx("a", { className: "vf-editor-chat-back-button vf-editor-chat-back-button--header", href: "/discover", "aria-label": "Back to Discover", children: _jsx("svg", { viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: "1.9", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: _jsx("path", { d: "m12 4-6 6 6 6" }) }) }), _jsx("button", { type: "button", className: "vf-editor-chat-history-icon", "aria-label": showHistoryPanel ? "Back to chat" : "Open chat history", "aria-pressed": showHistoryPanel, onClick: () => {
3920
+ setShowHistoryPanel((prev) => !prev);
3921
+ setShowBranchesPanel(false);
3922
+ }, children: showHistoryPanel ? (_jsx("svg", { viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: "1.9", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: _jsx("path", { d: "m12 4-6 6 6 6" }) })) : (_jsxs("svg", { viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: "1.7", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: [_jsx("circle", { cx: "10", cy: "10", r: "7.2" }), _jsx("path", { d: "M10 6v4l2.6 1.6" })] })) }), _jsx("button", { type: "button", className: "vf-editor-chat-history-icon vf-editor-chat-branches-icon", "aria-label": showBranchesPanel ? "Back to chat" : "Open template forks", "aria-pressed": showBranchesPanel, onClick: () => {
3923
+ setShowBranchesPanel((prev) => !prev);
3924
+ setShowHistoryPanel(false);
3925
+ }, children: showBranchesPanel ? (_jsx("svg", { viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: "1.9", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: _jsx("path", { d: "m12 4-6 6 6 6" }) })) : (_jsxs("svg", { viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: "1.7", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: [_jsx("circle", { cx: "5.5", cy: "4.5", r: "1.9" }), _jsx("circle", { cx: "5.5", cy: "15.5", r: "1.9" }), _jsx("circle", { cx: "14.5", cy: "8", r: "1.9" }), _jsx("path", { d: "M5.5 6.4v7.2" }), _jsx("path", { d: "M5.5 13.6c0-3.2 2.4-3.6 4.4-3.9 2-.3 4.6-.5 4.6-3.7" })] })) }), _jsxs("div", { className: "vf-editor-chat-header-titles", children: [_jsx("div", { className: "vf-editor-chat-header-title", children: "Vidfarm" }), _jsx("div", { className: "vf-editor-chat-header-subtitle", children: showHistoryPanel ? "Chat history" : showBranchesPanel ? "Template forks" : boot.template.templateTitle })] }), _jsx("button", { type: "button", className: "vf-editor-chat-new-thread", onClick: createNewThreadFromClick, children: "New chat" }), _jsx("button", { type: "button", className: "vf-editor-chat-minimize", "aria-label": "Minimize chat", onClick: () => setIsMobileChatOpen(false), children: "\u00D7" })] }), openThreadMenu && openThreadMenuThread ? createPortal(_jsxs("div", { className: "vf-editor-chat-thread-menu", role: "menu", style: {
3926
+ top: openThreadMenu.top,
3927
+ left: openThreadMenu.left
3928
+ }, onClick: (event) => event.stopPropagation(), children: [_jsx("button", { type: "button", onClick: () => copyThreadId(openThreadMenuThread.id), children: "Copy Chat ID" }), _jsx("button", { type: "button", onClick: () => editThreadTracers(openThreadMenuThread.id), children: "Edit tracers" }), _jsx("button", { type: "button", onClick: () => removeThreadFromView(openThreadMenuThread.id), children: "Remove from view" }), _jsx("button", { type: "button", onClick: () => archiveThread(openThreadMenuThread.id), children: "Archive thread" }), _jsx("button", { type: "button", "data-danger": "true", onClick: () => deleteThread(openThreadMenuThread.id), children: "Delete" })] }), document.body) : null] }), _jsx("div", { className: "vf-editor-chat-viewport", ref: viewportRef, children: showHistoryPanel ? (_jsx("div", { className: "vf-editor-chat-history-panel", children: threadListContent })) : showBranchesPanel ? (_jsxs("div", { className: "vf-editor-chat-history-panel vf-editor-chat-branches-panel", children: [_jsxs("div", { className: "vf-editor-chat-branches-header", children: [_jsxs("div", { className: "vf-editor-chat-branches-header-main", children: [_jsx("div", { className: "vf-editor-chat-branches-kicker", children: "Forks" }), _jsxs("div", { className: "vf-editor-chat-branches-title", children: [branches?.length ?? 0, " fork", (branches?.length ?? 0) === 1 ? "" : "s", " of this template"] })] }), _jsx("button", { type: "button", className: "vf-editor-chat-branches-refresh", onClick: () => { void loadBranches({ force: true }); }, disabled: branchesLoading, "aria-label": "Refresh forks", children: branchesLoading ? "Loading…" : "Refresh" })] }), branchesError ? (_jsx("div", { className: "vf-editor-chat-thread-empty", role: "status", children: branchesError })) : branchesLoading && !branches ? (_jsx("div", { className: "vf-editor-chat-thread-empty", children: "Loading forks\u2026" })) : branches && branches.length ? (_jsx("div", { className: "vf-editor-chat-thread-list", children: branches.map((fork) => {
3929
+ const isCurrent = currentForkId === fork.forkId;
3930
+ const forkUrl = `/editor/${encodeURIComponent(boot.template.templateId)}?fork=${encodeURIComponent(fork.forkId)}`;
3931
+ const label = fork.title && fork.title.trim() ? fork.title : fork.forkId;
3932
+ const parentSuffix = fork.parentForkId ? ` · forked from ${fork.parentForkId.slice(0, 12)}${fork.parentVersion ? `#v${fork.parentVersion}` : ""}` : "";
3933
+ const versionSuffix = fork.latestVersion && fork.latestVersion > 0 ? ` · v${fork.latestVersion}` : "";
3934
+ return (_jsx("div", { role: "listitem", className: "vf-editor-chat-thread-chip", "data-active": isCurrent ? "true" : "false", children: _jsxs("a", { className: "vf-editor-chat-thread-chip-main vf-editor-chat-branch-chip-link", href: forkUrl, target: "_blank", rel: "noopener noreferrer", children: [_jsxs("span", { className: "vf-editor-chat-thread-chip-title", children: [label, isCurrent ? _jsx("span", { className: "vf-editor-chat-branch-current-badge", children: "current" }) : null] }), _jsxs("span", { className: "vf-editor-chat-thread-chip-tracers", children: [fork.visibility ?? "private", versionSuffix, parentSuffix] })] }) }, fork.forkId));
3935
+ }) })) : (_jsx("div", { className: "vf-editor-chat-thread-empty", children: "No forks yet for this template." }))] })) : messages.length ? messages.map((message) => (_jsx(ChatBubble, { message: message }, message.id))) : activeThreadId && loadingThreadId === activeThreadId ? (_jsxs("div", { className: "vf-editor-chat-empty", children: [_jsx("div", { className: "vf-editor-chat-kicker", children: "Loading chat" }), _jsxs("h2", { children: ["Opening ", activeThreadId] })] })) : (_jsxs("div", { className: "vf-editor-chat-empty", children: [_jsx("div", { className: "vf-editor-chat-kicker", children: "AI Copilot" }), _jsx("h2", { children: isBrainstormSurface ? "Start a new chat." : "Repurpose this video." }), _jsx("p", { children: isBrainstormSurface
3936
+ ? "Use the brainstorm tools to go from zero context to angles, hooks, and a strategy you can actually test."
3937
+ : "Use the brainstorm tools to turn this video into a native ad for your product, spot product-placement opportunities, and shape the timeline you can actually test. You can also connect from your own AI coding agent, including Claude Code or OpenAI Codex, and ask it to work against this composition." }), _jsx("div", { className: "vf-editor-chat-empty-prompts", "aria-label": "Example prompts", children: isBrainstormSurface ? (_jsxs(_Fragment, { children: [_jsx("div", { children: "I don't know where to start" }), _jsx("div", { children: "Help me think of persuasive angles" }), _jsx("div", { children: "Help me think of viral hooks" })] })) : (_jsxs(_Fragment, { children: [_jsx("div", { children: "I don't know where to start" }), _jsx("div", { children: "Repurpose this video into a social media native ad for my product" }), _jsx("div", { children: "Help me identify product placement opportunities in this video for my product" })] })) })] })) }), _jsx("div", { className: "vf-editor-chat-footer", hidden: showHistoryPanel || showBranchesPanel, children: _jsxs("div", { className: "vf-editor-chat-composer", "data-drop-target": isDropTarget ? "true" : "false", onDragEnter: (event) => {
3938
+ event.preventDefault();
3939
+ setIsDropTarget(true);
3940
+ }, onDragOver: (event) => {
3941
+ event.preventDefault();
3942
+ if (!isDropTarget) {
3943
+ setIsDropTarget(true);
3944
+ }
3945
+ }, onDragLeave: (event) => {
3946
+ event.preventDefault();
3947
+ const nextTarget = event.relatedTarget;
3948
+ if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) {
3949
+ return;
3950
+ }
3951
+ setIsDropTarget(false);
3952
+ }, onDrop: (event) => void handleComposerDrop(event), children: [isDropTarget ? (_jsx("div", { className: "vf-editor-chat-dropzone-hint", children: "Drop files to attach" })) : null, pendingAttachments.length ? (_jsx("div", { className: "vf-editor-chat-composer-attachments-strip", children: pendingAttachments.map((attachment) => (_jsx(ComposerAttachmentItem, { attachment: attachment, onRemove: () => setPendingAttachments((current) => current.filter((entry) => entry.id !== attachment.id)) }, attachment.id))) })) : null, _jsxs("div", { className: "vf-editor-chat-composer-row", children: [_jsx("button", { type: "button", className: "vf-editor-chat-attach-button", "aria-label": "Open upload drawer", onClick: () => setIsUploadDrawerOpen(true), children: "\uD83D\uDCCE" }), _jsx("textarea", { rows: 1, placeholder: isBrainstormSurface ? "Describe the offer, audience, or strategy problem you want help with..." : "Ask it to call a route, inspect jobs, save config, or continue this thread...", className: "vf-editor-chat-input", value: draft, onChange: (event) => setDraft(event.target.value), onKeyDown: handleComposerKeyDown, onPaste: (event) => void handleComposerPaste(event) }), _jsx("button", { type: "button", className: "vf-editor-chat-send-button", onClick: () => {
3953
+ if (isActiveThreadSending) {
3954
+ stopActiveSend();
3955
+ return;
3956
+ }
3957
+ void handleSend();
3958
+ }, disabled: isActiveThreadSending ? false : !canSend, children: isActiveThreadSending ? "Stop" : "Send" })] }), _jsx("input", { ref: fileInputRef, type: "file", accept: "image/*,video/*,audio/*,application/pdf,text/plain,text/markdown", multiple: true, hidden: true, onChange: (event) => void handlePickFiles(event) })] }) })] })] }), _jsx(UploadDrawer, { apiKey: boot.vidfarmApiKey, open: isUploadDrawerOpen, onClose: () => setIsUploadDrawerOpen(false), onConfirm: (attachments) => setPendingAttachments((current) => [...current, ...attachments]) })] }));
3959
+ }
3960
+ //# sourceMappingURL=template-editor-chat.js.map