@mevdragon/vidfarm-devcli 0.2.11 → 0.2.13

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 (199) hide show
  1. package/.env.example +37 -1
  2. package/README.md +27 -183
  3. package/SKILL.director.md +293 -0
  4. package/SKILL.platform.md +312 -0
  5. package/auto-create-hyperframe-templates/AUTO_CREATE_HYPERFRAME_TEMPLATES.md +275 -0
  6. package/auto-create-hyperframe-templates/extractor-system-prompt.md +128 -0
  7. package/auto-create-hyperframe-templates/input-manifest.schema.json +56 -0
  8. package/auto-create-hyperframe-templates/lambda-memory-estimator.md +118 -0
  9. package/auto-create-hyperframe-templates/production-graph.schema.json +314 -0
  10. package/auto-create-hyperframe-templates/runbook.md +198 -0
  11. package/auto-create-hyperframe-templates/scripts/create-hyperframe-template.mjs +368 -0
  12. package/auto-create-hyperframe-templates/template-plan.schema.json +311 -0
  13. package/auto-create-hyperframe-templates/template-planner-prompt.md +144 -0
  14. package/auto-create-templates/AUTO_CREATE_TEMPLATES.md +124 -0
  15. package/auto-create-templates/extractor-system-prompt.md +8 -2
  16. package/auto-create-templates/template-plan.schema.json +73 -2
  17. package/auto-create-templates/template-planner-prompt.md +63 -1
  18. package/dist/src/account-pages-legacy.js +361 -89
  19. package/dist/src/account-pages-legacy.js.map +1 -0
  20. package/dist/src/account-pages.js +13 -3
  21. package/dist/src/account-pages.js.map +1 -0
  22. package/dist/src/app.js +7060 -946
  23. package/dist/src/app.js.map +1 -0
  24. package/dist/src/cli.js +111 -29
  25. package/dist/src/cli.js.map +1 -0
  26. package/dist/src/composition-runtime.js +581 -0
  27. package/dist/src/composition-runtime.js.map +1 -0
  28. package/dist/src/config.js +69 -11
  29. package/dist/src/config.js.map +1 -0
  30. package/dist/src/context.js +280 -21
  31. package/dist/src/context.js.map +1 -0
  32. package/dist/src/dev-app-legacy.js +18 -17
  33. package/dist/src/dev-app-legacy.js.map +1 -0
  34. package/dist/src/dev-app.js +1 -0
  35. package/dist/src/dev-app.js.map +1 -0
  36. package/dist/src/domain.js +2 -1
  37. package/dist/src/domain.js.map +1 -0
  38. package/dist/src/editor-chat-history.js +10 -0
  39. package/dist/src/editor-chat-history.js.map +1 -0
  40. package/dist/src/editor-chat.js +133 -24
  41. package/dist/src/editor-chat.js.map +1 -0
  42. package/dist/src/frontend/flockposter-cache-store.js +10 -2
  43. package/dist/src/frontend/flockposter-cache-store.js.map +1 -0
  44. package/dist/src/frontend/homepage-client.js +6 -3
  45. package/dist/src/frontend/homepage-client.js.map +1 -0
  46. package/dist/src/frontend/homepage-shared.js +1 -0
  47. package/dist/src/frontend/homepage-shared.js.map +1 -0
  48. package/dist/src/frontend/homepage-store.js +1 -0
  49. package/dist/src/frontend/homepage-store.js.map +1 -0
  50. package/dist/src/frontend/homepage-view.js +131 -18
  51. package/dist/src/frontend/homepage-view.js.map +1 -0
  52. package/dist/src/frontend/page-runtime-client.js +5 -2
  53. package/dist/src/frontend/page-runtime-client.js.map +1 -0
  54. package/dist/src/frontend/page-runtime-store.js +1 -0
  55. package/dist/src/frontend/page-runtime-store.js.map +1 -0
  56. package/dist/src/frontend/sentry.js +42 -0
  57. package/dist/src/frontend/sentry.js.map +1 -0
  58. package/dist/src/frontend/template-editor-chat.js +1719 -213
  59. package/dist/src/frontend/template-editor-chat.js.map +1 -0
  60. package/dist/src/help-page.js +333 -0
  61. package/dist/src/help-page.js.map +1 -0
  62. package/dist/src/homepage.js +169 -47
  63. package/dist/src/homepage.js.map +1 -0
  64. package/dist/src/hyperframes/composition.js +180 -0
  65. package/dist/src/hyperframes/composition.js.map +1 -0
  66. package/dist/src/index.js +1 -0
  67. package/dist/src/index.js.map +1 -0
  68. package/dist/src/instrument.js +30 -0
  69. package/dist/src/instrument.js.map +1 -0
  70. package/dist/src/lib/crypto.js +1 -0
  71. package/dist/src/lib/crypto.js.map +1 -0
  72. package/dist/src/lib/dev-log.js +54 -0
  73. package/dist/src/lib/dev-log.js.map +1 -0
  74. package/dist/src/lib/display-name.js +11 -0
  75. package/dist/src/lib/display-name.js.map +1 -0
  76. package/dist/src/lib/ids.js +21 -1
  77. package/dist/src/lib/ids.js.map +1 -0
  78. package/dist/src/lib/images.js +1 -0
  79. package/dist/src/lib/images.js.map +1 -0
  80. package/dist/src/lib/json.js +1 -0
  81. package/dist/src/lib/json.js.map +1 -0
  82. package/dist/src/lib/template-dna.js +1 -0
  83. package/dist/src/lib/template-dna.js.map +1 -0
  84. package/dist/src/lib/template-paths.js +1 -0
  85. package/dist/src/lib/template-paths.js.map +1 -0
  86. package/dist/src/lib/template-style-options.js +40 -2
  87. package/dist/src/lib/template-style-options.js.map +1 -0
  88. package/dist/src/lib/time.js +1 -0
  89. package/dist/src/lib/time.js.map +1 -0
  90. package/dist/src/lib/video-quality-harness.js +60 -0
  91. package/dist/src/lib/video-quality-harness.js.map +1 -0
  92. package/dist/src/page-runtime.js +1 -0
  93. package/dist/src/page-runtime.js.map +1 -0
  94. package/dist/src/page-shell.js +1501 -49
  95. package/dist/src/page-shell.js.map +1 -0
  96. package/dist/src/primitive-context.js +144 -8
  97. package/dist/src/primitive-context.js.map +1 -0
  98. package/dist/src/primitive-registry.js +1919 -78
  99. package/dist/src/primitive-registry.js.map +1 -0
  100. package/dist/src/primitive-sdk.js +1 -0
  101. package/dist/src/primitive-sdk.js.map +1 -0
  102. package/dist/src/primitives/remotion/html-image.js +3 -1
  103. package/dist/src/primitives/remotion/html-image.js.map +1 -0
  104. package/dist/src/primitives/remotion/media-slideshow.js +60 -0
  105. package/dist/src/primitives/remotion/media-slideshow.js.map +1 -0
  106. package/dist/src/react-page-shell.js +1 -0
  107. package/dist/src/react-page-shell.js.map +1 -0
  108. package/dist/src/ready-post-schedule-component.js +1 -0
  109. package/dist/src/ready-post-schedule-component.js.map +1 -0
  110. package/dist/src/registry.js +237 -24
  111. package/dist/src/registry.js.map +1 -0
  112. package/dist/src/runtime.js +3 -0
  113. package/dist/src/runtime.js.map +1 -0
  114. package/dist/src/services/api-call-history.js +4 -0
  115. package/dist/src/services/api-call-history.js.map +1 -0
  116. package/dist/src/services/auth.js +40 -50
  117. package/dist/src/services/auth.js.map +1 -0
  118. package/dist/src/services/billing.js +20 -44
  119. package/dist/src/services/billing.js.map +1 -0
  120. package/dist/src/services/chat-threads.js +10 -6
  121. package/dist/src/services/chat-threads.js.map +1 -0
  122. package/dist/src/services/fork-access.js +94 -0
  123. package/dist/src/services/fork-access.js.map +1 -0
  124. package/dist/src/services/ghostcut.js +132 -0
  125. package/dist/src/services/ghostcut.js.map +1 -0
  126. package/dist/src/services/hyperframes.js +1014 -0
  127. package/dist/src/services/hyperframes.js.map +1 -0
  128. package/dist/src/services/job-capacity.js +14 -0
  129. package/dist/src/services/job-capacity.js.map +1 -0
  130. package/dist/src/services/job-logs.js +4 -0
  131. package/dist/src/services/job-logs.js.map +1 -0
  132. package/dist/src/services/jobs.js +99 -91
  133. package/dist/src/services/jobs.js.map +1 -0
  134. package/dist/src/services/media-processing.js +743 -0
  135. package/dist/src/services/media-processing.js.map +1 -0
  136. package/dist/src/services/primitive-media-lambda.js +280 -0
  137. package/dist/src/services/primitive-media-lambda.js.map +1 -0
  138. package/dist/src/services/providers.js +1560 -178
  139. package/dist/src/services/providers.js.map +1 -0
  140. package/dist/src/services/rate-limits.js +3 -2
  141. package/dist/src/services/rate-limits.js.map +1 -0
  142. package/dist/src/services/remotion.js +495 -92
  143. package/dist/src/services/remotion.js.map +1 -0
  144. package/dist/src/services/serverless-auth.js +374 -0
  145. package/dist/src/services/serverless-auth.js.map +1 -0
  146. package/dist/src/services/serverless-jobs.js +1074 -0
  147. package/dist/src/services/serverless-jobs.js.map +1 -0
  148. package/dist/src/services/serverless-provider-keys.js +401 -0
  149. package/dist/src/services/serverless-provider-keys.js.map +1 -0
  150. package/dist/src/services/serverless-records.js +1086 -0
  151. package/dist/src/services/serverless-records.js.map +1 -0
  152. package/dist/src/services/serverless-template-configs.js +67 -0
  153. package/dist/src/services/serverless-template-configs.js.map +1 -0
  154. package/dist/src/services/storage.js +170 -35
  155. package/dist/src/services/storage.js.map +1 -0
  156. package/dist/src/services/template-certification.js +295 -3
  157. package/dist/src/services/template-certification.js.map +1 -0
  158. package/dist/src/services/template-loader.js +45 -1
  159. package/dist/src/services/template-loader.js.map +1 -0
  160. package/dist/src/services/template-runtime-bundles.js +217 -0
  161. package/dist/src/services/template-runtime-bundles.js.map +1 -0
  162. package/dist/src/services/template-sources.js +452 -87
  163. package/dist/src/services/template-sources.js.map +1 -0
  164. package/dist/src/services/video-normalization.js +2 -0
  165. package/dist/src/services/video-normalization.js.map +1 -0
  166. package/dist/src/services/webhooks.js +7 -6
  167. package/dist/src/services/webhooks.js.map +1 -0
  168. package/dist/src/template-editor-pages.js +5637 -1322
  169. package/dist/src/template-editor-pages.js.map +1 -0
  170. package/dist/src/template-editor-shell.js +1376 -170
  171. package/dist/src/template-editor-shell.js.map +1 -0
  172. package/dist/src/template-sdk.js +1 -0
  173. package/dist/src/template-sdk.js.map +1 -0
  174. package/dist/src/worker.js +11 -226
  175. package/dist/src/worker.js.map +1 -0
  176. package/package.json +47 -12
  177. package/public/assets/homepage-client-app.js +34 -8
  178. package/public/assets/page-runtime-client-app.js +44 -18
  179. package/readme.secret.md +89 -0
  180. package/templates/vidfarm_template_0000/README.md +106 -0
  181. package/templates/vidfarm_template_0000/SKILL.md +266 -0
  182. package/templates/vidfarm_template_0000/assets/Abel-Regular.ttf +0 -0
  183. package/templates/vidfarm_template_0000/assets/DMSerifDisplay-Regular.ttf +0 -0
  184. package/templates/vidfarm_template_0000/assets/Montserrat[wght].ttf +0 -0
  185. package/templates/vidfarm_template_0000/assets/SourceCodePro[wght].ttf +0 -0
  186. package/templates/vidfarm_template_0000/assets/TikTokSans-SemiBold.ttf +0 -0
  187. package/templates/vidfarm_template_0000/assets/Yesteryear-Regular.ttf +0 -0
  188. package/templates/vidfarm_template_0000/composition.json +11 -0
  189. package/templates/vidfarm_template_0000/src/lib/images.js +221 -0
  190. package/templates/vidfarm_template_0000/src/remotion/Root.js +33 -0
  191. package/templates/vidfarm_template_0000/src/remotion/index.js +3 -0
  192. package/templates/vidfarm_template_0000/src/sdk.js +3 -0
  193. package/templates/vidfarm_template_0000/src/style-options.js +200 -0
  194. package/templates/vidfarm_template_0000/src/template-dna.js +9 -0
  195. package/templates/vidfarm_template_0000/src/template.js +1566 -0
  196. package/templates/vidfarm_template_0000/template.config.json +21 -0
  197. package/GETTING_STARTED.developers.md +0 -87
  198. package/SKILL.developer.md +0 -1177
  199. package/dist/src/db.js +0 -2007
@@ -1,15 +1,29 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
3
3
  import { createPortal } from "react-dom";
4
+ const MB = 1024 * 1024;
5
+ const MY_FILES_UPLOAD_LIMIT_BYTES = 50 * MB;
6
+ const CHAT_INLINE_IMAGE_TARGET_BYTES = 2 * MB;
7
+ const CHAT_INLINE_IMAGE_HARD_LIMIT_BYTES = 3 * MB;
8
+ const CHAT_INLINE_TOTAL_BUDGET_BYTES = Math.floor(3.5 * MB);
9
+ const CHAT_INLINE_IMAGE_MAX_EDGE = 2048;
10
+ const CHAT_TEMPORARY_UPLOAD_FOLDER = "chat";
4
11
  function normalizeUploadedAttachment(raw) {
5
- const id = raw.id || raw.attachment_id || "";
12
+ const id = raw.id || raw.attachment_id || raw.file_id || "";
6
13
  const fileName = raw.fileName || raw.file_name;
7
14
  const contentType = raw.contentType || raw.content_type;
8
15
  const viewUrl = raw.viewUrl || raw.view_url || "";
9
16
  if (!id || !fileName || !contentType || !viewUrl) {
10
17
  return null;
11
18
  }
12
- return { id, fileName, contentType, viewUrl };
19
+ return {
20
+ id,
21
+ fileName,
22
+ contentType,
23
+ viewUrl,
24
+ sizeBytes: typeof raw.sizeBytes === "number" ? raw.sizeBytes : raw.size_bytes,
25
+ folderPath: raw.folderPath ?? raw.folder_path ?? ""
26
+ };
13
27
  }
14
28
  function isImageAttachment(attachment) {
15
29
  return attachment.contentType.startsWith("image/");
@@ -40,6 +54,12 @@ const MAX_CHAT_PAYLOAD_STRING_LENGTH = 1200;
40
54
  const MAX_CHAT_PAYLOAD_ARRAY_ITEMS = 24;
41
55
  const MAX_CHAT_PAYLOAD_OBJECT_KEYS = 80;
42
56
  const DATA_URL_PREFIX_REGEX = /^data:([^;,]+)[^,]*,/i;
57
+ const SECRET_HEADER_REGEX = /authorization|api[-_]?key|secret|token|vidfarm-api-key/i;
58
+ const CHAT_TRANSPORT_COMPACT_CHAR_THRESHOLD = 48_000;
59
+ const CHAT_TRANSPORT_RECENT_MESSAGES = 14;
60
+ const CHAT_TRANSPORT_MAX_SUMMARY_CHARS = 12_000;
61
+ const CHAT_TRANSPORT_MAX_MESSAGE_CHARS = 1_200;
62
+ const LOCAL_THREAD_MESSAGE_CACHE_LIMIT = 30;
43
63
  function formatJsonBody(value) {
44
64
  if (value === undefined || value === null || value === "") {
45
65
  return "";
@@ -116,10 +136,18 @@ function sanitizeHttpExchange(exchange) {
116
136
  ...exchange,
117
137
  request: {
118
138
  ...exchange.request,
139
+ headers: Object.fromEntries(Object.entries(exchange.request.headers).map(([key, value]) => [
140
+ key,
141
+ SECRET_HEADER_REGEX.test(key) ? "[redacted]" : summarizeLargeString(value)
142
+ ])),
119
143
  body: sanitizeChatPayload(exchange.request.body)
120
144
  },
121
145
  result: {
122
146
  ...exchange.result,
147
+ headers: Object.fromEntries(Object.entries(exchange.result.headers).map(([key, value]) => [
148
+ key,
149
+ SECRET_HEADER_REGEX.test(key) ? "[redacted]" : summarizeLargeString(value)
150
+ ])),
123
151
  body: sanitizeChatPayload(exchange.result.body),
124
152
  error: exchange.result.error ? summarizeLargeString(exchange.result.error) : exchange.result.error
125
153
  }
@@ -225,17 +253,25 @@ function extractStorageKeyFromUrl(url) {
225
253
  return null;
226
254
  }
227
255
  }
256
+ function safeDecodeURIComponent(value) {
257
+ try {
258
+ return decodeURIComponent(value);
259
+ }
260
+ catch {
261
+ return value;
262
+ }
263
+ }
228
264
  function extractFilenameFromUrl(url) {
229
265
  const storageKey = extractStorageKeyFromUrl(url);
230
266
  if (storageKey) {
231
- const fileName = decodeURIComponent(storageKey.split("/").filter(Boolean).pop() || "");
267
+ const fileName = safeDecodeURIComponent(storageKey.split("/").filter(Boolean).pop() || "");
232
268
  if (fileName) {
233
269
  return fileName;
234
270
  }
235
271
  }
236
272
  try {
237
273
  const pathname = new URL(url).pathname;
238
- return decodeURIComponent(pathname.split("/").filter(Boolean).pop() || "");
274
+ return safeDecodeURIComponent(pathname.split("/").filter(Boolean).pop() || "");
239
275
  }
240
276
  catch {
241
277
  return "";
@@ -245,6 +281,14 @@ function labelFromUrl(url) {
245
281
  const filename = extractFilenameFromUrl(url);
246
282
  return filename || "Output";
247
283
  }
284
+ function downloadNameFromUrl(url, fallback) {
285
+ const filename = extractFilenameFromUrl(url);
286
+ if (filename) {
287
+ return filename;
288
+ }
289
+ const normalizedFallback = fallback.trim().replace(/[\\/:*?"<>|]+/g, "-");
290
+ return normalizedFallback || "download";
291
+ }
248
292
  function getMediaKindFromUrl(url) {
249
293
  const fileName = extractFilenameFromUrl(url);
250
294
  const extension = fileName.split(".").pop()?.trim().toLowerCase() || "";
@@ -328,15 +372,136 @@ function collectLabeledUrls(value, output, seen = new Set(), path = []) {
328
372
  }
329
373
  function buildJobQueuedMessage(job) {
330
374
  return [
331
- `Started job \`${job.job_id}\`${job.tracer ? ` with tracer \`${job.tracer}\`` : ""}.`,
375
+ `Started render \`${job.job_id}\`${job.tracer ? ` with tracer \`${job.tracer}\`` : ""}.`,
332
376
  "If it takes a while, ask for an update later and I can check it for you."
333
377
  ].join("\n\n");
334
378
  }
379
+ function isBrainstormPrimitiveId(value) {
380
+ return typeof value === "string" && value.startsWith("primitive:brainstorm_");
381
+ }
382
+ function normalizeJobArtifacts(value) {
383
+ if (!Array.isArray(value)) {
384
+ return [];
385
+ }
386
+ const artifacts = [];
387
+ for (const entry of value) {
388
+ if (!entry || typeof entry !== "object") {
389
+ continue;
390
+ }
391
+ const artifact = entry;
392
+ const normalized = {
393
+ artifact_id: typeof artifact.artifact_id === "string" ? artifact.artifact_id : undefined,
394
+ kind: typeof artifact.kind === "string" ? artifact.kind : undefined,
395
+ storage_key: typeof artifact.storage_key === "string" ? artifact.storage_key : undefined,
396
+ public_url: typeof artifact.public_url === "string" ? artifact.public_url : undefined,
397
+ metadata: artifact.metadata && typeof artifact.metadata === "object"
398
+ ? artifact.metadata
399
+ : null,
400
+ created_at: typeof artifact.created_at === "string" ? artifact.created_at : undefined
401
+ };
402
+ if (normalized.public_url || normalized.storage_key) {
403
+ artifacts.push(normalized);
404
+ }
405
+ }
406
+ return artifacts;
407
+ }
408
+ function artifactLabelFromStorageKey(storageKey) {
409
+ if (!storageKey) {
410
+ return "JSON result";
411
+ }
412
+ const name = safeDecodeURIComponent(storageKey.split("/").filter(Boolean).pop() || "result.json");
413
+ return name;
414
+ }
415
+ function artifactFileName(storageKey) {
416
+ return safeDecodeURIComponent(storageKey?.split("/").filter(Boolean).pop() || "");
417
+ }
418
+ function brainstormLabelFromPrimitiveId(primitiveId) {
419
+ switch (primitiveId) {
420
+ case "primitive:brainstorm_hooks":
421
+ return "Hooks JSON";
422
+ case "primitive:brainstorm_angles":
423
+ return "Angles JSON";
424
+ case "primitive:brainstorm_awareness_stages":
425
+ return "Awareness JSON";
426
+ case "primitive:brainstorm_coldstart":
427
+ return "Coldstart JSON";
428
+ default:
429
+ return "Brainstorm JSON";
430
+ }
431
+ }
432
+ function extractBrainstormJsonArtifactsFromJob(job) {
433
+ if (job.status !== "succeeded" || !isBrainstormPrimitiveId(job.primitive_id)) {
434
+ return [];
435
+ }
436
+ const previews = [];
437
+ for (const [index, artifact] of normalizeJobArtifacts(job.artifacts).entries()) {
438
+ const publicUrl = artifact.public_url?.trim();
439
+ const storageKey = artifact.storage_key?.trim();
440
+ const isJsonArtifact = artifact.kind === "json"
441
+ || Boolean(publicUrl && /\.json(?:[?#]|$)/i.test(publicUrl))
442
+ || Boolean(storageKey && /\.json$/i.test(storageKey));
443
+ if (!isJsonArtifact || !publicUrl) {
444
+ continue;
445
+ }
446
+ const fileLabel = artifactLabelFromStorageKey(artifact.storage_key);
447
+ const prefixLabel = brainstormLabelFromPrimitiveId(job.primitive_id);
448
+ previews.push({
449
+ key: artifact.artifact_id || `${job.job_id}:${publicUrl}:${index}`,
450
+ url: publicUrl,
451
+ label: fileLabel === "result.json" ? prefixLabel : `${prefixLabel} • ${fileLabel}`,
452
+ primitiveId: job.primitive_id,
453
+ primitiveType: job.primitive_type,
454
+ jobId: job.job_id
455
+ });
456
+ }
457
+ return previews;
458
+ }
459
+ function brainstormDocumentLabelFromPrimitiveId(primitiveId) {
460
+ switch (primitiveId) {
461
+ case "primitive:brainstorm_awareness_stages":
462
+ return "Awareness response";
463
+ default:
464
+ return "Brainstorm response";
465
+ }
466
+ }
467
+ function extractBrainstormDocumentArtifactsFromJob(job) {
468
+ if (job.status !== "succeeded" || !isBrainstormPrimitiveId(job.primitive_id)) {
469
+ return [];
470
+ }
471
+ const previews = [];
472
+ for (const [index, artifact] of normalizeJobArtifacts(job.artifacts).entries()) {
473
+ const publicUrl = artifact.public_url?.trim();
474
+ const storageKey = artifact.storage_key?.trim();
475
+ const fileName = artifactFileName(storageKey);
476
+ const isPrompt = /^prompt\./i.test(fileName);
477
+ const isMarkdown = artifact.kind === "text"
478
+ ? Boolean(publicUrl && /\.md(?:[?#]|$)/i.test(publicUrl) || storageKey && /\.md$/i.test(storageKey))
479
+ : Boolean(publicUrl && /\.md(?:[?#]|$)/i.test(publicUrl) || storageKey && /\.md$/i.test(storageKey));
480
+ const isText = Boolean(publicUrl && /\.txt(?:[?#]|$)/i.test(publicUrl) || storageKey && /\.txt$/i.test(storageKey));
481
+ if (!publicUrl || isPrompt || (!isMarkdown && !isText)) {
482
+ continue;
483
+ }
484
+ const defaultLabel = brainstormDocumentLabelFromPrimitiveId(job.primitive_id);
485
+ previews.push({
486
+ key: artifact.artifact_id || `${job.job_id}:${publicUrl}:${index}`,
487
+ url: publicUrl,
488
+ label: fileName === "response.md" || fileName === "response.txt" ? defaultLabel : artifactLabelFromStorageKey(storageKey),
489
+ jobId: job.job_id,
490
+ format: isMarkdown ? "markdown" : "text"
491
+ });
492
+ }
493
+ return previews;
494
+ }
335
495
  function buildJobCompletionMessage(job) {
336
496
  if (job.status === "succeeded") {
337
497
  const urls = [];
338
498
  collectLabeledUrls(job.result, urls);
339
- const lines = [`Job \`${job.job_id}\` succeeded.`];
499
+ const lines = [`Render \`${job.job_id}\` succeeded.`];
500
+ if (isBrainstormPrimitiveId(job.primitive_id)
501
+ && (extractBrainstormJsonArtifactsFromJob(job).length || extractBrainstormDocumentArtifactsFromJob(job).length)) {
502
+ lines.push("");
503
+ lines.push("Open the result below. It contains the brainstorm output you can review, copy, and reuse.");
504
+ }
340
505
  if (urls.length) {
341
506
  lines.push("");
342
507
  lines.push("Outputs:");
@@ -345,12 +510,12 @@ function buildJobCompletionMessage(job) {
345
510
  return lines.join("\n");
346
511
  }
347
512
  if (job.status === "failed") {
348
- return `Job \`${job.job_id}\` failed.${job.error?.message ? ` ${job.error.message}` : ""}`;
513
+ return `Render \`${job.job_id}\` failed.${job.error?.message ? ` ${job.error.message}` : ""}`;
349
514
  }
350
515
  if (job.status === "cancelled") {
351
- return `Job \`${job.job_id}\` was cancelled.`;
516
+ return `Render \`${job.job_id}\` was cancelled.`;
352
517
  }
353
- return `Job \`${job.job_id}\` is ${job.status}.`;
518
+ return `Render \`${job.job_id}\` is ${job.status}.`;
354
519
  }
355
520
  function appendDistinctMessageText(current, addition) {
356
521
  const normalizedCurrent = current.trim();
@@ -395,8 +560,12 @@ function tryReadJobStatusPayload(value) {
395
560
  job_id: record.job_id,
396
561
  tracer: typeof record.tracer === "string" ? record.tracer : undefined,
397
562
  status: record.status,
563
+ primitive_id: typeof record.primitive_id === "string" ? record.primitive_id : undefined,
564
+ primitive_type: typeof record.primitive_type === "string" ? record.primitive_type : undefined,
565
+ operation_name: typeof record.operation_name === "string" ? record.operation_name : undefined,
398
566
  progress: typeof record.progress === "number" ? record.progress : null,
399
567
  result: record.result,
568
+ artifacts: normalizeJobArtifacts(record.artifacts),
400
569
  error: record.error && typeof record.error === "object"
401
570
  ? {
402
571
  message: typeof record.error.message === "string"
@@ -409,6 +578,24 @@ function tryReadJobStatusPayload(value) {
409
578
  : null
410
579
  };
411
580
  }
581
+ function isPrimitiveRestUrl(value) {
582
+ if (!value) {
583
+ return false;
584
+ }
585
+ try {
586
+ const base = typeof window !== "undefined" ? window.location.origin : "http://localhost";
587
+ return new URL(value, base).pathname.startsWith("/api/v1/primitives");
588
+ }
589
+ catch {
590
+ return false;
591
+ }
592
+ }
593
+ function isTerminalJobStatus(status) {
594
+ return status === "succeeded" || status === "failed" || status === "cancelled";
595
+ }
596
+ function makeJobPollerKey(threadId, assistantMessageId, jobId) {
597
+ return `${threadId}::${assistantMessageId}::${jobId}`;
598
+ }
412
599
  function buildAuthHeaders(apiKey, contentType) {
413
600
  const headers = new Headers();
414
601
  if (apiKey) {
@@ -436,35 +623,215 @@ async function fileToDataUrl(file) {
436
623
  reader.readAsDataURL(file);
437
624
  });
438
625
  }
626
+ async function blobToDataUrl(blob) {
627
+ return await new Promise((resolve, reject) => {
628
+ const reader = new FileReader();
629
+ reader.onload = () => {
630
+ if (typeof reader.result === "string") {
631
+ resolve(reader.result);
632
+ return;
633
+ }
634
+ reject(new Error("Unable to read compressed attachment contents."));
635
+ };
636
+ reader.onerror = () => reject(reader.error ?? new Error("Unable to read compressed attachment contents."));
637
+ reader.readAsDataURL(blob);
638
+ });
639
+ }
640
+ function estimateDataUrlBytes(value) {
641
+ if (!value) {
642
+ return 0;
643
+ }
644
+ const marker = ";base64,";
645
+ const markerIndex = value.indexOf(marker);
646
+ if (markerIndex < 0) {
647
+ return value.length;
648
+ }
649
+ const base64 = value.slice(markerIndex + marker.length).replace(/\s/g, "");
650
+ const padding = base64.endsWith("==") ? 2 : base64.endsWith("=") ? 1 : 0;
651
+ return Math.max(0, Math.floor(base64.length * 3 / 4) - padding);
652
+ }
653
+ function estimateInlineAttachmentBytes(attachments) {
654
+ return attachments.reduce((total, attachment) => total + estimateDataUrlBytes(attachment.transportData), 0);
655
+ }
656
+ async function canvasToBlob(canvas, contentType, quality) {
657
+ return await new Promise((resolve) => {
658
+ canvas.toBlob((blob) => resolve(blob), contentType, quality);
659
+ });
660
+ }
661
+ async function decodeImageForCanvas(file) {
662
+ if (typeof createImageBitmap === "function") {
663
+ try {
664
+ const bitmap = await createImageBitmap(file);
665
+ return {
666
+ source: bitmap,
667
+ width: bitmap.width,
668
+ height: bitmap.height,
669
+ close: () => bitmap.close()
670
+ };
671
+ }
672
+ catch {
673
+ // Fall back to HTMLImageElement decoding below.
674
+ }
675
+ }
676
+ return await new Promise((resolve) => {
677
+ const image = new Image();
678
+ const objectUrl = URL.createObjectURL(file);
679
+ image.onload = () => {
680
+ URL.revokeObjectURL(objectUrl);
681
+ resolve({
682
+ source: image,
683
+ width: image.naturalWidth || image.width,
684
+ height: image.naturalHeight || image.height
685
+ });
686
+ };
687
+ image.onerror = () => {
688
+ URL.revokeObjectURL(objectUrl);
689
+ resolve(null);
690
+ };
691
+ image.src = objectUrl;
692
+ });
693
+ }
694
+ async function createCompressedImageDataUrl(file, remainingBudgetBytes) {
695
+ if (!file.type.startsWith("image/") || remainingBudgetBytes <= 0) {
696
+ return undefined;
697
+ }
698
+ const hardLimitBytes = Math.min(CHAT_INLINE_IMAGE_HARD_LIMIT_BYTES, remainingBudgetBytes);
699
+ const targetBytes = Math.min(CHAT_INLINE_IMAGE_TARGET_BYTES, hardLimitBytes);
700
+ if (file.size <= targetBytes) {
701
+ return fileToDataUrl(file);
702
+ }
703
+ const decoded = await decodeImageForCanvas(file);
704
+ if (!decoded || decoded.width <= 0 || decoded.height <= 0) {
705
+ return file.size <= hardLimitBytes ? fileToDataUrl(file) : undefined;
706
+ }
707
+ try {
708
+ let bestBlob = null;
709
+ const maxEdges = [CHAT_INLINE_IMAGE_MAX_EDGE, 1600, 1280, 1024];
710
+ const qualities = [0.86, 0.78, 0.7, 0.62, 0.54];
711
+ for (const maxEdge of maxEdges) {
712
+ const scale = Math.min(1, maxEdge / Math.max(decoded.width, decoded.height));
713
+ const width = Math.max(1, Math.round(decoded.width * scale));
714
+ const height = Math.max(1, Math.round(decoded.height * scale));
715
+ const canvas = document.createElement("canvas");
716
+ canvas.width = width;
717
+ canvas.height = height;
718
+ const context = canvas.getContext("2d");
719
+ if (!context) {
720
+ continue;
721
+ }
722
+ context.fillStyle = "#fff";
723
+ context.fillRect(0, 0, width, height);
724
+ context.drawImage(decoded.source, 0, 0, width, height);
725
+ for (const quality of qualities) {
726
+ const blob = await canvasToBlob(canvas, "image/jpeg", quality);
727
+ if (!blob) {
728
+ continue;
729
+ }
730
+ if (blob.size <= hardLimitBytes && (!bestBlob || blob.size < bestBlob.size)) {
731
+ bestBlob = blob;
732
+ }
733
+ if (blob.size <= targetBytes) {
734
+ return blobToDataUrl(blob);
735
+ }
736
+ }
737
+ }
738
+ if (bestBlob && (file.size > hardLimitBytes || bestBlob.size < file.size)) {
739
+ return blobToDataUrl(bestBlob);
740
+ }
741
+ return file.size <= hardLimitBytes ? fileToDataUrl(file) : undefined;
742
+ }
743
+ finally {
744
+ decoded.close?.();
745
+ }
746
+ }
747
+ async function addInlinePreviewForChatUpload(input) {
748
+ const remainingBudgetBytes = CHAT_INLINE_TOTAL_BUDGET_BYTES - input.currentInlineBytes;
749
+ const transportData = await createCompressedImageDataUrl(input.file, remainingBudgetBytes).catch(() => undefined);
750
+ return transportData ? { ...input.attachment, transportData } : input.attachment;
751
+ }
439
752
  async function uploadAttachment(input) {
440
- const presignResponse = await fetch("/api/v1/user/me/attachments/presign", {
753
+ const maxBytes = input.maxBytes ?? MY_FILES_UPLOAD_LIMIT_BYTES;
754
+ const limitLabel = input.limitLabel ?? "50 MB";
755
+ if (input.file.size > maxBytes) {
756
+ throw new Error(`File uploads can be at most ${limitLabel}.`);
757
+ }
758
+ const uploadFormData = new FormData();
759
+ uploadFormData.append("file", input.file);
760
+ uploadFormData.append("folder_path", input.folderPath || "");
761
+ const uploadResponse = await fetch("/api/v1/user/me/attachments/upload", {
762
+ method: "POST",
763
+ headers: buildAuthHeaders(input.apiKey),
764
+ body: uploadFormData
765
+ });
766
+ const uploadPayload = await parseJson(uploadResponse);
767
+ if (!uploadResponse.ok || !uploadPayload.attachment) {
768
+ throw new Error(uploadPayload.error || "Unable to upload attachment.");
769
+ }
770
+ return normalizeUploadedAttachment(uploadPayload.attachment) ?? (() => {
771
+ throw new Error("Attachment response was incomplete.");
772
+ })();
773
+ }
774
+ async function listMyFiles(apiKey) {
775
+ const response = await fetch("/api/v1/user/me/attachments", {
776
+ headers: buildAuthHeaders(apiKey)
777
+ });
778
+ const payload = await parseJson(response);
779
+ if (!response.ok) {
780
+ throw new Error(payload.error || "Unable to load My Files.");
781
+ }
782
+ const attachments = [];
783
+ for (const entry of payload.attachments ?? []) {
784
+ const normalized = normalizeUploadedAttachment(entry);
785
+ if (normalized) {
786
+ attachments.push({ ...normalized, createdAt: entry.createdAt ?? entry.created_at });
787
+ }
788
+ }
789
+ return {
790
+ attachments,
791
+ folders: payload.folders ?? []
792
+ };
793
+ }
794
+ async function uploadTemporaryFile(input) {
795
+ if (input.file.size > 100 * 1024 * 1024) {
796
+ throw new Error("Temporary files can be at most 100 MB.");
797
+ }
798
+ const presignResponse = await fetch("/api/v1/user/me/temporary-files/presign", {
441
799
  method: "POST",
442
800
  headers: buildAuthHeaders(input.apiKey, "application/json"),
443
801
  body: JSON.stringify({
444
802
  file_name: input.file.name,
445
803
  content_type: input.file.type || undefined,
446
- size_bytes: input.file.size
804
+ size_bytes: input.file.size,
805
+ folder_path: input.folderPath || undefined
447
806
  })
448
807
  });
449
808
  const presignPayload = await parseJson(presignResponse);
450
809
  if (!presignResponse.ok || !("transport" in presignPayload)) {
451
- throw new Error(("error" in presignPayload && presignPayload.error) || "Unable to prepare upload.");
810
+ throw new Error(("error" in presignPayload && presignPayload.error) || "Unable to prepare temporary upload.");
452
811
  }
453
812
  if (presignPayload.transport === "server") {
454
813
  const uploadFormData = new FormData();
455
814
  uploadFormData.append(presignPayload.upload.form_field || "file", input.file);
815
+ uploadFormData.append("folder_path", input.folderPath || "");
456
816
  const uploadResponse = await fetch(presignPayload.upload.url, {
457
817
  method: presignPayload.upload.method || "POST",
458
818
  headers: buildAuthHeaders(input.apiKey),
459
819
  body: uploadFormData
460
820
  });
461
821
  const uploadPayload = await parseJson(uploadResponse);
462
- if (!uploadResponse.ok || !uploadPayload.attachment) {
463
- throw new Error(uploadPayload.error || "Unable to upload attachment.");
822
+ if (!uploadResponse.ok || !uploadPayload.file) {
823
+ throw new Error(uploadPayload.error || "Unable to upload temporary file.");
464
824
  }
465
- return normalizeUploadedAttachment(uploadPayload.attachment) ?? (() => {
466
- throw new Error("Attachment response was incomplete.");
467
- })();
825
+ const normalized = normalizeUploadedAttachment(uploadPayload.file);
826
+ if (!normalized) {
827
+ throw new Error("Temporary file response was incomplete.");
828
+ }
829
+ return {
830
+ ...normalized,
831
+ createdAt: uploadPayload.file.createdAt ?? uploadPayload.file.created_at,
832
+ expiresAt: uploadPayload.file.expiresAt ?? uploadPayload.file.expires_at,
833
+ s3Url: uploadPayload.file.s3Url ?? uploadPayload.file.s3_url
834
+ };
468
835
  }
469
836
  const uploadResponse = await fetch(presignPayload.upload.url, {
470
837
  method: presignPayload.upload.method,
@@ -474,45 +841,166 @@ async function uploadAttachment(input) {
474
841
  if (!uploadResponse.ok) {
475
842
  throw new Error(`Upload failed with status ${uploadResponse.status}.`);
476
843
  }
477
- const finalizeResponse = await fetch("/api/v1/user/me/attachments", {
844
+ const finalizeResponse = await fetch("/api/v1/user/me/temporary-files", {
478
845
  method: "POST",
479
846
  headers: buildAuthHeaders(input.apiKey, "application/json"),
480
847
  body: JSON.stringify({
481
- attachment_id: presignPayload.attachment_id,
848
+ file_id: presignPayload.file_id,
482
849
  file_name: presignPayload.file_name,
483
850
  content_type: presignPayload.content_type,
484
851
  size_bytes: input.file.size,
485
- storage_key: presignPayload.storage_key
852
+ storage_key: presignPayload.storage_key,
853
+ folder_path: presignPayload.folder_path || input.folderPath || ""
486
854
  })
487
855
  });
488
856
  const finalizePayload = await parseJson(finalizeResponse);
489
- if (!finalizeResponse.ok || !finalizePayload.attachment) {
490
- throw new Error(finalizePayload.error || "Unable to save uploaded attachment.");
857
+ if (!finalizeResponse.ok || !finalizePayload.file) {
858
+ throw new Error(finalizePayload.error || "Unable to save temporary file.");
491
859
  }
492
- return normalizeUploadedAttachment(finalizePayload.attachment) ?? (() => {
493
- throw new Error("Attachment response was incomplete.");
494
- })();
860
+ const normalized = normalizeUploadedAttachment(finalizePayload.file);
861
+ if (!normalized) {
862
+ throw new Error("Temporary file response was incomplete.");
863
+ }
864
+ return {
865
+ ...normalized,
866
+ createdAt: finalizePayload.file.createdAt ?? finalizePayload.file.created_at,
867
+ expiresAt: finalizePayload.file.expiresAt ?? finalizePayload.file.expires_at,
868
+ s3Url: finalizePayload.file.s3Url ?? finalizePayload.file.s3_url
869
+ };
870
+ }
871
+ async function listTemporaryFiles(apiKey) {
872
+ const response = await fetch("/api/v1/user/me/temporary-files", {
873
+ headers: buildAuthHeaders(apiKey)
874
+ });
875
+ const payload = await parseJson(response);
876
+ if (!response.ok) {
877
+ throw new Error(payload.error || "Unable to load temporary files.");
878
+ }
879
+ const files = [];
880
+ for (const entry of payload.files ?? []) {
881
+ const normalized = normalizeUploadedAttachment(entry);
882
+ if (normalized) {
883
+ files.push({
884
+ ...normalized,
885
+ createdAt: entry.createdAt ?? entry.created_at,
886
+ expiresAt: entry.expiresAt ?? entry.expires_at,
887
+ s3Url: entry.s3Url ?? entry.s3_url
888
+ });
889
+ }
890
+ }
891
+ return {
892
+ files,
893
+ folders: payload.folders ?? []
894
+ };
895
+ }
896
+ async function listGeneratedMedia(apiKey, cursor) {
897
+ const url = new URL("/api/v1/user/me/generated-media", window.location.origin);
898
+ url.searchParams.set("limit", "24");
899
+ if (cursor) {
900
+ url.searchParams.set("cursor", cursor);
901
+ }
902
+ const response = await fetch(`${url.pathname}${url.search}`, {
903
+ headers: buildAuthHeaders(apiKey)
904
+ });
905
+ const payload = await parseJson(response);
906
+ if (!response.ok) {
907
+ throw new Error(payload.error || "Unable to load recent media.");
908
+ }
909
+ const media = [];
910
+ for (const entry of payload.media ?? []) {
911
+ const normalized = normalizeUploadedAttachment(entry);
912
+ if (normalized) {
913
+ media.push({
914
+ ...normalized,
915
+ createdAt: entry.createdAt ?? entry.created_at,
916
+ jobId: typeof entry.job_id === "string" ? entry.job_id : undefined,
917
+ tracer: typeof entry.tracer === "string" ? entry.tracer : undefined,
918
+ templateId: typeof entry.template_id === "string" ? entry.template_id : undefined
919
+ });
920
+ }
921
+ }
922
+ return {
923
+ media,
924
+ nextCursor: payload.next_cursor ?? null
925
+ };
926
+ }
927
+ function estimateChatMessageChars(messages) {
928
+ return messages.reduce((total, message) => {
929
+ const attachmentChars = message.attachments.reduce((sum, attachment) => (sum + attachment.fileName.length + attachment.contentType.length + 32), 0);
930
+ return total + message.text.length + attachmentChars;
931
+ }, 0);
932
+ }
933
+ function truncateTransportSummaryText(value, maxChars) {
934
+ const normalized = value.trim().replace(/\s+/g, " ");
935
+ if (normalized.length <= maxChars) {
936
+ return normalized;
937
+ }
938
+ return `${normalized.slice(0, Math.max(0, maxChars - 32)).trimEnd()}… [truncated]`;
939
+ }
940
+ function compactTransportHistory(messages) {
941
+ if (messages.length <= CHAT_TRANSPORT_RECENT_MESSAGES
942
+ || estimateChatMessageChars(messages) <= CHAT_TRANSPORT_COMPACT_CHAR_THRESHOLD) {
943
+ return messages;
944
+ }
945
+ const recentMessages = messages.slice(-CHAT_TRANSPORT_RECENT_MESSAGES);
946
+ const oldMessages = messages.slice(0, -CHAT_TRANSPORT_RECENT_MESSAGES);
947
+ const summaryLines = [];
948
+ let summaryChars = 0;
949
+ for (const message of oldMessages) {
950
+ const attachmentsText = message.attachments.length
951
+ ? ` Attachments: ${message.attachments.map((attachment) => `${attachment.fileName} (${attachment.contentType})`).join(", ")}.`
952
+ : "";
953
+ const line = `[${message.role}]: ${truncateTransportSummaryText(`${message.text}${attachmentsText}`, CHAT_TRANSPORT_MAX_MESSAGE_CHARS)}`;
954
+ if (summaryChars + line.length > CHAT_TRANSPORT_MAX_SUMMARY_CHARS) {
955
+ summaryLines.push(`[${oldMessages.length - summaryLines.length} earlier messages omitted]`);
956
+ break;
957
+ }
958
+ summaryLines.push(line);
959
+ summaryChars += line.length;
960
+ }
961
+ return [
962
+ {
963
+ id: makeMessageId("assistant"),
964
+ role: "assistant",
965
+ text: [
966
+ "[Earlier in this conversation - compacted summary]",
967
+ "Older turns were summarized before sending this request so the chat can continue efficiently.",
968
+ "",
969
+ summaryLines.join("\n\n")
970
+ ].join("\n"),
971
+ attachments: []
972
+ },
973
+ ...recentMessages
974
+ ];
495
975
  }
496
- function toTransportMessages(messages, systemMessages) {
976
+ function buildAttachmentTransportText(attachments) {
977
+ if (!attachments.length) {
978
+ return null;
979
+ }
980
+ const lines = attachments.map((attachment) => (`- ${attachment.fileName} (${attachment.contentType}): ${attachment.viewUrl}`));
497
981
  return [
498
- ...systemMessages
499
- .filter((entry) => entry.trim().length > 0)
500
- .map((entry) => ({
501
- role: "system",
502
- content: entry
503
- })),
504
- ...messages.map((message) => ({
982
+ "Attached file URLs for REST payloads:",
983
+ ...lines,
984
+ "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."
985
+ ].join("\n");
986
+ }
987
+ function toTransportMessages(messages) {
988
+ const latestIndex = messages.length - 1;
989
+ return messages.map((message, messageIndex) => {
990
+ const attachmentText = buildAttachmentTransportText(message.attachments);
991
+ return {
505
992
  role: message.role,
506
993
  content: [
507
994
  ...(message.text ? [{ type: "text", text: message.text }] : []),
995
+ ...(attachmentText ? [{ type: "text", text: attachmentText }] : []),
508
996
  ...message.attachments.map((attachment) => ({
509
997
  type: "file",
510
- data: attachment.transportData || attachment.viewUrl,
998
+ data: messageIndex === latestIndex ? attachment.transportData || attachment.viewUrl : attachment.viewUrl,
511
999
  mediaType: attachment.contentType
512
1000
  }))
513
1001
  ]
514
- }))
515
- ];
1002
+ };
1003
+ });
516
1004
  }
517
1005
  function readCachedScheduleChannels() {
518
1006
  const cache = window.__vidfarmFlockPosterCache;
@@ -755,6 +1243,14 @@ function readTracersFromLocation() {
755
1243
  }
756
1244
  return normalizeTracerList(output);
757
1245
  }
1246
+ function readThreadIdFromLocation() {
1247
+ const params = new URLSearchParams(window.location.search);
1248
+ return (params.get("thread_id") ||
1249
+ params.get("threadId") ||
1250
+ params.get("chat_id") ||
1251
+ params.get("chatId") ||
1252
+ "").trim();
1253
+ }
758
1254
  function formatThreadTitle(text) {
759
1255
  const normalized = text.trim().replace(/\s+/g, " ");
760
1256
  if (!normalized) {
@@ -762,15 +1258,18 @@ function formatThreadTitle(text) {
762
1258
  }
763
1259
  return normalized.length > 42 ? `${normalized.slice(0, 42).trimEnd()}…` : normalized;
764
1260
  }
765
- function serializeThreads(threads) {
1261
+ function serializeThreads(threads, activeThreadId) {
766
1262
  return threads.map((thread) => ({
767
1263
  id: thread.id,
1264
+ templateId: thread.templateId,
768
1265
  title: thread.title,
769
1266
  createdAt: thread.createdAt,
770
1267
  updatedAt: thread.updatedAt,
1268
+ lastMessageAt: thread.lastMessageAt ?? null,
1269
+ messageCount: Math.max(thread.messageCount ?? 0, thread.messages.length),
771
1270
  tracers: thread.tracers,
772
1271
  hiddenFromView: thread.hiddenFromView,
773
- messages: thread.messages.map((message) => ({
1272
+ messages: (thread.id === activeThreadId ? thread.messages.slice(-LOCAL_THREAD_MESSAGE_CACHE_LIMIT) : []).map((message) => ({
774
1273
  id: message.id,
775
1274
  role: message.role,
776
1275
  text: message.text,
@@ -789,7 +1288,7 @@ function serializeThreads(threads) {
789
1288
  }))
790
1289
  }));
791
1290
  }
792
- function hydrateThreads(raw) {
1291
+ function hydrateThreads(raw, fallbackTemplateId) {
793
1292
  if (!raw) {
794
1293
  return [];
795
1294
  }
@@ -802,9 +1301,12 @@ function hydrateThreads(raw) {
802
1301
  .filter((thread) => thread && typeof thread.id === "string")
803
1302
  .map((thread) => ({
804
1303
  id: thread.id,
1304
+ templateId: typeof thread.templateId === "string" && thread.templateId.trim() ? thread.templateId : fallbackTemplateId,
805
1305
  title: typeof thread.title === "string" && thread.title.trim() ? thread.title : "New chat",
806
1306
  createdAt: typeof thread.createdAt === "string" ? thread.createdAt : new Date().toISOString(),
807
1307
  updatedAt: typeof thread.updatedAt === "string" ? thread.updatedAt : new Date().toISOString(),
1308
+ lastMessageAt: typeof thread.lastMessageAt === "string" ? thread.lastMessageAt : null,
1309
+ messageCount: Math.max(typeof thread.messageCount === "number" ? thread.messageCount : 0, Array.isArray(thread.messages) ? thread.messages.length : 0),
808
1310
  tracers: normalizeTracerList(Array.isArray(thread.tracers) ? thread.tracers.filter((value) => typeof value === "string") : []),
809
1311
  hiddenFromView: thread.hiddenFromView === true,
810
1312
  messages: Array.isArray(thread.messages)
@@ -823,15 +1325,18 @@ function hydrateThreads(raw) {
823
1325
  return [];
824
1326
  }
825
1327
  }
826
- function hydrateThreadRecord(thread) {
1328
+ function hydrateThreadRecord(thread, fallbackTemplateId) {
827
1329
  if (!thread || typeof thread.id !== "string") {
828
1330
  return null;
829
1331
  }
830
1332
  return {
831
1333
  id: thread.id,
1334
+ templateId: typeof thread.templateId === "string" && thread.templateId.trim() ? thread.templateId : fallbackTemplateId,
832
1335
  title: typeof thread.title === "string" && thread.title.trim() ? thread.title : "New chat",
833
1336
  createdAt: typeof thread.createdAt === "string" ? thread.createdAt : new Date().toISOString(),
834
1337
  updatedAt: typeof thread.updatedAt === "string" ? thread.updatedAt : new Date().toISOString(),
1338
+ lastMessageAt: typeof thread.lastMessageAt === "string" ? thread.lastMessageAt : null,
1339
+ messageCount: Math.max(typeof thread.messageCount === "number" ? thread.messageCount : 0, Array.isArray(thread.messages) ? thread.messages.length : 0),
835
1340
  tracers: normalizeTracerList(Array.isArray(thread.tracers) ? thread.tracers.filter((value) => typeof value === "string") : []),
836
1341
  hiddenFromView: thread.hiddenFromView === true,
837
1342
  messages: Array.isArray(thread.messages)
@@ -849,7 +1354,13 @@ function hydrateThreadRecord(thread) {
849
1354
  function mergeThreadSets(localThreads, remoteThreads) {
850
1355
  const byId = new Map();
851
1356
  for (const thread of remoteThreads) {
852
- byId.set(thread.id, thread);
1357
+ const localThread = localThreads.find((entry) => entry.id === thread.id);
1358
+ byId.set(thread.id, {
1359
+ ...thread,
1360
+ lastMessageAt: thread.lastMessageAt ?? localThread?.lastMessageAt ?? null,
1361
+ messageCount: Math.max(thread.messageCount ?? 0, localThread?.messageCount ?? 0, thread.messages.length, localThread?.messages.length ?? 0),
1362
+ messages: thread.messages.length ? thread.messages : (localThread?.messages ?? [])
1363
+ });
853
1364
  }
854
1365
  for (const thread of localThreads) {
855
1366
  if (!byId.has(thread.id)) {
@@ -858,6 +1369,9 @@ function mergeThreadSets(localThreads, remoteThreads) {
858
1369
  }
859
1370
  return [...byId.values()].sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt));
860
1371
  }
1372
+ function sortThreadsByUpdatedAt(threads) {
1373
+ return [...threads].sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt));
1374
+ }
861
1375
  function threadMatchesTracers(thread, selectedTracers) {
862
1376
  if (!selectedTracers.length) {
863
1377
  return true;
@@ -894,19 +1408,128 @@ function summarizeThreadPreview(thread) {
894
1408
  function AttachmentChip(props) {
895
1409
  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] }));
896
1410
  }
1411
+ function DownloadIcon() {
1412
+ 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" })] }));
1413
+ }
1414
+ function PaintBrushIcon() {
1415
+ 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" })] }));
1416
+ }
1417
+ function LinkIcon() {
1418
+ 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" })] }));
1419
+ }
1420
+ function CopyIcon() {
1421
+ 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" })] }));
1422
+ }
1423
+ function ChevronIcon(props) {
1424
+ 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" }) }));
1425
+ }
1426
+ function mediaDownloadName(preview) {
1427
+ const cleanLabel = preview.label.trim().replace(/[\\/:*?"<>|]+/g, "-");
1428
+ if (cleanLabel) {
1429
+ return cleanLabel;
1430
+ }
1431
+ try {
1432
+ const pathname = new URL(preview.url, window.location.href).pathname;
1433
+ const name = pathname.split("/").filter(Boolean).pop();
1434
+ return name || "media";
1435
+ }
1436
+ catch {
1437
+ return "media";
1438
+ }
1439
+ }
1440
+ function mediaInpaintHref(preview, ensureTracer = false) {
1441
+ if (typeof window === "undefined" || preview.kind !== "image" || !preview.url) {
1442
+ return "";
1443
+ }
1444
+ const pageUrl = new URL("/inpaint", window.location.origin);
1445
+ const bridge = window.__vidfarmEditorTracers;
1446
+ const existingTracers = bridge?.get() ?? readTracersFromLocation();
1447
+ const tracer = existingTracers[0] || (ensureTracer ? bridge?.add() ?? "" : "");
1448
+ if (tracer) {
1449
+ pageUrl.searchParams.set("tracer", tracer);
1450
+ }
1451
+ pageUrl.searchParams.set("source_image_url", preview.url);
1452
+ pageUrl.searchParams.set("source_image_name", preview.label || mediaDownloadName(preview));
1453
+ return pageUrl.toString();
1454
+ }
897
1455
  function MediaPreviewModal(props) {
898
1456
  const [isMounted, setIsMounted] = useState(false);
1457
+ const [copyStatus, setCopyStatus] = useState("");
1458
+ const { onClose } = props;
1459
+ const gallery = props.gallery?.length ? props.gallery : [props.preview];
1460
+ const initialIndex = Math.max(0, gallery.findIndex((preview) => preview.url === props.preview.url));
1461
+ const [activeIndex, setActiveIndex] = useState(initialIndex);
1462
+ const activePreview = gallery[activeIndex] || props.preview;
1463
+ const canNavigate = gallery.length > 1;
1464
+ const canInpaint = activePreview.kind === "image";
1465
+ const initialInpaintHref = canInpaint ? mediaInpaintHref(activePreview, false) || "/inpaint" : "/inpaint";
899
1466
  useEffect(() => {
900
1467
  setIsMounted(true);
901
1468
  }, []);
1469
+ useEffect(() => {
1470
+ setCopyStatus("");
1471
+ }, [activePreview.url]);
1472
+ const handleCopyLink = async () => {
1473
+ try {
1474
+ if (navigator.clipboard?.writeText) {
1475
+ await navigator.clipboard.writeText(activePreview.url);
1476
+ }
1477
+ else {
1478
+ const field = document.createElement("textarea");
1479
+ field.value = activePreview.url;
1480
+ field.style.position = "fixed";
1481
+ field.style.opacity = "0";
1482
+ document.body.appendChild(field);
1483
+ field.focus();
1484
+ field.select();
1485
+ document.execCommand("copy");
1486
+ document.body.removeChild(field);
1487
+ }
1488
+ setCopyStatus("done");
1489
+ }
1490
+ catch {
1491
+ setCopyStatus("error");
1492
+ }
1493
+ window.setTimeout(() => setCopyStatus(""), 1600);
1494
+ };
1495
+ const handleOpenInpaint = (event) => {
1496
+ const href = mediaInpaintHref(activePreview, true);
1497
+ if (!href) {
1498
+ event.preventDefault();
1499
+ return;
1500
+ }
1501
+ event.preventDefault();
1502
+ window.open(href, "_blank", "noopener,noreferrer");
1503
+ };
1504
+ useEffect(() => {
1505
+ const handleKeyDown = (event) => {
1506
+ if (event.key === "Escape") {
1507
+ onClose();
1508
+ return;
1509
+ }
1510
+ if (!canNavigate) {
1511
+ return;
1512
+ }
1513
+ if (event.key === "ArrowLeft") {
1514
+ event.preventDefault();
1515
+ setActiveIndex((current) => (current - 1 + gallery.length) % gallery.length);
1516
+ }
1517
+ else if (event.key === "ArrowRight") {
1518
+ event.preventDefault();
1519
+ setActiveIndex((current) => (current + 1) % gallery.length);
1520
+ }
1521
+ };
1522
+ window.addEventListener("keydown", handleKeyDown);
1523
+ return () => window.removeEventListener("keydown", handleKeyDown);
1524
+ }, [canNavigate, gallery.length, onClose]);
902
1525
  if (!isMounted) {
903
1526
  return null;
904
1527
  }
905
- 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: props.preview.url, target: "_blank", rel: "noopener noreferrer", children: props.preview.label }), _jsx("button", { type: "button", className: "vf-editor-chat-media-modal-close", onClick: props.onClose, "aria-label": "Close media preview", children: "\u00D7" })] }), _jsx("div", { className: "vf-editor-chat-media-modal-body", children: props.preview.kind === "image" ? (_jsx("img", { src: props.preview.url, alt: props.preview.label, className: "vf-editor-chat-media-modal-image" })) : (_jsx("video", { src: props.preview.url, className: "vf-editor-chat-media-modal-video", controls: true, autoPlay: true, playsInline: true })) })] }) }), document.body);
1528
+ 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);
906
1529
  }
907
1530
  function ImagePreviewButton(props) {
908
1531
  const [hasError, setHasError] = useState(false);
909
- return (_jsx("button", { type: "button", className: "vf-editor-chat-message-thumb-button is-image", onClick: () => props.onOpenModal(props.preview), "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) })) }));
1532
+ 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) })) }));
910
1533
  }
911
1534
  function VideoPreviewCard(props) {
912
1535
  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" })] }) }));
@@ -915,7 +1538,7 @@ function ImagePreviewRail(props) {
915
1538
  if (!props.previews.length) {
916
1539
  return null;
917
1540
  }
918
- return (_jsx("div", { className: "vf-editor-chat-message-image-rail", "aria-label": "Image previews", children: props.previews.map((preview) => (_jsx(ImagePreviewButton, { preview: preview, onOpenModal: props.onOpenModal }, preview.url))) }));
1541
+ 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))) }));
919
1542
  }
920
1543
  function ComposerAttachmentItem(props) {
921
1544
  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" })] }));
@@ -936,11 +1559,248 @@ function HttpExchangeCard(props) {
936
1559
  }, []);
937
1560
  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] }));
938
1561
  }
1562
+ function formatJsonInlineValue(value) {
1563
+ if (typeof value === "string") {
1564
+ return JSON.stringify(value);
1565
+ }
1566
+ if (typeof value === "number" || typeof value === "boolean") {
1567
+ return String(value);
1568
+ }
1569
+ if (value === null) {
1570
+ return "null";
1571
+ }
1572
+ return JSON.stringify(value);
1573
+ }
1574
+ function copyPlainText(text) {
1575
+ if (navigator.clipboard?.writeText) {
1576
+ return navigator.clipboard.writeText(text);
1577
+ }
1578
+ const field = document.createElement("textarea");
1579
+ field.value = text;
1580
+ field.style.position = "fixed";
1581
+ field.style.opacity = "0";
1582
+ document.body.appendChild(field);
1583
+ field.focus();
1584
+ field.select();
1585
+ document.execCommand("copy");
1586
+ document.body.removeChild(field);
1587
+ return Promise.resolve();
1588
+ }
1589
+ function JsonViewerLine(props) {
1590
+ 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 })] }));
1591
+ }
1592
+ function JsonViewerNode(props) {
1593
+ const value = props.value;
1594
+ const suffix = props.isLast ? "" : ",";
1595
+ if (Array.isArray(value)) {
1596
+ const path = props.path;
1597
+ const isCollapsed = props.collapsedPaths.has(path);
1598
+ const headerText = `${props.linePrefix ?? ""}[`;
1599
+ if (!value.length) {
1600
+ return _jsx(JsonViewerLine, { text: `${props.linePrefix ?? ""}[]${suffix}`, depth: props.depth, onCopy: props.onCopy });
1601
+ }
1602
+ if (isCollapsed) {
1603
+ 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 })] }));
1604
+ }
1605
+ 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 })] }));
1606
+ }
1607
+ if (value && typeof value === "object") {
1608
+ const entries = Object.entries(value);
1609
+ const path = props.path;
1610
+ const isCollapsed = props.collapsedPaths.has(path);
1611
+ const headerText = `${props.linePrefix ?? ""}{`;
1612
+ if (!entries.length) {
1613
+ return _jsx(JsonViewerLine, { text: `${props.linePrefix ?? ""}{}${suffix}`, depth: props.depth, onCopy: props.onCopy });
1614
+ }
1615
+ if (isCollapsed) {
1616
+ 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 })] }));
1617
+ }
1618
+ 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) => {
1619
+ const childPrefix = `${JSON.stringify(key)}: `;
1620
+ if (entry && typeof entry === "object") {
1621
+ 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}`));
1622
+ }
1623
+ return (_jsx(JsonViewerLine, { text: `${childPrefix}${formatJsonInlineValue(entry)}${index === entries.length - 1 ? "" : ","}`, depth: props.depth + 1, onCopy: props.onCopy }, `${path}:${key}`));
1624
+ }), _jsx(JsonViewerLine, { text: `}${suffix}`, depth: props.depth, onCopy: props.onCopy })] }));
1625
+ }
1626
+ return (_jsx(JsonViewerLine, { text: `${props.linePrefix ?? ""}${formatJsonInlineValue(value)}${suffix}`, depth: props.depth, onCopy: props.onCopy }));
1627
+ }
1628
+ function JsonViewer(props) {
1629
+ const [collapsedPaths, setCollapsedPaths] = useState(new Set());
1630
+ const [copyState, setCopyState] = useState("");
1631
+ const togglePath = (path) => {
1632
+ setCollapsedPaths((current) => {
1633
+ const next = new Set(current);
1634
+ if (next.has(path)) {
1635
+ next.delete(path);
1636
+ }
1637
+ else {
1638
+ next.add(path);
1639
+ }
1640
+ return next;
1641
+ });
1642
+ };
1643
+ const handleCopy = async (text) => {
1644
+ try {
1645
+ await copyPlainText(text);
1646
+ setCopyState("done");
1647
+ }
1648
+ catch {
1649
+ setCopyState("error");
1650
+ }
1651
+ window.setTimeout(() => setCopyState(""), 1400);
1652
+ };
1653
+ 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 }) })] }));
1654
+ }
1655
+ function JsonArtifactModal(props) {
1656
+ const [isMounted, setIsMounted] = useState(false);
1657
+ const [copyStatus, setCopyStatus] = useState("");
1658
+ const downloadName = downloadNameFromUrl(props.artifact.url, `${props.artifact.label}.json`);
1659
+ const formattedData = formatJsonBody(props.data);
1660
+ useEffect(() => {
1661
+ setIsMounted(true);
1662
+ }, []);
1663
+ const handleCopy = async () => {
1664
+ try {
1665
+ await copyPlainText(formattedData);
1666
+ setCopyStatus("done");
1667
+ }
1668
+ catch {
1669
+ setCopyStatus("error");
1670
+ }
1671
+ window.setTimeout(() => setCopyStatus(""), 1600);
1672
+ };
1673
+ useEffect(() => {
1674
+ const handleKeyDown = (event) => {
1675
+ if (event.key === "Escape") {
1676
+ props.onClose();
1677
+ }
1678
+ };
1679
+ window.addEventListener("keydown", handleKeyDown);
1680
+ return () => window.removeEventListener("keydown", handleKeyDown);
1681
+ }, [props]);
1682
+ if (!isMounted) {
1683
+ return null;
1684
+ }
1685
+ 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);
1686
+ }
1687
+ function JsonArtifactCard(props) {
1688
+ const [isExpanded, setIsExpanded] = useState(false);
1689
+ const [isModalOpen, setIsModalOpen] = useState(false);
1690
+ const [data, setData] = useState(null);
1691
+ const [error, setError] = useState(null);
1692
+ const [loading, setLoading] = useState(false);
1693
+ const ensureLoaded = async () => {
1694
+ if (data !== null || loading) {
1695
+ return;
1696
+ }
1697
+ setLoading(true);
1698
+ setError(null);
1699
+ try {
1700
+ const response = await fetch(props.artifact.url, { credentials: "omit" });
1701
+ const payload = await response.json();
1702
+ setData(payload);
1703
+ }
1704
+ catch (nextError) {
1705
+ setError(nextError instanceof Error ? nextError.message : "Unable to load JSON artifact.");
1706
+ }
1707
+ finally {
1708
+ setLoading(false);
1709
+ }
1710
+ };
1711
+ const handleToggleExpand = () => {
1712
+ const next = !isExpanded;
1713
+ setIsExpanded(next);
1714
+ if (next) {
1715
+ void ensureLoaded();
1716
+ }
1717
+ };
1718
+ const handleOpenModal = () => {
1719
+ void ensureLoaded();
1720
+ setIsModalOpen(true);
1721
+ };
1722
+ 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] }));
1723
+ }
1724
+ function TextArtifactViewer(props) {
1725
+ if (props.format === "markdown") {
1726
+ return (_jsx("div", { className: "vf-editor-chat-doc-markdown", dangerouslySetInnerHTML: { __html: renderMarkdownToHtml(props.text) } }));
1727
+ }
1728
+ return _jsx("pre", { className: "vf-editor-chat-doc-pre", children: props.text });
1729
+ }
1730
+ function DocumentArtifactModal(props) {
1731
+ const [isMounted, setIsMounted] = useState(false);
1732
+ const [copyStatus, setCopyStatus] = useState("");
1733
+ const fallbackExtension = props.artifact.format === "markdown" ? ".md" : ".txt";
1734
+ const downloadName = downloadNameFromUrl(props.artifact.url, `${props.artifact.label}${fallbackExtension}`);
1735
+ useEffect(() => {
1736
+ setIsMounted(true);
1737
+ }, []);
1738
+ const handleCopy = async () => {
1739
+ try {
1740
+ await copyPlainText(props.text);
1741
+ setCopyStatus("done");
1742
+ }
1743
+ catch {
1744
+ setCopyStatus("error");
1745
+ }
1746
+ window.setTimeout(() => setCopyStatus(""), 1600);
1747
+ };
1748
+ useEffect(() => {
1749
+ const handleKeyDown = (event) => {
1750
+ if (event.key === "Escape") {
1751
+ props.onClose();
1752
+ }
1753
+ };
1754
+ window.addEventListener("keydown", handleKeyDown);
1755
+ return () => window.removeEventListener("keydown", handleKeyDown);
1756
+ }, [props]);
1757
+ if (!isMounted) {
1758
+ return null;
1759
+ }
1760
+ 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);
1761
+ }
1762
+ function DocumentArtifactCard(props) {
1763
+ const [isExpanded, setIsExpanded] = useState(false);
1764
+ const [isModalOpen, setIsModalOpen] = useState(false);
1765
+ const [text, setText] = useState(null);
1766
+ const [error, setError] = useState(null);
1767
+ const [loading, setLoading] = useState(false);
1768
+ const ensureLoaded = async () => {
1769
+ if (text !== null || loading) {
1770
+ return;
1771
+ }
1772
+ setLoading(true);
1773
+ setError(null);
1774
+ try {
1775
+ const response = await fetch(props.artifact.url, { credentials: "omit" });
1776
+ const payload = await response.text();
1777
+ setText(payload);
1778
+ }
1779
+ catch (nextError) {
1780
+ setError(nextError instanceof Error ? nextError.message : "Unable to load document artifact.");
1781
+ }
1782
+ finally {
1783
+ setLoading(false);
1784
+ }
1785
+ };
1786
+ const handleToggleExpand = () => {
1787
+ const next = !isExpanded;
1788
+ setIsExpanded(next);
1789
+ if (next) {
1790
+ void ensureLoaded();
1791
+ }
1792
+ };
1793
+ const handleOpenModal = () => {
1794
+ void ensureLoaded();
1795
+ setIsModalOpen(true);
1796
+ };
1797
+ 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] }));
1798
+ }
939
1799
  function ChatBubble(props) {
940
1800
  const isAssistant = props.message.role === "assistant";
941
1801
  const hasVisibleText = props.message.text.trim().length > 0;
942
1802
  const renderedText = hasVisibleText ? renderMarkdownToHtml(props.message.text) : "";
943
- const [mediaModalPreview, setMediaModalPreview] = useState(null);
1803
+ const [mediaModalState, setMediaModalState] = useState(null);
944
1804
  const imageAttachments = props.message.attachments.filter(isImageAttachment);
945
1805
  const videoAttachments = props.message.attachments.filter(isVideoAttachment);
946
1806
  const fileAttachments = props.message.attachments.filter((attachment) => !isImageAttachment(attachment) && !isVideoAttachment(attachment));
@@ -949,29 +1809,426 @@ function ChatBubble(props) {
949
1809
  : [];
950
1810
  const inlineImagePreviews = inlineMediaPreviews.filter((preview) => preview.kind === "image");
951
1811
  const inlineVideoPreviews = inlineMediaPreviews.filter((preview) => preview.kind === "video");
1812
+ const brainstormJsonArtifacts = (props.message.httpExchanges ?? []).flatMap((exchange) => {
1813
+ const job = tryReadJobStatusPayload(exchange.result.body);
1814
+ return job ? extractBrainstormJsonArtifactsFromJob(job) : [];
1815
+ }).filter((artifact, index, items) => items.findIndex((entry) => entry.url === artifact.url) === index);
1816
+ const brainstormDocumentArtifacts = (props.message.httpExchanges ?? []).flatMap((exchange) => {
1817
+ const job = tryReadJobStatusPayload(exchange.result.body);
1818
+ return job ? extractBrainstormDocumentArtifactsFromJob(job) : [];
1819
+ }).filter((artifact, index, items) => items.findIndex((entry) => entry.url === artifact.url) === index);
952
1820
  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) => ({
953
1821
  kind: "image",
954
1822
  url: attachment.viewUrl,
955
1823
  label: attachment.fileName
956
- })), onOpenModal: setMediaModalPreview }), videoAttachments.map((attachment) => (_jsx(VideoPreviewCard, { preview: {
1824
+ })), onOpenModal: (preview, gallery) => setMediaModalState({ preview, gallery }) }), videoAttachments.map((attachment) => (_jsx(VideoPreviewCard, { preview: {
957
1825
  kind: "video",
958
1826
  url: attachment.viewUrl,
959
1827
  label: attachment.fileName
960
- }, onOpenModal: setMediaModalPreview }, 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: setMediaModalPreview }), inlineVideoPreviews.map((preview) => (_jsx(VideoPreviewCard, { preview: preview, onOpenModal: setMediaModalPreview }, preview.url)))] })) : null] }), mediaModalPreview ? (_jsx(MediaPreviewModal, { preview: mediaModalPreview, onClose: () => setMediaModalPreview(null) })) : null] }));
1828
+ }, 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] }));
1829
+ }
1830
+ function formatBytes(bytes) {
1831
+ if (!bytes || bytes <= 0) {
1832
+ return "Unknown size";
1833
+ }
1834
+ const units = ["B", "KB", "MB", "GB"];
1835
+ let value = bytes;
1836
+ let unitIndex = 0;
1837
+ while (value >= 1024 && unitIndex < units.length - 1) {
1838
+ value /= 1024;
1839
+ unitIndex += 1;
1840
+ }
1841
+ return `${value >= 10 || unitIndex === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[unitIndex]}`;
1842
+ }
1843
+ function FilePreview(props) {
1844
+ if (isImageAttachment(props.file)) {
1845
+ return _jsx("img", { className: "vf-editor-upload-file-thumb", src: props.file.viewUrl, alt: props.file.fileName });
1846
+ }
1847
+ if (isVideoAttachment(props.file)) {
1848
+ return _jsx("video", { className: "vf-editor-upload-file-thumb", src: props.file.viewUrl, muted: true, playsInline: true });
1849
+ }
1850
+ return _jsx("div", { className: "vf-editor-upload-file-icon", "aria-hidden": "true", children: props.file.fileName.slice(0, 1).toUpperCase() });
1851
+ }
1852
+ function normalizeFolderPath(value) {
1853
+ return value
1854
+ .split("/")
1855
+ .map((segment) => segment.trim())
1856
+ .filter(Boolean)
1857
+ .join("/");
1858
+ }
1859
+ function joinFolderPath(parent, name) {
1860
+ return normalizeFolderPath([parent, name].filter(Boolean).join("/"));
1861
+ }
1862
+ function parentFolderPath(value) {
1863
+ const parts = normalizeFolderPath(value).split("/").filter(Boolean);
1864
+ parts.pop();
1865
+ return parts.join("/");
1866
+ }
1867
+ function folderDisplayName(value) {
1868
+ const parts = normalizeFolderPath(value).split("/").filter(Boolean);
1869
+ return parts.at(-1) || "All files";
1870
+ }
1871
+ function getImmediateFolders(input) {
1872
+ const current = normalizeFolderPath(input.currentFolder);
1873
+ const prefix = current ? `${current}/` : "";
1874
+ const names = new Map();
1875
+ for (const folder of input.folders) {
1876
+ const normalized = normalizeFolderPath(folder);
1877
+ if (!normalized || (current && normalized === current) || !normalized.startsWith(prefix)) {
1878
+ continue;
1879
+ }
1880
+ const rest = normalized.slice(prefix.length);
1881
+ const childName = rest.split("/")[0];
1882
+ if (childName) {
1883
+ names.set(childName, joinFolderPath(current, childName));
1884
+ }
1885
+ }
1886
+ for (const file of input.files) {
1887
+ const normalized = normalizeFolderPath(file.folderPath ?? "");
1888
+ if (!normalized || (current && normalized === current) || !normalized.startsWith(prefix)) {
1889
+ continue;
1890
+ }
1891
+ const rest = normalized.slice(prefix.length);
1892
+ const childName = rest.split("/")[0];
1893
+ if (childName) {
1894
+ names.set(childName, joinFolderPath(current, childName));
1895
+ }
1896
+ }
1897
+ return Array.from(names.entries())
1898
+ .map(([name, path]) => ({ name, path }))
1899
+ .sort((a, b) => a.name.localeCompare(b.name));
1900
+ }
1901
+ function UploadActionIcon(props) {
1902
+ if (props.name === "share") {
1903
+ 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" })] }));
1904
+ }
1905
+ if (props.name === "link") {
1906
+ 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" })] }));
1907
+ }
1908
+ 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" })] }));
1909
+ }
1910
+ function UploadDrawer(props) {
1911
+ const [tab, setTab] = useState("my-files");
1912
+ const [uploadSelected, setUploadSelected] = useState([]);
1913
+ const [myFiles, setMyFiles] = useState([]);
1914
+ const [myFolders, setMyFolders] = useState([]);
1915
+ const [mySelectedIds, setMySelectedIds] = useState(() => new Set());
1916
+ const [recentMedia, setRecentMedia] = useState([]);
1917
+ const [recentTemporaryFiles, setRecentTemporaryFiles] = useState([]);
1918
+ const [recentSelectedIds, setRecentSelectedIds] = useState(() => new Set());
1919
+ const [recentNextCursor, setRecentNextCursor] = useState(null);
1920
+ const [recentLoading, setRecentLoading] = useState(false);
1921
+ const [myFolderPath, setMyFolderPath] = useState("");
1922
+ const [openFolderMenu, setOpenFolderMenu] = useState(null);
1923
+ const [folderDialog, setFolderDialog] = useState(null);
1924
+ const [status, setStatus] = useState(null);
1925
+ const [error, setError] = useState(null);
1926
+ const [confirmDelete, setConfirmDelete] = useState(null);
1927
+ const uploadInputRef = useRef(null);
1928
+ const myInputRef = useRef(null);
1929
+ const recentFiles = useMemo(() => {
1930
+ const generated = recentMedia.map((file) => ({ ...file, recentSource: "generated" }));
1931
+ const temporary = recentTemporaryFiles.map((file) => ({ ...file, recentSource: "temporary" }));
1932
+ return [...temporary, ...generated].sort((a, b) => {
1933
+ const aTime = a.createdAt ? Date.parse(a.createdAt) : 0;
1934
+ const bTime = b.createdAt ? Date.parse(b.createdAt) : 0;
1935
+ return bTime - aTime;
1936
+ });
1937
+ }, [recentMedia, recentTemporaryFiles]);
1938
+ const confirmAttachments = tab === "upload"
1939
+ ? uploadSelected
1940
+ : tab === "my-files"
1941
+ ? myFiles.filter((file) => mySelectedIds.has(file.id))
1942
+ : tab === "recents"
1943
+ ? recentFiles.filter((file) => recentSelectedIds.has(file.id))
1944
+ : [];
1945
+ async function refreshMyFiles() {
1946
+ const next = await listMyFiles(props.apiKey);
1947
+ setMyFiles(next.attachments);
1948
+ setMyFolders(next.folders);
1949
+ }
1950
+ async function refreshRecentMedia(cursor) {
1951
+ setRecentLoading(true);
1952
+ try {
1953
+ const [next, temporary] = cursor
1954
+ ? [await listGeneratedMedia(props.apiKey, cursor), null]
1955
+ : await Promise.all([
1956
+ listGeneratedMedia(props.apiKey),
1957
+ listTemporaryFiles(props.apiKey)
1958
+ ]);
1959
+ setRecentMedia((current) => cursor ? [...current, ...next.media] : next.media);
1960
+ if (temporary) {
1961
+ setRecentTemporaryFiles(temporary.files);
1962
+ }
1963
+ setRecentNextCursor(next.nextCursor);
1964
+ if (!cursor) {
1965
+ setRecentSelectedIds(new Set());
1966
+ }
1967
+ }
1968
+ finally {
1969
+ setRecentLoading(false);
1970
+ }
1971
+ }
1972
+ useEffect(() => {
1973
+ if (!props.open) {
1974
+ return;
1975
+ }
1976
+ setError(null);
1977
+ setStatus(null);
1978
+ void refreshMyFiles().catch((nextError) => setError(nextError instanceof Error ? nextError.message : "Unable to load My Files."));
1979
+ void refreshRecentMedia().catch((nextError) => setError(nextError instanceof Error ? nextError.message : "Unable to load recent media."));
1980
+ }, [props.open]);
1981
+ function resetAndClose() {
1982
+ setUploadSelected([]);
1983
+ setMySelectedIds(new Set());
1984
+ setRecentSelectedIds(new Set());
1985
+ setStatus(null);
1986
+ setError(null);
1987
+ setOpenFolderMenu(null);
1988
+ setFolderDialog(null);
1989
+ props.onClose();
1990
+ }
1991
+ async function copyText(value) {
1992
+ try {
1993
+ if (navigator.clipboard?.writeText) {
1994
+ await navigator.clipboard.writeText(value);
1995
+ }
1996
+ else {
1997
+ const textarea = document.createElement("textarea");
1998
+ textarea.value = value;
1999
+ textarea.style.position = "fixed";
2000
+ textarea.style.opacity = "0";
2001
+ document.body.appendChild(textarea);
2002
+ textarea.focus();
2003
+ textarea.select();
2004
+ document.execCommand("copy");
2005
+ document.body.removeChild(textarea);
2006
+ }
2007
+ setStatus("File URL copied.");
2008
+ }
2009
+ catch {
2010
+ setError("Unable to copy file URL.");
2011
+ }
2012
+ }
2013
+ async function uploadFiles(files, scope) {
2014
+ if (!files.length) {
2015
+ return;
2016
+ }
2017
+ setError(null);
2018
+ const uploaded = [];
2019
+ for (const file of files) {
2020
+ try {
2021
+ setStatus(`Uploading ${file.name}...`);
2022
+ if (scope === "upload") {
2023
+ const temporary = await uploadTemporaryFile({
2024
+ apiKey: props.apiKey,
2025
+ file,
2026
+ folderPath: CHAT_TEMPORARY_UPLOAD_FOLDER
2027
+ });
2028
+ const attachment = await addInlinePreviewForChatUpload({
2029
+ attachment: temporary,
2030
+ file,
2031
+ currentInlineBytes: estimateInlineAttachmentBytes([...uploadSelected, ...uploaded])
2032
+ });
2033
+ uploaded.push(attachment);
2034
+ continue;
2035
+ }
2036
+ const attachment = await uploadAttachment({
2037
+ apiKey: props.apiKey,
2038
+ file,
2039
+ folderPath: myFolderPath,
2040
+ maxBytes: MY_FILES_UPLOAD_LIMIT_BYTES,
2041
+ limitLabel: "50 MB"
2042
+ });
2043
+ uploaded.push(attachment);
2044
+ }
2045
+ catch (nextError) {
2046
+ setError(nextError instanceof Error ? nextError.message : "Upload failed.");
2047
+ }
2048
+ }
2049
+ if (scope === "upload") {
2050
+ setUploadSelected((current) => [...current, ...uploaded]);
2051
+ await refreshRecentMedia().catch((nextError) => setError(nextError instanceof Error ? nextError.message : "Unable to refresh recent files."));
2052
+ }
2053
+ else if (scope === "my-files") {
2054
+ await refreshMyFiles();
2055
+ setMySelectedIds((current) => new Set([...current, ...uploaded.map((file) => file.id)]));
2056
+ }
2057
+ if (uploaded.length) {
2058
+ setStatus(`${uploaded.length} file${uploaded.length === 1 ? "" : "s"} uploaded.`);
2059
+ }
2060
+ }
2061
+ async function deleteFile(file, scope) {
2062
+ const url = scope === "my-files"
2063
+ ? `/api/v1/user/me/attachments/${encodeURIComponent(file.id)}`
2064
+ : `/api/v1/user/me/temporary-files/${encodeURIComponent(file.id)}`;
2065
+ const response = await fetch(url, {
2066
+ method: "DELETE",
2067
+ headers: buildAuthHeaders(props.apiKey, "application/json")
2068
+ });
2069
+ const payload = await response.json().catch(() => ({}));
2070
+ if (!response.ok) {
2071
+ throw new Error(payload.error || "Unable to delete file.");
2072
+ }
2073
+ if (scope === "my-files") {
2074
+ setMyFiles((current) => current.filter((entry) => entry.id !== file.id));
2075
+ setMySelectedIds((current) => {
2076
+ const next = new Set(current);
2077
+ next.delete(file.id);
2078
+ return next;
2079
+ });
2080
+ }
2081
+ setConfirmDelete(null);
2082
+ setStatus("File deleted.");
2083
+ }
2084
+ async function createFolder(scope) {
2085
+ if (scope !== "my-files" || !folderDialog || folderDialog.mode !== "create") {
2086
+ return;
2087
+ }
2088
+ const folderPath = joinFolderPath(folderDialog.baseFolder, folderDialog.value);
2089
+ if (!folderPath) {
2090
+ setError("Choose a folder name.");
2091
+ return;
2092
+ }
2093
+ const response = await fetch("/api/v1/user/me/attachments/folders", {
2094
+ method: "POST",
2095
+ headers: buildAuthHeaders(props.apiKey, "application/json"),
2096
+ body: JSON.stringify({ folder_path: folderPath })
2097
+ });
2098
+ const payload = await response.json().catch(() => ({}));
2099
+ if (!response.ok) {
2100
+ setError(payload.error || "Unable to create folder.");
2101
+ return;
2102
+ }
2103
+ setMyFolders((current) => Array.from(new Set([...current, folderPath])).sort());
2104
+ setFolderDialog(null);
2105
+ setStatus("Folder created.");
2106
+ }
2107
+ async function renameFolder(scope, fromFolder) {
2108
+ if (scope !== "my-files" || !folderDialog || folderDialog.mode !== "rename") {
2109
+ return;
2110
+ }
2111
+ const renamedLeaf = folderDialog.value.trim();
2112
+ const toFolder = joinFolderPath(parentFolderPath(fromFolder), renamedLeaf);
2113
+ if (!toFolder || toFolder === fromFolder) {
2114
+ return;
2115
+ }
2116
+ const response = await fetch("/api/v1/user/me/attachments/folders", {
2117
+ method: "PATCH",
2118
+ headers: buildAuthHeaders(props.apiKey, "application/json"),
2119
+ body: JSON.stringify({ from_folder_path: fromFolder, to_folder_path: toFolder })
2120
+ });
2121
+ const payload = await response.json().catch(() => ({}));
2122
+ if (!response.ok) {
2123
+ setError(payload.error || "Unable to rename folder.");
2124
+ return;
2125
+ }
2126
+ setMyFolderPath((current) => current === fromFolder || current.startsWith(`${fromFolder}/`)
2127
+ ? `${toFolder}${current.slice(fromFolder.length)}`
2128
+ : current);
2129
+ await refreshMyFiles();
2130
+ setOpenFolderMenu(null);
2131
+ setFolderDialog(null);
2132
+ setStatus("Folder renamed.");
2133
+ }
2134
+ async function deleteFolder(scope, folderPath) {
2135
+ if (scope !== "my-files") {
2136
+ return;
2137
+ }
2138
+ const response = await fetch("/api/v1/user/me/attachments/folders", {
2139
+ method: "DELETE",
2140
+ headers: buildAuthHeaders(props.apiKey, "application/json"),
2141
+ body: JSON.stringify({ folder_path: folderPath })
2142
+ });
2143
+ const payload = await response.json().catch(() => ({}));
2144
+ if (!response.ok) {
2145
+ setError(payload.error || "Unable to delete folder.");
2146
+ return;
2147
+ }
2148
+ setMyFolderPath((current) => current === folderPath || current.startsWith(`${folderPath}/`) ? parentFolderPath(folderPath) : current);
2149
+ await refreshMyFiles();
2150
+ setOpenFolderMenu(null);
2151
+ setFolderDialog(null);
2152
+ setStatus("Folder deleted.");
2153
+ }
2154
+ function renderFolderBrowser(scope) {
2155
+ const folders = myFolders;
2156
+ const files = myFiles;
2157
+ const currentFolder = myFolderPath;
2158
+ const setCurrentFolder = setMyFolderPath;
2159
+ const childFolders = getImmediateFolders({ folders, files, currentFolder });
2160
+ const parts = normalizeFolderPath(currentFolder).split("/").filter(Boolean);
2161
+ 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) => {
2162
+ const path = parts.slice(0, index + 1).join("/");
2163
+ 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));
2164
+ })] }), _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] }));
2165
+ }
2166
+ function renderFileCard(file, scope) {
2167
+ const selected = scope === "my-files"
2168
+ ? mySelectedIds.has(file.id)
2169
+ : scope === "recents"
2170
+ ? recentSelectedIds.has(file.id)
2171
+ : false;
2172
+ const copyUrl = "s3Url" in file && file.s3Url ? file.s3Url : file.viewUrl;
2173
+ 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: () => {
2174
+ if (scope === "temporary") {
2175
+ void copyText(copyUrl);
2176
+ return;
2177
+ }
2178
+ const setSelectedIds = scope === "recents" ? setRecentSelectedIds : setMySelectedIds;
2179
+ setSelectedIds((current) => {
2180
+ const next = new Set(current);
2181
+ if (next.has(file.id)) {
2182
+ next.delete(file.id);
2183
+ }
2184
+ else {
2185
+ next.add(file.id);
2186
+ }
2187
+ return next;
2188
+ });
2189
+ }, 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));
2190
+ }
2191
+ function renderFolderDialog() {
2192
+ if (!folderDialog) {
2193
+ return null;
2194
+ }
2195
+ const title = folderDialog.mode === "create"
2196
+ ? "New folder"
2197
+ : folderDialog.mode === "rename"
2198
+ ? "Rename folder"
2199
+ : "Delete folder";
2200
+ const targetPath = folderDialog.mode === "delete" ? folderDialog.folderPath : "";
2201
+ 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) => {
2202
+ if (event.key === "Enter") {
2203
+ event.preventDefault();
2204
+ if (folderDialog.mode === "create") {
2205
+ void createFolder(folderDialog.scope);
2206
+ }
2207
+ else {
2208
+ void renameFolder(folderDialog.scope, folderDialog.folderPath);
2209
+ }
2210
+ }
2211
+ } })), _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" }))] })] }));
2212
+ }
2213
+ if (!props.open) {
2214
+ return null;
2215
+ }
2216
+ const visibleMyFiles = myFiles.filter((file) => normalizeFolderPath(file.folderPath ?? "") === normalizeFolderPath(myFolderPath));
2217
+ 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) => {
2218
+ void uploadFiles(Array.from(event.target.files ?? []), "upload");
2219
+ event.target.value = "";
2220
+ } }), _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) => {
2221
+ void uploadFiles(Array.from(event.target.files ?? []), "my-files");
2222
+ event.target.value = "";
2223
+ } }), 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: () => {
2224
+ props.onConfirm(confirmAttachments);
2225
+ resetAndClose();
2226
+ }, children: "Confirm" })] })] })] }) }), document.body);
961
2227
  }
962
2228
  export function TemplateEditorChat({ boot }) {
2229
+ const isBrainstormSurface = boot.template.templateId === "chat-brainstorm";
963
2230
  const storageKeys = useMemo(() => makeStorageKeys(boot.template.templateId), [boot.template.templateId]);
964
2231
  const chatStorage = useMemo(() => getChatStorage(), []);
965
- const systemMessages = useMemo(() => {
966
- const messages = [];
967
- if (boot.cachedContext.directorSkill?.trim()) {
968
- messages.push(`Director skill context:\n\n${boot.cachedContext.directorSkill.trim()}`);
969
- }
970
- if (boot.cachedContext.templateSkill?.trim()) {
971
- messages.push(`Template skill context:\n\n${boot.cachedContext.templateSkill.trim()}`);
972
- }
973
- return messages;
974
- }, [boot.cachedContext.directorSkill, boot.cachedContext.templateSkill]);
975
2232
  const fileInputRef = useRef(null);
976
2233
  const viewportRef = useRef(null);
977
2234
  const shouldStickToChatBottomRef = useRef(true);
@@ -981,8 +2238,10 @@ export function TemplateEditorChat({ boot }) {
981
2238
  const activeJobPollersRef = useRef(new Map());
982
2239
  const jobPollGateRef = useRef({ nextAvailableAt: 0 });
983
2240
  const threadsRef = useRef([]);
2241
+ const loadedThreadDetailsRef = useRef(new Set());
984
2242
  const [threads, setThreads] = useState([]);
985
2243
  const [activeThreadId, setActiveThreadId] = useState(null);
2244
+ const [loadingThreadId, setLoadingThreadId] = useState(null);
986
2245
  const [openThreadMenu, setOpenThreadMenu] = useState(null);
987
2246
  const [draft, setDraft] = useState("");
988
2247
  const [pendingAttachments, setPendingAttachments] = useState([]);
@@ -992,6 +2251,8 @@ export function TemplateEditorChat({ boot }) {
992
2251
  const [hasHydrated, setHasHydrated] = useState(false);
993
2252
  const [isDropTarget, setIsDropTarget] = useState(false);
994
2253
  const [isMobileChatOpen, setIsMobileChatOpen] = useState(false);
2254
+ const [showHistoryPanel, setShowHistoryPanel] = useState(false);
2255
+ const [isUploadDrawerOpen, setIsUploadDrawerOpen] = useState(false);
995
2256
  const [tracers, setTracers] = useState(() => {
996
2257
  const bridge = typeof window !== "undefined" ? window.__vidfarmEditorTracers : null;
997
2258
  if (bridge) {
@@ -1010,21 +2271,47 @@ export function TemplateEditorChat({ boot }) {
1010
2271
  return null;
1011
2272
  }
1012
2273
  const url = new URL(boot.threadsUrl, window.location.origin);
1013
- url.searchParams.set("template_id", boot.template.templateId);
2274
+ if (!isBrainstormSurface) {
2275
+ url.searchParams.set("template_id", boot.template.templateId);
2276
+ }
2277
+ url.searchParams.set("include_messages", "false");
2278
+ url.searchParams.set("limit", isBrainstormSurface ? "200" : "50");
1014
2279
  return url.toString();
1015
- }, [boot.template.templateId, boot.threadsUrl]);
2280
+ }, [boot.template.templateId, boot.threadsUrl, isBrainstormSurface]);
1016
2281
  const threadDetailUrl = (threadId) => (boot.threadsUrl
1017
2282
  ? new URL(`${boot.threadsUrl.replace(/\/$/, "")}/${encodeURIComponent(threadId)}`, window.location.origin).toString()
1018
2283
  : null);
1019
2284
  useEffect(() => {
1020
- const hydratedThreads = hydrateThreads(chatStorage?.getItem(storageKeys.threads) ?? null);
2285
+ const hydratedThreads = hydrateThreads(chatStorage?.getItem(storageKeys.threads) ?? null, boot.template.templateId);
1021
2286
  const savedActiveThread = chatStorage?.getItem(storageKeys.activeThread) ?? null;
2287
+ const requestedThreadId = readThreadIdFromLocation();
1022
2288
  const currentTracers = window.__vidfarmEditorTracers?.get() ?? readTracersFromLocation();
1023
- const visibleHydratedThreads = hydratedThreads.filter((thread) => !thread.hiddenFromView);
2289
+ const nextHydratedThreads = requestedThreadId && !hydratedThreads.some((thread) => thread.id === requestedThreadId)
2290
+ ? [
2291
+ {
2292
+ id: requestedThreadId,
2293
+ templateId: boot.template.templateId,
2294
+ title: "Loading chat",
2295
+ createdAt: new Date().toISOString(),
2296
+ updatedAt: new Date().toISOString(),
2297
+ lastMessageAt: null,
2298
+ messageCount: 0,
2299
+ tracers: currentTracers,
2300
+ hiddenFromView: false,
2301
+ messages: []
2302
+ },
2303
+ ...hydratedThreads
2304
+ ]
2305
+ : hydratedThreads;
2306
+ const visibleHydratedThreads = nextHydratedThreads.filter((thread) => !thread.hiddenFromView);
1024
2307
  const newestMatchingThread = visibleHydratedThreads.find((thread) => threadMatchesTracers(thread, currentTracers));
1025
- threadsRef.current = hydratedThreads;
1026
- setThreads(hydratedThreads);
1027
- if (savedActiveThread
2308
+ threadsRef.current = nextHydratedThreads;
2309
+ setThreads(nextHydratedThreads);
2310
+ if (requestedThreadId
2311
+ && visibleHydratedThreads.some((thread) => thread.id === requestedThreadId)) {
2312
+ setActiveThreadId(requestedThreadId);
2313
+ }
2314
+ else if (savedActiveThread
1028
2315
  && visibleHydratedThreads.some((thread) => thread.id === savedActiveThread)
1029
2316
  && (!currentTracers.length || visibleHydratedThreads.some((thread) => thread.id === savedActiveThread && threadMatchesTracers(thread, currentTracers)))) {
1030
2317
  setActiveThreadId(savedActiveThread);
@@ -1033,7 +2320,7 @@ export function TemplateEditorChat({ boot }) {
1033
2320
  setActiveThreadId(newestMatchingThread?.id ?? visibleHydratedThreads[0]?.id ?? null);
1034
2321
  }
1035
2322
  setHasHydrated(true);
1036
- }, [chatStorage, storageKeys]);
2323
+ }, [boot.template.templateId, chatStorage, storageKeys]);
1037
2324
  useEffect(() => {
1038
2325
  if (!boot.vidfarmApiKey || !threadListUrl) {
1039
2326
  return;
@@ -1041,22 +2328,45 @@ export function TemplateEditorChat({ boot }) {
1041
2328
  const abortController = new AbortController();
1042
2329
  void (async () => {
1043
2330
  try {
1044
- const response = await fetch(threadListUrl, {
1045
- method: "GET",
1046
- headers: buildAuthHeaders(boot.vidfarmApiKey),
1047
- signal: abortController.signal
1048
- });
1049
- if (!response.ok) {
1050
- return;
1051
- }
1052
- const payload = await response.json().catch(() => null);
1053
- const remoteThreads = Array.isArray(payload?.threads)
1054
- ? payload.threads.map(hydrateThreadRecord).filter((thread) => Boolean(thread))
1055
- : [];
2331
+ const remoteThreads = [];
2332
+ let nextCursor = null;
2333
+ const seenCursors = new Set();
2334
+ do {
2335
+ const pageUrl = new URL(threadListUrl);
2336
+ if (nextCursor) {
2337
+ pageUrl.searchParams.set("cursor", nextCursor);
2338
+ }
2339
+ const response = await fetch(pageUrl.toString(), {
2340
+ method: "GET",
2341
+ headers: buildAuthHeaders(boot.vidfarmApiKey),
2342
+ signal: abortController.signal
2343
+ });
2344
+ if (!response.ok) {
2345
+ return;
2346
+ }
2347
+ const payload = await response.json().catch(() => null);
2348
+ if (Array.isArray(payload?.threads)) {
2349
+ remoteThreads.push(...payload.threads
2350
+ .map((thread) => hydrateThreadRecord(thread, boot.template.templateId))
2351
+ .filter((thread) => Boolean(thread)));
2352
+ }
2353
+ const candidateCursor = typeof payload?.next_cursor === "string" && payload.next_cursor ? payload.next_cursor : null;
2354
+ if (!candidateCursor || seenCursors.has(candidateCursor)) {
2355
+ nextCursor = null;
2356
+ }
2357
+ else {
2358
+ seenCursors.add(candidateCursor);
2359
+ nextCursor = candidateCursor;
2360
+ }
2361
+ } while (nextCursor && !abortController.signal.aborted);
1056
2362
  if (!remoteThreads.length) {
1057
2363
  return;
1058
2364
  }
1059
- setThreads((current) => mergeThreadSets(current, remoteThreads));
2365
+ setThreads((current) => {
2366
+ const nextThreads = mergeThreadSets(current, remoteThreads);
2367
+ threadsRef.current = nextThreads;
2368
+ return nextThreads;
2369
+ });
1060
2370
  setActiveThreadId((current) => {
1061
2371
  if (current && remoteThreads.some((thread) => thread.id === current)) {
1062
2372
  return current;
@@ -1073,7 +2383,7 @@ export function TemplateEditorChat({ boot }) {
1073
2383
  return () => {
1074
2384
  abortController.abort();
1075
2385
  };
1076
- }, [boot.vidfarmApiKey, threadListUrl]);
2386
+ }, [boot.template.templateId, boot.vidfarmApiKey, threadListUrl]);
1077
2387
  useEffect(() => {
1078
2388
  threadsRef.current = threads;
1079
2389
  }, [threads]);
@@ -1098,12 +2408,12 @@ export function TemplateEditorChat({ boot }) {
1098
2408
  return;
1099
2409
  }
1100
2410
  try {
1101
- chatStorage?.setItem(storageKeys.threads, JSON.stringify(serializeThreads(threads)));
2411
+ chatStorage?.setItem(storageKeys.threads, JSON.stringify(serializeThreads(threads, activeThreadId)));
1102
2412
  }
1103
2413
  catch (error) {
1104
2414
  console.warn("Unable to persist editor chat threads.", error);
1105
2415
  }
1106
- }, [chatStorage, hasHydrated, storageKeys, threads]);
2416
+ }, [activeThreadId, chatStorage, hasHydrated, storageKeys, threads]);
1107
2417
  useEffect(() => {
1108
2418
  if (!hasHydrated) {
1109
2419
  return;
@@ -1122,6 +2432,74 @@ export function TemplateEditorChat({ boot }) {
1122
2432
  }, [activeThreadId, chatStorage, hasHydrated, storageKeys]);
1123
2433
  const activeThread = useMemo(() => threads.find((thread) => thread.id === activeThreadId) ?? null, [activeThreadId, threads]);
1124
2434
  const openThreadMenuThread = useMemo(() => threads.find((thread) => thread.id === openThreadMenu?.threadId) ?? null, [openThreadMenu?.threadId, threads]);
2435
+ useEffect(() => {
2436
+ if (!hasHydrated || !boot.vidfarmApiKey || !activeThreadId) {
2437
+ return;
2438
+ }
2439
+ const existingThread = threadsRef.current.find((thread) => thread.id === activeThreadId);
2440
+ if (loadedThreadDetailsRef.current.has(activeThreadId)) {
2441
+ return;
2442
+ }
2443
+ const requestedThreadId = readThreadIdFromLocation();
2444
+ if (existingThread
2445
+ && activeThreadId !== requestedThreadId
2446
+ && (existingThread.messageCount ?? 0) === 0) {
2447
+ loadedThreadDetailsRef.current.add(activeThreadId);
2448
+ return;
2449
+ }
2450
+ const url = threadDetailUrl(activeThreadId);
2451
+ if (!url) {
2452
+ return;
2453
+ }
2454
+ const abortController = new AbortController();
2455
+ const shouldShowLoadingState = !existingThread?.messages.length;
2456
+ if (shouldShowLoadingState) {
2457
+ setLoadingThreadId(activeThreadId);
2458
+ }
2459
+ void (async () => {
2460
+ try {
2461
+ const response = await fetch(url, {
2462
+ method: "GET",
2463
+ headers: buildAuthHeaders(boot.vidfarmApiKey),
2464
+ signal: abortController.signal
2465
+ });
2466
+ if (!response.ok) {
2467
+ const isLocalOnlyThread = Boolean(existingThread && activeThreadId !== requestedThreadId);
2468
+ if (response.status === 404 && !isLocalOnlyThread) {
2469
+ setLastError(`Chat ID ${activeThreadId} was not found for this account.`);
2470
+ }
2471
+ loadedThreadDetailsRef.current.add(activeThreadId);
2472
+ return;
2473
+ }
2474
+ const payload = await response.json().catch(() => null);
2475
+ const remoteThread = payload?.thread ? hydrateThreadRecord(payload.thread, boot.template.templateId) : null;
2476
+ if (!remoteThread) {
2477
+ return;
2478
+ }
2479
+ loadedThreadDetailsRef.current.add(remoteThread.id);
2480
+ setLastError(null);
2481
+ setThreads((current) => {
2482
+ const nextThreads = mergeThreadSets(current, [remoteThread]);
2483
+ threadsRef.current = nextThreads;
2484
+ return nextThreads;
2485
+ });
2486
+ }
2487
+ catch (error) {
2488
+ if (error instanceof DOMException && error.name === "AbortError") {
2489
+ return;
2490
+ }
2491
+ }
2492
+ finally {
2493
+ if (!abortController.signal.aborted) {
2494
+ setLoadingThreadId((current) => (current === activeThreadId ? null : current));
2495
+ }
2496
+ }
2497
+ })();
2498
+ return () => {
2499
+ abortController.abort();
2500
+ setLoadingThreadId((current) => (current === activeThreadId ? null : current));
2501
+ };
2502
+ }, [activeThreadId, boot.vidfarmApiKey, boot.threadsUrl, hasHydrated]);
1125
2503
  useEffect(() => {
1126
2504
  if (!hasHydrated || activeThread && !activeThread.hiddenFromView) {
1127
2505
  return;
@@ -1143,6 +2521,12 @@ export function TemplateEditorChat({ boot }) {
1143
2521
  const isAnyThreadSending = sendingThreadId !== null;
1144
2522
  const isActiveThreadSending = Boolean(activeThreadId && sendingThreadId === activeThreadId);
1145
2523
  const canSend = useMemo(() => (draft.trim() || pendingAttachments.length) && !isAnyThreadSending, [draft, pendingAttachments.length, isAnyThreadSending]);
2524
+ useEffect(() => {
2525
+ if (!hasHydrated || !boot.vidfarmApiKey || !activeThread || !activeThread.messages.length) {
2526
+ return;
2527
+ }
2528
+ resumePersistedJobPolling(activeThread);
2529
+ }, [activeThread, boot.vidfarmApiKey, hasHydrated]);
1146
2530
  async function updateRemoteThreadState(thread, overrides) {
1147
2531
  if (!boot.vidfarmApiKey || !boot.threadsUrl || !thread.messages.length) {
1148
2532
  return;
@@ -1163,6 +2547,37 @@ export function TemplateEditorChat({ boot }) {
1163
2547
  })
1164
2548
  }).catch(() => { });
1165
2549
  }
2550
+ async function appendRemoteThreadExchange(input) {
2551
+ if (!boot.vidfarmApiKey || !boot.threadsUrl || !input.messages.length) {
2552
+ return;
2553
+ }
2554
+ const url = threadDetailUrl(input.threadId);
2555
+ if (!url) {
2556
+ return;
2557
+ }
2558
+ await fetch(`${url}/exchanges`, {
2559
+ method: "POST",
2560
+ headers: buildAuthHeaders(boot.vidfarmApiKey, "application/json"),
2561
+ body: JSON.stringify({
2562
+ template_id: input.templateId ?? boot.template.templateId,
2563
+ title: input.title || "New chat",
2564
+ tracers: input.tracers,
2565
+ messages: input.messages.map((message) => ({
2566
+ id: message.id,
2567
+ role: message.role,
2568
+ text: message.text,
2569
+ attachments: message.attachments.map((attachment) => ({
2570
+ id: attachment.id,
2571
+ fileName: attachment.fileName,
2572
+ contentType: attachment.contentType,
2573
+ viewUrl: attachment.viewUrl
2574
+ })),
2575
+ toolExchanges: message.toolExchanges,
2576
+ httpExchanges: message.httpExchanges?.map(sanitizeHttpExchange)
2577
+ }))
2578
+ })
2579
+ }).catch(() => { });
2580
+ }
1166
2581
  async function deleteRemoteThreadState(threadId) {
1167
2582
  if (!boot.vidfarmApiKey || !boot.threadsUrl) {
1168
2583
  return;
@@ -1343,12 +2758,16 @@ export function TemplateEditorChat({ boot }) {
1343
2758
  const threadId = makeThreadId();
1344
2759
  const nextThread = {
1345
2760
  id: threadId,
2761
+ templateId: boot.template.templateId,
1346
2762
  title: formatThreadTitle(seedText || ""),
1347
2763
  createdAt: now,
1348
2764
  updatedAt: now,
2765
+ lastMessageAt: null,
2766
+ messageCount: 0,
1349
2767
  tracers: normalizeTracerList(tracers),
1350
2768
  messages: []
1351
2769
  };
2770
+ threadsRef.current = [nextThread, ...threadsRef.current];
1352
2771
  setThreads((current) => [nextThread, ...current]);
1353
2772
  setActiveThreadId(threadId);
1354
2773
  return threadId;
@@ -1359,9 +2778,12 @@ export function TemplateEditorChat({ boot }) {
1359
2778
  const threadId = makeThreadId();
1360
2779
  const nextThread = {
1361
2780
  id: threadId,
2781
+ templateId: boot.template.templateId,
1362
2782
  title: options?.title || "New chat",
1363
2783
  createdAt: now,
1364
2784
  updatedAt: now,
2785
+ lastMessageAt: null,
2786
+ messageCount: 0,
1365
2787
  tracers: threadTracers,
1366
2788
  messages: []
1367
2789
  };
@@ -1388,9 +2810,10 @@ export function TemplateEditorChat({ boot }) {
1388
2810
  return;
1389
2811
  }
1390
2812
  const remainingThreads = threads.filter((entry) => entry.id !== threadId);
1391
- for (const [jobId, poller] of activeJobPollersRef.current.entries()) {
2813
+ threadsRef.current = threadsRef.current.filter((entry) => entry.id !== threadId);
2814
+ for (const [pollerKey, poller] of activeJobPollersRef.current.entries()) {
1392
2815
  if (poller.threadId === threadId) {
1393
- stopJobPoller(jobId);
2816
+ stopJobPoller(pollerKey);
1394
2817
  }
1395
2818
  }
1396
2819
  setThreads(remainingThreads);
@@ -1402,10 +2825,9 @@ export function TemplateEditorChat({ boot }) {
1402
2825
  void deleteRemoteThreadState(threadId);
1403
2826
  }
1404
2827
  function updateThread(threadId, updater) {
1405
- setThreads((current) => {
1406
- const next = current.map((thread) => (thread.id === threadId ? updater(thread) : thread));
1407
- return [...next].sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt));
1408
- });
2828
+ const nextThreads = sortThreadsByUpdatedAt(threadsRef.current.map((thread) => (thread.id === threadId ? updater(thread) : thread)));
2829
+ threadsRef.current = nextThreads;
2830
+ setThreads(nextThreads);
1409
2831
  }
1410
2832
  function attachTracersToThread(threadId, nextTracers) {
1411
2833
  const normalized = normalizeTracerList(nextTracers);
@@ -1447,7 +2869,11 @@ export function TemplateEditorChat({ boot }) {
1447
2869
  const normalized = text.toLowerCase();
1448
2870
  return /(?:own|separate|different|new)\s+(?:tracer\s+and\s+)?chat\s+threads?/.test(normalized)
1449
2871
  || /chat\s+threads?.{0,80}(?:own|separate|different|new)\s+tracers?/.test(normalized)
1450
- || /each.{0,80}(?:own|separate|different|new).{0,80}chat\s+threads?/.test(normalized);
2872
+ || /each.{0,80}(?:own|separate|different|new).{0,80}chat\s+threads?/.test(normalized)
2873
+ || /(?:own|separate|different|new)\s+tracers?/.test(normalized)
2874
+ || /(?:each|every|per|one\s+per).{0,80}(?:own|separate|different|new).{0,80}tracers?/.test(normalized)
2875
+ || /(?:own|separate|different|new)\s+tracers?.{0,80}(?:each|every|per|outputs?|pieces?|posts?|jobs?|generations?)/.test(normalized)
2876
+ || /(?:outputs?|pieces?|posts?|jobs?|generations?).{0,80}(?:own|separate|different|new)\s+tracers?/.test(normalized);
1451
2877
  }
1452
2878
  function mirrorJobToTracerThread(input) {
1453
2879
  const existingThreadId = findThreadIdForTracer(input.tracer, input.sourceThreadId);
@@ -1469,22 +2895,33 @@ export function TemplateEditorChat({ boot }) {
1469
2895
  const statusText = input.job.status === "succeeded" || input.job.status === "failed" || input.job.status === "cancelled"
1470
2896
  ? null
1471
2897
  : `Job ${input.job.job_id} is being tracked. ${getJobPollCadenceText()}`;
2898
+ const mirroredMessage = {
2899
+ id: messageId,
2900
+ role: "assistant",
2901
+ text: messageText,
2902
+ attachments: [],
2903
+ httpExchanges: input.httpExchange ? [input.httpExchange] : [],
2904
+ statusText,
2905
+ isPending: false
2906
+ };
1472
2907
  updateThread(targetThreadId, (thread) => ({
1473
2908
  ...thread,
1474
2909
  updatedAt: now,
1475
2910
  messages: [
1476
2911
  ...thread.messages,
1477
- {
1478
- id: messageId,
1479
- role: "assistant",
1480
- text: messageText,
1481
- attachments: [],
1482
- httpExchanges: input.httpExchange ? [input.httpExchange] : [],
1483
- statusText,
1484
- isPending: false
1485
- }
2912
+ mirroredMessage
1486
2913
  ]
1487
2914
  }));
2915
+ const targetThread = threadsRef.current.find((thread) => thread.id === targetThreadId);
2916
+ if (targetThread) {
2917
+ void appendRemoteThreadExchange({
2918
+ threadId: targetThreadId,
2919
+ templateId: targetThread.templateId,
2920
+ title: targetThread.title,
2921
+ tracers: targetThread.tracers,
2922
+ messages: [mirroredMessage]
2923
+ });
2924
+ }
1488
2925
  return { threadId: targetThreadId, messageId };
1489
2926
  }
1490
2927
  function editThreadTracers(threadId) {
@@ -1532,9 +2969,9 @@ export function TemplateEditorChat({ boot }) {
1532
2969
  if (!confirmed) {
1533
2970
  return;
1534
2971
  }
1535
- for (const [jobId, poller] of activeJobPollersRef.current.entries()) {
2972
+ for (const [pollerKey, poller] of activeJobPollersRef.current.entries()) {
1536
2973
  if (poller.threadId === threadId) {
1537
- stopJobPoller(jobId);
2974
+ stopJobPoller(pollerKey);
1538
2975
  }
1539
2976
  }
1540
2977
  updateThread(threadId, (current) => ({
@@ -1588,8 +3025,8 @@ export function TemplateEditorChat({ boot }) {
1588
3025
  left: Math.max(8, Math.min(window.innerWidth - menuWidth - 8, rect.right - menuWidth))
1589
3026
  });
1590
3027
  }
1591
- function stopJobPoller(jobId) {
1592
- const poller = activeJobPollersRef.current.get(jobId);
3028
+ function stopJobPoller(pollerKey) {
3029
+ const poller = activeJobPollersRef.current.get(pollerKey);
1593
3030
  if (!poller) {
1594
3031
  return;
1595
3032
  }
@@ -1597,10 +3034,10 @@ export function TemplateEditorChat({ boot }) {
1597
3034
  window.clearTimeout(poller.timeoutId);
1598
3035
  }
1599
3036
  poller.abortController?.abort();
1600
- activeJobPollersRef.current.delete(jobId);
3037
+ activeJobPollersRef.current.delete(pollerKey);
1601
3038
  }
1602
- function scheduleNextJobPoll(jobId, runPoll, delayMs) {
1603
- const poller = activeJobPollersRef.current.get(jobId);
3039
+ function scheduleNextJobPoll(pollerKey, runPoll, delayMs) {
3040
+ const poller = activeJobPollersRef.current.get(pollerKey);
1604
3041
  if (!poller) {
1605
3042
  return;
1606
3043
  }
@@ -1631,7 +3068,7 @@ export function TemplateEditorChat({ boot }) {
1631
3068
  }
1632
3069
  function getJobPollCadenceText(ms = getJobPollIntervalMs()) {
1633
3070
  return activeJobPollersRef.current.size >= JOB_POLL_BUSY_THRESHOLD
1634
- ? `Many jobs are active, so the next check is in ${formatJobPollInterval(ms)}.`
3071
+ ? `Many renders are active, so the next check is in ${formatJobPollInterval(ms)}.`
1635
3072
  : `Checking again gently every ${formatJobPollInterval(ms)}.`;
1636
3073
  }
1637
3074
  function rescheduleActiveJobPollersForLoad() {
@@ -1656,10 +3093,11 @@ export function TemplateEditorChat({ boot }) {
1656
3093
  return slotAt - now;
1657
3094
  }
1658
3095
  function scheduleJobPolling(input) {
1659
- if (activeJobPollersRef.current.has(input.jobId)) {
3096
+ const pollerKey = makeJobPollerKey(input.threadId, input.assistantMessageId, input.jobId);
3097
+ if (activeJobPollersRef.current.has(pollerKey)) {
1660
3098
  return;
1661
3099
  }
1662
- activeJobPollersRef.current.set(input.jobId, {
3100
+ activeJobPollersRef.current.set(pollerKey, {
1663
3101
  timeoutId: null,
1664
3102
  abortController: null,
1665
3103
  runPoll: null,
@@ -1670,7 +3108,7 @@ export function TemplateEditorChat({ boot }) {
1670
3108
  threadId: input.threadId
1671
3109
  });
1672
3110
  const runPoll = async () => {
1673
- const poller = activeJobPollersRef.current.get(input.jobId);
3111
+ const poller = activeJobPollersRef.current.get(pollerKey);
1674
3112
  if (!poller) {
1675
3113
  return;
1676
3114
  }
@@ -1680,7 +3118,7 @@ export function TemplateEditorChat({ boot }) {
1680
3118
  const now = Date.now();
1681
3119
  const pollIntervalMs = getJobPollIntervalMs();
1682
3120
  if (poller.lastPollAt !== null && now - poller.lastPollAt < pollIntervalMs) {
1683
- scheduleNextJobPoll(input.jobId, runPoll, pollIntervalMs - (now - poller.lastPollAt));
3121
+ scheduleNextJobPoll(pollerKey, runPoll, pollIntervalMs - (now - poller.lastPollAt));
1684
3122
  return;
1685
3123
  }
1686
3124
  if (poller.attempts >= JOB_POLL_MAX_ATTEMPTS) {
@@ -1694,16 +3132,16 @@ export function TemplateEditorChat({ boot }) {
1694
3132
  }
1695
3133
  : message))
1696
3134
  }));
1697
- stopJobPoller(input.jobId);
3135
+ stopJobPoller(pollerKey);
1698
3136
  return;
1699
3137
  }
1700
3138
  if (document.visibilityState !== "visible" || !navigator.onLine) {
1701
- scheduleNextJobPoll(input.jobId, runPoll, pollIntervalMs);
3139
+ scheduleNextJobPoll(pollerKey, runPoll, pollIntervalMs);
1702
3140
  return;
1703
3141
  }
1704
3142
  const globalDelayMs = reserveJobPollSlot(Date.now());
1705
3143
  if (globalDelayMs > 0) {
1706
- scheduleNextJobPoll(input.jobId, runPoll, globalDelayMs);
3144
+ scheduleNextJobPoll(pollerKey, runPoll, globalDelayMs);
1707
3145
  return;
1708
3146
  }
1709
3147
  poller.inFlight = true;
@@ -1722,7 +3160,9 @@ export function TemplateEditorChat({ boot }) {
1722
3160
  }
1723
3161
  : message))
1724
3162
  }));
1725
- const url = `/api/v1/templates/${encodeURIComponent(boot.template.templateId)}/jobs/${encodeURIComponent(input.jobId)}`;
3163
+ const url = input.primitiveId || isPrimitiveRestUrl(input.sourceRequestUrl)
3164
+ ? `/api/v1/primitives/jobs/${encodeURIComponent(input.jobId)}`
3165
+ : `/api/v1/templates/${encodeURIComponent(boot.template.templateId)}/jobs/${encodeURIComponent(input.jobId)}`;
1726
3166
  const response = await fetch(url, {
1727
3167
  method: "GET",
1728
3168
  headers: buildAuthHeaders(boot.vidfarmApiKey),
@@ -1756,11 +3196,11 @@ export function TemplateEditorChat({ boot }) {
1756
3196
  }));
1757
3197
  const nextStatusText = payload
1758
3198
  ? payload.status === "succeeded"
1759
- ? `Job ${payload.job_id} succeeded.`
3199
+ ? `Render ${payload.job_id} succeeded.`
1760
3200
  : payload.status === "failed" || payload.status === "cancelled"
1761
- ? `Job ${payload.job_id} ${payload.status}.${payload.error?.message ? ` ${payload.error.message}` : ""}`
1762
- : `Job ${payload.job_id} is ${payload.status}. Next check in ${formatJobPollInterval(pollIntervalMs)}.`
1763
- : `Job ${input.jobId} checked. Next check in ${formatJobPollInterval(pollIntervalMs)}.`;
3201
+ ? `Render ${payload.job_id} ${payload.status}.${payload.error?.message ? ` ${payload.error.message}` : ""}`
3202
+ : `Render ${payload.job_id} is ${payload.status}. Next check in ${formatJobPollInterval(pollIntervalMs)}.`
3203
+ : `Render ${input.jobId} checked. Next check in ${formatJobPollInterval(pollIntervalMs)}.`;
1764
3204
  return {
1765
3205
  ...message,
1766
3206
  httpExchanges: nextHttpExchanges,
@@ -1772,7 +3212,7 @@ export function TemplateEditorChat({ boot }) {
1772
3212
  })
1773
3213
  }));
1774
3214
  if (payload && (payload.status === "succeeded" || payload.status === "failed" || payload.status === "cancelled")) {
1775
- stopJobPoller(input.jobId);
3215
+ stopJobPoller(pollerKey);
1776
3216
  return;
1777
3217
  }
1778
3218
  }
@@ -1792,7 +3232,7 @@ export function TemplateEditorChat({ boot }) {
1792
3232
  }));
1793
3233
  }
1794
3234
  finally {
1795
- const latestPoller = activeJobPollersRef.current.get(input.jobId);
3235
+ const latestPoller = activeJobPollersRef.current.get(pollerKey);
1796
3236
  if (!latestPoller) {
1797
3237
  return;
1798
3238
  }
@@ -1800,21 +3240,55 @@ export function TemplateEditorChat({ boot }) {
1800
3240
  latestPoller.abortController = null;
1801
3241
  if (latestPoller.attempts < JOB_POLL_MAX_ATTEMPTS
1802
3242
  && latestPoller.timeoutId === null
1803
- && activeJobPollersRef.current.has(input.jobId)) {
1804
- scheduleNextJobPoll(input.jobId, runPoll, getJobPollIntervalMs());
3243
+ && activeJobPollersRef.current.has(pollerKey)) {
3244
+ scheduleNextJobPoll(pollerKey, runPoll, getJobPollIntervalMs());
1805
3245
  }
1806
3246
  }
1807
3247
  };
1808
- const initialPoller = activeJobPollersRef.current.get(input.jobId);
3248
+ const initialPoller = activeJobPollersRef.current.get(pollerKey);
1809
3249
  if (initialPoller) {
1810
3250
  initialPoller.runPoll = runPoll;
1811
3251
  }
1812
- const poller = activeJobPollersRef.current.get(input.jobId);
3252
+ const poller = activeJobPollersRef.current.get(pollerKey);
1813
3253
  if (poller) {
1814
- scheduleNextJobPoll(input.jobId, runPoll, getJobPollIntervalMs());
3254
+ scheduleNextJobPoll(pollerKey, runPoll, input.initialDelayMs ?? getJobPollIntervalMs());
1815
3255
  rescheduleActiveJobPollersForLoad();
1816
3256
  }
1817
3257
  }
3258
+ function resumePersistedJobPolling(thread) {
3259
+ for (const message of thread.messages) {
3260
+ if (message.role !== "assistant" || !message.httpExchanges?.length) {
3261
+ continue;
3262
+ }
3263
+ for (const exchange of message.httpExchanges) {
3264
+ if (exchange.request.method !== "POST") {
3265
+ continue;
3266
+ }
3267
+ const payload = tryReadJobStatusPayload(exchange.result.body);
3268
+ const pollerKey = makeJobPollerKey(thread.id, message.id, payload?.job_id ?? "");
3269
+ if (!payload || isTerminalJobStatus(payload.status) || activeJobPollersRef.current.has(pollerKey)) {
3270
+ continue;
3271
+ }
3272
+ const hasTerminalFollowup = message.httpExchanges.some((candidate) => {
3273
+ const candidatePayload = tryReadJobStatusPayload(candidate.result.body);
3274
+ return candidatePayload?.job_id === payload.job_id && isTerminalJobStatus(candidatePayload.status);
3275
+ });
3276
+ if (hasTerminalFollowup) {
3277
+ continue;
3278
+ }
3279
+ scheduleJobPolling({
3280
+ threadId: thread.id,
3281
+ assistantMessageId: message.id,
3282
+ jobId: payload.job_id,
3283
+ tracer: payload.tracer,
3284
+ initialStatus: payload.status,
3285
+ primitiveId: payload.primitive_id,
3286
+ sourceRequestUrl: exchange.request.url,
3287
+ initialDelayMs: JOB_POLL_MIN_TIMER_MS
3288
+ });
3289
+ }
3290
+ }
3291
+ }
1818
3292
  async function appendFiles(files) {
1819
3293
  if (!files.length) {
1820
3294
  return;
@@ -1824,15 +3298,17 @@ export function TemplateEditorChat({ boot }) {
1824
3298
  let lastUploadError = null;
1825
3299
  for (const file of files) {
1826
3300
  try {
1827
- const transportData = await fileToDataUrl(file);
1828
- const uploaded = await uploadAttachment({
3301
+ const uploaded = await uploadTemporaryFile({
1829
3302
  apiKey: boot.vidfarmApiKey,
1830
- file
3303
+ file,
3304
+ folderPath: CHAT_TEMPORARY_UPLOAD_FOLDER
1831
3305
  });
1832
- uploadedAttachments.push({
1833
- ...uploaded,
1834
- transportData
3306
+ const attachment = await addInlinePreviewForChatUpload({
3307
+ attachment: uploaded,
3308
+ file,
3309
+ currentInlineBytes: estimateInlineAttachmentBytes([...pendingAttachments, ...uploadedAttachments])
1835
3310
  });
3311
+ uploadedAttachments.push(attachment);
1836
3312
  }
1837
3313
  catch (error) {
1838
3314
  lastUploadError = error instanceof Error ? error.message : "Unable to upload attachment.";
@@ -1877,6 +3353,7 @@ export function TemplateEditorChat({ boot }) {
1877
3353
  forceChatBottomOnNextRenderRef.current = true;
1878
3354
  const threadId = ensureActiveThread(draft);
1879
3355
  const now = new Date().toISOString();
3356
+ const threadForSend = threadsRef.current.find((thread) => thread.id === threadId);
1880
3357
  const userMessage = {
1881
3358
  id: makeMessageId("user"),
1882
3359
  role: "user",
@@ -1891,8 +3368,8 @@ export function TemplateEditorChat({ boot }) {
1891
3368
  statusText: "Waiting for AI response…",
1892
3369
  isPending: true
1893
3370
  };
1894
- const existingMessages = threads.find((thread) => thread.id === threadId)?.messages ?? [];
1895
- const historyForTransport = [...existingMessages, userMessage];
3371
+ const existingMessages = threadForSend?.messages ?? [];
3372
+ const historyForTransport = compactTransportHistory([...existingMessages, userMessage]);
1896
3373
  updateThread(threadId, (thread) => ({
1897
3374
  ...thread,
1898
3375
  title: thread.messages.length === 0 ? formatThreadTitle(userMessage.text) : thread.title,
@@ -1911,42 +3388,44 @@ export function TemplateEditorChat({ boot }) {
1911
3388
  assistantMessageId: assistantMessage.id
1912
3389
  };
1913
3390
  try {
3391
+ const requestBody = JSON.stringify({
3392
+ messages: toTransportMessages(historyForTransport),
3393
+ thread_id: threadId,
3394
+ thread_template_id: threadForSend?.templateId ?? boot.template.templateId,
3395
+ thread_title: existingMessages.length === 0 ? formatThreadTitle(userMessage.text) : (threadForSend?.title ?? formatThreadTitle(userMessage.text)),
3396
+ thread_tracers: requestTracers,
3397
+ client_context: buildBrowserDateContext(),
3398
+ user_message: {
3399
+ id: userMessage.id,
3400
+ role: userMessage.role,
3401
+ text: userMessage.text,
3402
+ attachments: userMessage.attachments.map((attachment) => ({
3403
+ id: attachment.id,
3404
+ fileName: attachment.fileName,
3405
+ contentType: attachment.contentType,
3406
+ viewUrl: attachment.viewUrl
3407
+ }))
3408
+ },
3409
+ assistant_message: {
3410
+ id: assistantMessage.id
3411
+ },
3412
+ template: {
3413
+ ...boot.template,
3414
+ tracers: requestTracers,
3415
+ defaultRequestTracer,
3416
+ activeTracer: defaultRequestTracer
3417
+ },
3418
+ cached_context: {
3419
+ ...boot.cachedContext,
3420
+ ...readCachedScheduleChannels(),
3421
+ ...readSelectedLibraryPosts()
3422
+ }
3423
+ });
1914
3424
  const response = await fetch(boot.apiUrl || "/api/v1/editor-chat", {
1915
3425
  method: "POST",
1916
3426
  headers: buildAuthHeaders(boot.vidfarmApiKey, "application/json"),
1917
3427
  signal: abortController.signal,
1918
- body: JSON.stringify({
1919
- messages: toTransportMessages(historyForTransport, systemMessages),
1920
- thread_id: threadId,
1921
- thread_title: existingMessages.length === 0 ? formatThreadTitle(userMessage.text) : (threads.find((entry) => entry.id === threadId)?.title ?? formatThreadTitle(userMessage.text)),
1922
- thread_tracers: requestTracers,
1923
- client_context: buildBrowserDateContext(),
1924
- user_message: {
1925
- id: userMessage.id,
1926
- role: userMessage.role,
1927
- text: userMessage.text,
1928
- attachments: userMessage.attachments.map((attachment) => ({
1929
- id: attachment.id,
1930
- fileName: attachment.fileName,
1931
- contentType: attachment.contentType,
1932
- viewUrl: attachment.viewUrl
1933
- }))
1934
- },
1935
- assistant_message: {
1936
- id: assistantMessage.id
1937
- },
1938
- template: {
1939
- ...boot.template,
1940
- tracers: requestTracers,
1941
- defaultRequestTracer,
1942
- activeTracer: defaultRequestTracer
1943
- },
1944
- cached_context: {
1945
- ...boot.cachedContext,
1946
- ...readCachedScheduleChannels(),
1947
- ...readSelectedLibraryPosts()
1948
- }
1949
- })
3428
+ body: requestBody
1950
3429
  });
1951
3430
  if (!response.ok) {
1952
3431
  const payload = await response.json().catch(() => ({}));
@@ -2049,18 +3528,16 @@ export function TemplateEditorChat({ boot }) {
2049
3528
  })
2050
3529
  : null;
2051
3530
  if (mirroredJobTarget
2052
- && toolResultRecordForMirror?.request?.method === "POST"
2053
- && toolResultRecordForMirror?.response?.status === 202
2054
3531
  && jobStatusPayloadForMirror
2055
- && jobStatusPayloadForMirror.status !== "succeeded"
2056
- && jobStatusPayloadForMirror.status !== "failed"
2057
- && jobStatusPayloadForMirror.status !== "cancelled") {
3532
+ && !isTerminalJobStatus(jobStatusPayloadForMirror.status)) {
2058
3533
  scheduleJobPolling({
2059
3534
  threadId: mirroredJobTarget.threadId,
2060
3535
  assistantMessageId: mirroredJobTarget.messageId,
2061
3536
  jobId: jobStatusPayloadForMirror.job_id,
2062
3537
  tracer: jobStatusPayloadForMirror.tracer,
2063
- initialStatus: jobStatusPayloadForMirror.status
3538
+ initialStatus: jobStatusPayloadForMirror.status,
3539
+ primitiveId: jobStatusPayloadForMirror.primitive_id,
3540
+ sourceRequestUrl: toolResultRecordForMirror?.request?.url
2064
3541
  });
2065
3542
  }
2066
3543
  updateThread(threadId, (thread) => ({
@@ -2102,30 +3579,35 @@ export function TemplateEditorChat({ boot }) {
2102
3579
  }
2103
3580
  attachTracersToThread(threadId, [jobStatusPayload.tracer]);
2104
3581
  }
2105
- if (toolResultRecord?.request?.method === "POST"
2106
- && toolResultRecord?.response?.status === 202
2107
- && jobStatusPayload
3582
+ if (jobStatusPayload
2108
3583
  && !mirroredJobTarget
2109
- && jobStatusPayload.status !== "succeeded"
2110
- && jobStatusPayload.status !== "failed"
2111
- && jobStatusPayload.status !== "cancelled") {
3584
+ && !isTerminalJobStatus(jobStatusPayload.status)) {
2112
3585
  scheduleJobPolling({
2113
3586
  threadId,
2114
3587
  assistantMessageId: assistantMessage.id,
2115
3588
  jobId: jobStatusPayload.job_id,
2116
3589
  tracer: jobStatusPayload.tracer,
2117
- initialStatus: jobStatusPayload.status
3590
+ initialStatus: jobStatusPayload.status,
3591
+ primitiveId: jobStatusPayload.primitive_id,
3592
+ sourceRequestUrl: toolResultRecord?.request?.url
2118
3593
  });
2119
3594
  }
2120
3595
  return {
2121
3596
  ...message,
2122
3597
  toolExchanges: nextToolExchanges,
2123
3598
  httpExchanges: nextHttpExchanges,
3599
+ statusText: jobStatusPayload
3600
+ && !isTerminalJobStatus(jobStatusPayload.status)
3601
+ ? `Job ${jobStatusPayload.job_id} is being tracked. ${getJobPollCadenceText()}`
3602
+ : message.statusText,
3603
+ isPending: false,
2124
3604
  text: jobStatusPayload && toolResultRecord?.request?.method === "POST" && toolResultRecord?.response?.status === 202
2125
3605
  ? appendDistinctMessageText(message.text, buildJobQueuedMessage(jobStatusPayload))
2126
- : jobStatusPayload && (jobStatusPayload.status === "succeeded" || jobStatusPayload.status === "failed" || jobStatusPayload.status === "cancelled")
3606
+ : jobStatusPayload && isTerminalJobStatus(jobStatusPayload.status)
2127
3607
  ? appendDistinctMessageText(message.text, buildJobCompletionMessage(jobStatusPayload))
2128
- : message.text
3608
+ : jobStatusPayload
3609
+ ? appendDistinctMessageText(message.text, buildJobCompletionMessage(jobStatusPayload))
3610
+ : message.text
2129
3611
  };
2130
3612
  })
2131
3613
  }));
@@ -2145,7 +3627,7 @@ export function TemplateEditorChat({ boot }) {
2145
3627
  messages: thread.messages.map((message) => (message.id === assistantMessage.id
2146
3628
  ? {
2147
3629
  ...message,
2148
- statusText: null,
3630
+ statusText: message.statusText?.includes("being tracked") ? message.statusText : null,
2149
3631
  isPending: false
2150
3632
  }
2151
3633
  : message))
@@ -2220,42 +3702,66 @@ export function TemplateEditorChat({ boot }) {
2220
3702
  }
2221
3703
  activeSendRef.current?.abortController.abort();
2222
3704
  }
3705
+ useEffect(() => {
3706
+ return () => {
3707
+ activeSendRef.current?.abortController.abort();
3708
+ for (const pollerKey of activeJobPollersRef.current.keys()) {
3709
+ stopJobPoller(pollerKey);
3710
+ }
3711
+ };
3712
+ }, []);
2223
3713
  function handleComposerKeyDown(event) {
2224
3714
  if (event.key === "Enter" && !event.shiftKey) {
2225
3715
  event.preventDefault();
2226
3716
  void handleSend();
2227
3717
  }
2228
3718
  }
2229
- return (_jsxs("div", { className: "vf-editor-chat-shell", "data-mobile-open": isMobileChatOpen ? "true" : "false", 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: "vf-editor-chat-thread", children: [_jsxs("div", { className: "vf-editor-chat-header", children: [_jsxs("div", { className: "vf-editor-chat-header-row", children: [_jsxs("div", { children: [_jsx("div", { className: "vf-editor-chat-header-title", children: "Copilot" }), _jsx("div", { className: "vf-editor-chat-header-subtitle", children: 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" })] }), _jsx("div", { className: "vf-editor-chat-thread-list", 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: () => {
2230
- setActiveThreadId(thread.id);
2231
- setLastError(null);
2232
- setOpenThreadMenu(null);
2233
- }, 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) => {
2234
- event.stopPropagation();
2235
- toggleThreadMenu(thread.id, event.currentTarget);
2236
- }, children: "..." }) })] }, thread.id))) : (_jsx("div", { className: "vf-editor-chat-thread-empty", children: "Start a chat to create your first conversation thread." })) }), openThreadMenu && openThreadMenuThread ? createPortal(_jsxs("div", { className: "vf-editor-chat-thread-menu", role: "menu", style: {
2237
- top: openThreadMenu.top,
2238
- left: openThreadMenu.left
2239
- }, 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: messages.length ? messages.map((message) => (_jsx(ChatBubble, { message: message }, message.id))) : (_jsxs("div", { className: "vf-editor-chat-empty", children: [_jsx("div", { className: "vf-editor-chat-kicker", children: "Template Copilot" }), _jsx("h2", { children: "Ask it to inspect or call the template REST API." }), _jsx("p", { children: "The sidebar can read skill context, execute template routes, attach POST runs to this thread's tracers, and show each HTTP request and response inline per thread." })] })) }), _jsx("div", { className: "vf-editor-chat-footer", children: _jsxs("div", { className: "vf-editor-chat-composer", "data-drop-target": isDropTarget ? "true" : "false", onDragEnter: (event) => {
2240
- event.preventDefault();
2241
- setIsDropTarget(true);
2242
- }, onDragOver: (event) => {
2243
- event.preventDefault();
2244
- if (!isDropTarget) {
2245
- setIsDropTarget(true);
2246
- }
2247
- }, onDragLeave: (event) => {
2248
- event.preventDefault();
2249
- const nextTarget = event.relatedTarget;
2250
- if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) {
2251
- return;
2252
- }
2253
- setIsDropTarget(false);
2254
- }, 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": "Add attachment", onClick: () => fileInputRef.current?.click(), children: "+" }), _jsx("textarea", { rows: 1, placeholder: "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: () => {
2255
- if (isActiveThreadSending) {
2256
- stopActiveSend();
2257
- return;
2258
- }
2259
- void handleSend();
2260
- }, 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) })] }) })] })] }));
2261
- }
3719
+ 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: () => {
3720
+ setActiveThreadId(thread.id);
3721
+ setLastError(null);
3722
+ setOpenThreadMenu(null);
3723
+ if (isBrainstormSurface) {
3724
+ setIsMobileChatOpen(false);
3725
+ }
3726
+ }, 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) => {
3727
+ event.stopPropagation();
3728
+ toggleThreadMenu(thread.id, event.currentTarget);
3729
+ }, children: "..." }) })] }, thread.id))) : (_jsx("div", { className: "vf-editor-chat-thread-empty", children: "Start a chat to create your first conversation thread." })) }));
3730
+ const openHistoryDrawer = () => setIsMobileChatOpen(true);
3731
+ const closeHistoryDrawer = () => setIsMobileChatOpen(false);
3732
+ const goBack = () => {
3733
+ if (window.history.length > 1) {
3734
+ window.history.back();
3735
+ return;
3736
+ }
3737
+ window.location.href = "/discover";
3738
+ };
3739
+ 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", children: [isBrainstormSurface ? (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", className: "vf-editor-chat-history-scrim", "aria-label": "Close chat history", onClick: closeHistoryDrawer }), _jsxs("aside", { className: "vf-editor-chat-history-pane", children: [_jsxs("div", { className: "vf-editor-chat-history-header", children: [_jsxs("div", { className: "vf-editor-chat-history-header-main", children: [_jsx("button", { type: "button", className: "vf-editor-chat-back-button", onClick: goBack, children: "Back" }), _jsx("div", { className: "vf-editor-chat-history-kicker", children: "Threads" })] }), _jsx("button", { type: "button", className: "vf-editor-chat-history-close", "aria-label": "Close chat history", onClick: closeHistoryDrawer, children: "\u00D7" })] }), _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: [isBrainstormSurface ? (_jsxs("div", { className: "vf-editor-chat-brainstorm-topbar", children: [_jsxs("div", { className: "vf-editor-chat-brainstorm-topbar-left", children: [_jsx("button", { type: "button", className: "vf-editor-chat-history-toggle", onClick: openHistoryDrawer, children: "History" }), _jsx("div", { className: "vf-editor-chat-brainstorm-title", children: boot.template.templateTitle })] }), _jsx("button", { type: "button", className: "vf-editor-chat-new-thread", onClick: createNewThreadFromClick, children: "New chat" })] })) : (_jsxs(_Fragment, { children: [_jsxs("div", { className: "vf-editor-chat-header-row", children: [_jsxs("div", { children: [_jsx("div", { className: "vf-editor-chat-header-title", children: "Copilot" }), _jsx("div", { className: "vf-editor-chat-header-subtitle", children: boot.template.templateTitle })] }), _jsx("button", { type: "button", className: "vf-editor-chat-history-icon", "aria-label": showHistoryPanel ? "Close chat history" : "Open chat history", "aria-pressed": showHistoryPanel, onClick: () => setShowHistoryPanel((prev) => !prev), children: _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-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" })] }), showHistoryPanel ? null : threadListContent] })), openThreadMenu && openThreadMenuThread ? createPortal(_jsxs("div", { className: "vf-editor-chat-thread-menu", role: "menu", style: {
3740
+ top: openThreadMenu.top,
3741
+ left: openThreadMenu.left
3742
+ }, 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 && !isBrainstormSurface ? (_jsxs("div", { className: "vf-editor-chat-history-panel", children: [_jsxs("div", { className: "vf-editor-chat-history-panel-header", children: [_jsx("div", { className: "vf-editor-chat-kicker", children: "Chat history" }), _jsx("button", { type: "button", className: "vf-editor-chat-new-thread", onClick: createNewThreadFromClick, children: "New chat" })] }), threadListContent] })) : 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." : "Build a HyperFrames timeline." }), _jsx("p", { children: isBrainstormSurface
3743
+ ? "Use the brainstorm tools to go from zero context to angles, hooks, and a strategy you can actually test."
3744
+ : "Use the Copilot here to reason about layers, draft overlays, prepare assets, and run HyperFrames render jobs. 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: "teach me how this composition works" }), _jsx("div", { children: "where should the overlay layers go?" }), _jsx("div", { children: "create a timeline render using my video..." })] })) })] })) }), _jsx("div", { className: "vf-editor-chat-footer", children: _jsxs("div", { className: "vf-editor-chat-composer", "data-drop-target": isDropTarget ? "true" : "false", onDragEnter: (event) => {
3745
+ event.preventDefault();
3746
+ setIsDropTarget(true);
3747
+ }, onDragOver: (event) => {
3748
+ event.preventDefault();
3749
+ if (!isDropTarget) {
3750
+ setIsDropTarget(true);
3751
+ }
3752
+ }, onDragLeave: (event) => {
3753
+ event.preventDefault();
3754
+ const nextTarget = event.relatedTarget;
3755
+ if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) {
3756
+ return;
3757
+ }
3758
+ setIsDropTarget(false);
3759
+ }, 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: () => {
3760
+ if (isActiveThreadSending) {
3761
+ stopActiveSend();
3762
+ return;
3763
+ }
3764
+ void handleSend();
3765
+ }, 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]) })] }));
3766
+ }
3767
+ //# sourceMappingURL=template-editor-chat.js.map