@officexapp/vidfarm-devcli 0.21.26 → 0.21.27

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.
@@ -591,6 +591,7 @@
591
591
  background:#fff;border:1px solid var(--rk-border);border-radius:var(--rk-r-full);padding:4px 11px;text-decoration:none;line-height:1.4;
592
592
  transition:background var(--rk-dur) var(--rk-ease),border-color var(--rk-dur) var(--rk-ease)}
593
593
  .rk-aichat-preview-act:hover{background:var(--rk-gold-tint);border-color:var(--rk-gold-600)}
594
+ .rk-aichat-preview-act[hidden]{display:none}
594
595
 
595
596
  /* ── markdown pipe tables (assistant messages + .md previews) ── */
596
597
  .rk-md-tablewrap{max-width:100%;overflow-x:auto;margin:8px 0;border:1px solid var(--rk-border);border-radius:var(--rk-r-lg)}
@@ -622,6 +623,14 @@
622
623
  .rk-aichat-file-thumb--markdown,.rk-aichat-file-thumb--text{background:var(--rk-n-50)}.rk-aichat-file-thumb--audio{background:var(--rk-gold-tint2)}
623
624
  .rk-aichat-file-play{position:absolute;inset:0;display:grid;place-items:center;font-size:22px;color:#fff;text-shadow:0 1px 6px rgba(0,0,0,.55);pointer-events:none}
624
625
  .rk-aichat-file-name{font-size:11px;font-weight:600;color:var(--rk-n-600);line-height:1.3;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:112px}
626
+ /* "Edit in the tool" pencil — only on image/video cards; opens /tools/image or
627
+ /tools/video in a new tab with this file preloaded as the working file. */
628
+ .rk-aichat-file-open{position:absolute;top:5px;right:5px;width:24px;height:24px;border-radius:var(--rk-r-md);
629
+ display:grid;place-items:center;background:rgba(255,255,255,.94);border:1px solid var(--rk-border);color:var(--rk-ink);
630
+ opacity:0;transition:opacity var(--rk-dur) var(--rk-ease),background var(--rk-dur) var(--rk-ease);z-index:2}
631
+ .rk-aichat-file-open svg{width:13px;height:13px}
632
+ .rk-aichat-file:hover .rk-aichat-file-open,.rk-aichat-file-open:focus-visible{opacity:1}
633
+ .rk-aichat-file-open:hover{background:var(--rk-gold-tint)}
625
634
 
626
635
  /* create_video pipeline: live status row + clickable result links */
627
636
  .rk-aichat-flow{display:flex;align-items:center;gap:9px;font-size:12.5px;color:var(--rk-text-muted);padding:2px 2px}
@@ -1378,6 +1387,7 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
1378
1387
  </div>
1379
1388
  <div class="rk-inpaint-toolbar">
1380
1389
  <button type="button" class="rk-btn rk-btn-ghost rk-btn-sm rk-inpaint-startnew" data-ipv-start-new><svg width="14" height="14" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 4v12"/><path d="M4 10h12"/></svg> Start New</button>
1390
+ <button type="button" class="rk-ip-iconbtn" data-ipv-copy-chat aria-label="Copy chat ID" title="Copy chat ID"><svg width="13" height="13" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><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"/><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"/></svg></button>
1381
1391
  <button type="button" class="rk-ip-iconbtn" data-ipv-settings-button aria-label="Settings" title="Settings"><svg width="16" height="16" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M8.8 3.4 9.4 2h1.2l.6 1.4 1.2.5 1.4-.6.9.9-.6 1.4.5 1.2 1.4.6v1.2l-1.4.6-.5 1.2.6 1.4-.9.9-1.4-.6-1.2.5-.6 1.4H9.4l-.6-1.4-1.2-.5-1.4.6-.9-.9.6-1.4-.5-1.2L4 8.6V7.4l1.4-.6.5-1.2-.6-1.4.9-.9 1.4.6 1.2-.5Z"/><circle cx="10" cy="8" r="2.4"/></svg></button>
1382
1392
  <button type="button" class="rk-ip-iconbtn" data-ipv-history-toggle aria-label="Past sessions" title="Past sessions"><svg width="16" height="16" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M3.2 10a6.8 6.8 0 1 1 2 4.8"/><path d="M3.2 14.4v-3.6h3.6"/><path d="M10 6.2V10l2.6 1.6"/></svg></button>
1383
1393
  </div>
@@ -1797,7 +1807,14 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
1797
1807
  sessionTracer = value; tracerAutoPending = false; if(tracerInput) tracerInput.value = value; return value;
1798
1808
  }
1799
1809
  function resolveTracer(){ if(sessionTracer) return sessionTracer; var v = tracerInput ? tracerInput.value.trim() : ""; return v; }
1800
- function getTracerValue(){ if(sessionTracer) return sessionTracer; var v = tracerInput ? tracerInput.value.trim() : ""; if(v){ sessionTracer = v; tracerAutoPending = false; return v; } return nameTracerFromSeed("video-edit"); }
1810
+ function getTracerValue(){ if(sessionTracer) return sessionTracer; var v = tracerInput ? tracerInput.value.trim() : ""; if(v){ sessionTracer = v; tracerAutoPending = false; return v; } return commitRandomTracer(); }
1811
+ // A session with nothing to name it from yet (e.g. Copy chat ID before the first
1812
+ // prompt) gets a plain id — seeding nameTracerFromSeed("video-edit") here produced
1813
+ // the doubled "video-edit-video-edit-…".
1814
+ function commitRandomTracer(){
1815
+ var value = "video-edit-" + tracerSuffix();
1816
+ sessionTracer = value; tracerAutoPending = false; if(tracerInput) tracerInput.value = value; return value;
1817
+ }
1801
1818
 
1802
1819
  // ===== provider list =====
1803
1820
  function loadProviders(){
@@ -2186,6 +2203,20 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
2186
2203
  var path = String((entry && entry.path) || "").toLowerCase();
2187
2204
  return tid === "primitive:video_generate" || path.indexOf("/primitives/videos/generate") >= 0 || path.indexOf("/primitives/video_generate") >= 0;
2188
2205
  }
2206
+ // The user's message for a run-history entry. New turns carry chat_message on the
2207
+ // envelope; older ones are un-composed back out of the instruction we built.
2208
+ function userTextFromEntry(entry){
2209
+ var body = (entry && entry.request && entry.request.body) || {};
2210
+ if(typeof body.chat_message === "string" && body.chat_message.trim()) return body.chat_message.trim();
2211
+ var payload = body.payload || {};
2212
+ var raw = String(payload.prompt || payload.instruction || "");
2213
+ if(!raw) return "";
2214
+ var whole = raw.split("Whole clip: ")[1];
2215
+ if(whole) return whole.split(". Timed region edits:")[0].split(". Match the surrounding lighting")[0].trim();
2216
+ var timed = raw.split("Timed region edits: ")[1];
2217
+ if(timed) return timed.split(". Match the surrounding lighting")[0].trim();
2218
+ return raw.length > 240 ? raw.slice(0, 240) + "\u2026" : raw;
2219
+ }
2189
2220
  function extractHistoryItems(entries){
2190
2221
  var items = []; var seen = {};
2191
2222
  (entries || []).forEach(function(entry){
@@ -2204,16 +2235,38 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
2204
2235
  });
2205
2236
  return items;
2206
2237
  }
2238
+ // Set while opening a past session: the next history load restores that session's
2239
+ // conversation state (its last prompt back in the composer, its newest video in the
2240
+ // Result panel) instead of just repainting the rail.
2241
+ var sessionRestorePending = false;
2242
+ function restoreSessionFromEntries(entries){
2243
+ var rows = (entries || []).filter(isVideoEntry)
2244
+ .sort(function(a, b){ return Date.parse(a.createdAt || 0) - Date.parse(b.createdAt || 0); });
2245
+ if(!rows.length){ setStatus("Opened " + resolveTracer() + " \u2014 no messages in it yet.", ""); return; }
2246
+ var last = rows[rows.length - 1];
2247
+ var text = userTextFromEntry(last);
2248
+ if(text && globalPrompt && !globalPrompt.value.trim()) globalPrompt.value = text;
2249
+ // Newest output of the session becomes the visible result.
2250
+ for(var i = rows.length - 1; i >= 0; i--){
2251
+ var urls = historyEntryUrls(rows[i]);
2252
+ if(urls[0]){
2253
+ try { resultVideo.src = absoluteUrl(urls[0]); outputPanel.hidden = false; } catch(_){}
2254
+ break;
2255
+ }
2256
+ }
2257
+ setStatus("Session restored \u2014 " + rows.length + " message" + (rows.length === 1 ? "" : "s") + ". Keep editing to pick up where you left off.", "success");
2258
+ }
2207
2259
  function loadHistory(){
2208
2260
  var tracer = resolveTracer();
2209
- if(!tracer){ historyItems = []; reinjectSources(); renderHistory(); return; }
2261
+ if(!tracer){ historyItems = []; reinjectSources(); renderHistory(); sessionRestorePending = false; return; }
2210
2262
  if(historyRefresh) historyRefresh.disabled = true;
2211
2263
  var endpoint = new URL("/job-runs/history", window.location.origin);
2212
2264
  endpoint.searchParams.set("limit", "120"); endpoint.searchParams.set("template_id", "primitive:video_generate"); endpoint.searchParams.set("tracer", tracer);
2213
2265
  fetch(endpoint, { credentials: "same-origin" }).then(function(r){ return parseJsonResponse(r).then(function(p){ return { ok: r.ok, p: p }; }); }).then(function(res){
2214
2266
  if(!res.ok || (res.p && res.p.error)) throw new Error((res.p && res.p.error) || "Unable to load history.");
2215
2267
  historyItems = extractHistoryItems(res.p.entries); reinjectSources(); renderHistory();
2216
- }).catch(function(err){ setHistoryEmpty(err && err.message ? err.message : "Unable to load history."); }).then(function(){ if(historyRefresh) historyRefresh.disabled = false; });
2268
+ if(sessionRestorePending){ sessionRestorePending = false; restoreSessionFromEntries(res.p.entries); }
2269
+ }).catch(function(err){ sessionRestorePending = false; setHistoryEmpty(err && err.message ? err.message : "Unable to load history."); }).then(function(){ if(historyRefresh) historyRefresh.disabled = false; });
2217
2270
  }
2218
2271
  function applyCompletedJob(payload){
2219
2272
  var out = extractVideoUrl(payload); if(!out) return "";
@@ -2267,13 +2320,19 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
2267
2320
  setSubmitLoading(true);
2268
2321
  var seed = (globalPrompt && globalPrompt.value.trim()) || (masks.find(function(m){ return String(m.prompt || "").trim(); }) || {}).prompt || "video-edit";
2269
2322
  var tracer = tracerAutoPending ? nameTracerFromSeed(seed) : getTracerValue();
2323
+ // The message the USER actually wrote (not the compiled instruction). It rides
2324
+ // along on the primitive envelope as chat_message — zod strips it server-side,
2325
+ // but the api-call-history middleware records the raw body, so past sessions can
2326
+ // be titled/replayed by their conversation exactly like /tools/image.
2327
+ var chatMessage = (globalPrompt && globalPrompt.value.trim())
2328
+ || (masks.map(function(m){ return String(m.prompt || "").trim(); }).filter(Boolean)[0] || "");
2270
2329
  var payload = { prompt: instruction, output_format: "mp4", aspect_ratio: getAspectRatio() };
2271
2330
  if(frameImgs.length) payload.frame_images = frameImgs;
2272
2331
  if(providerSelect && providerSelect.value) payload.provider = providerSelect.value;
2273
2332
  if(durationSelect && durationSelect.value) payload.duration = Number(durationSelect.value);
2274
2333
  if(audioToggle && audioToggle.checked) payload.generate_audio = true;
2275
2334
  setStatus("Creating video job\u2026", "");
2276
- fetch("/api/v1/primitives/videos/generate", { method: "POST", credentials: "same-origin", headers: { "content-type": "application/json" }, body: JSON.stringify({ tracer: tracer, payload: payload }) })
2335
+ fetch("/api/v1/primitives/videos/generate", { method: "POST", credentials: "same-origin", headers: { "content-type": "application/json" }, body: JSON.stringify({ tracer: tracer, payload: payload, chat_message: chatMessage, chat_mode: "video", chat_masked: masks.length > 0 }) })
2277
2336
  .then(function(r){ return parseJsonResponse(r).then(function(p){ return { ok: r.ok, p: p }; }); })
2278
2337
  .then(function(res){ if(!res.ok || !res.p.job_id) throw new Error(res.p.error || "Unable to create video job."); setStatus("Video job " + res.p.job_id + " started \u2014 generating\u2026", "success"); upsertHistory(makePending(res.p)); return pollJob(res.p.job_id); })
2279
2338
  .then(function(){ loadHistory(); loadSessions(); })
@@ -2306,8 +2365,16 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
2306
2365
  (res.p.entries || []).forEach(function(entry){
2307
2366
  if(!isVideoEntry(entry)) return; var tracer = String(entry.tracer || "").trim(); if(!tracer) return;
2308
2367
  var urls = historyEntryUrls(entry);
2309
- if(!groups[tracer]){ groups[tracer] = { tracer: tracer, thumb: "", count: 0, createdAt: entry.createdAt || "" }; order.push(tracer); }
2310
- var g = groups[tracer]; if(!g.thumb && urls[0]){ try { g.thumb = absoluteUrl(urls[0]); } catch(_){} } g.count += urls.length;
2368
+ if(!groups[tracer]){ groups[tracer] = { tracer: tracer, thumb: "", title: "", count: 0, createdAt: entry.createdAt || "", firstAt: entry.createdAt || "" }; order.push(tracer); }
2369
+ var g = groups[tracer];
2370
+ // One history entry = one message in this session's conversation.
2371
+ g.count += 1;
2372
+ if(!g.thumb && urls[0]){ try { g.thumb = absoluteUrl(urls[0]); } catch(_){} }
2373
+ // Oldest message names the session (same rule as /tools/image).
2374
+ if(!g.title || Date.parse(entry.createdAt || 0) <= Date.parse(g.firstAt || 0)){
2375
+ var text = userTextFromEntry(entry);
2376
+ if(text){ g.title = text.length > 60 ? text.slice(0, 60) + "\u2026" : text; g.firstAt = entry.createdAt || g.firstAt; }
2377
+ }
2311
2378
  if(Date.parse(entry.createdAt || 0) > Date.parse(g.createdAt || 0)) g.createdAt = entry.createdAt;
2312
2379
  });
2313
2380
  var list = order.map(function(t){ return groups[t]; }).sort(function(a, b){ return Date.parse(b.createdAt || 0) - Date.parse(a.createdAt || 0); });
@@ -2324,16 +2391,20 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
2324
2391
  if(group.thumb){ var v = document.createElement("video"); v.className = "rk-ip-session-thumb"; v.src = group.thumb + "#t=0.1"; v.muted = true; v.preload = "metadata"; row.appendChild(v); }
2325
2392
  else { var ph = document.createElement("span"); ph.className = "rk-ip-session-thumb is-empty"; ph.innerHTML = PLAY_SVG; row.appendChild(ph); }
2326
2393
  var meta = document.createElement("div"); meta.className = "rk-ip-session-meta";
2327
- var title = document.createElement("div"); title.className = "rk-ip-session-title"; title.textContent = group.tracer;
2394
+ var title = document.createElement("div"); title.className = "rk-ip-session-title"; title.textContent = group.title || group.tracer;
2395
+ title.title = group.title ? group.title + " (" + group.tracer + ")" : group.tracer;
2328
2396
  var sub = document.createElement("div"); sub.className = "rk-ip-session-sub"; var when = relTimeShort(group.createdAt);
2329
- sub.textContent = (group.count ? group.count + " video" + (group.count === 1 ? "" : "s") : "session") + (when ? " \u00b7 " + when : "");
2397
+ sub.textContent = (group.count ? group.count + " message" + (group.count === 1 ? "" : "s") : "session") + (when ? " \u00b7 " + when : "");
2330
2398
  meta.appendChild(title); meta.appendChild(sub); row.appendChild(meta); sessionsList.appendChild(row);
2331
2399
  });
2332
2400
  }
2333
2401
  function switchToTracer(tracer){
2334
2402
  var value = String(tracer || "").trim(); if(!value) return;
2335
- sessionTracer = value; if(tracerInput) tracerInput.value = value; tracerAutoPending = false; closeSessions(); loadHistory(); loadSessions();
2336
- setStatus("Opened session " + value + ".", "");
2403
+ clearUrlParams(); resetWorkspace();
2404
+ sessionTracer = value; if(tracerInput) tracerInput.value = value; tracerAutoPending = false; closeSessions();
2405
+ setStatus("Opening " + value + "\u2026", "");
2406
+ sessionRestorePending = true;
2407
+ loadHistory(); loadSessions();
2337
2408
  }
2338
2409
 
2339
2410
  // ===== reset =====
@@ -2378,6 +2449,13 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
2378
2449
  if(brushInput){ brushInput.addEventListener("input", function(){ if(brushLabel) brushLabel.textContent = brushInput.value; updateBrushCursorSize(); }); }
2379
2450
  if(uploadBtn && fileInput){ uploadBtn.addEventListener("click", function(){ fileInput.click(); }); fileInput.addEventListener("change", function(){ if(fileInput.files && fileInput.files[0]) loadSourceFile(fileInput.files[0]); }); }
2380
2451
  if(startNewBtn) startNewBtn.addEventListener("click", startNew);
2452
+ // Copy chat ID — the session tracer IS this chat's thread id (what /job-runs/history
2453
+ // groups on and what ?tracer= reopens). An unnamed session mints its id here.
2454
+ var copyChatBtn = document.querySelector("[data-ipv-copy-chat]");
2455
+ if(copyChatBtn) copyChatBtn.addEventListener("click", function(){
2456
+ var tracer = resolveTracer() || getTracerValue();
2457
+ copyText(tracer).then(function(ok){ setStatus(ok ? "Chat ID copied \u2014 " + tracer : "Chat ID: " + tracer, ok ? "success" : ""); });
2458
+ });
2381
2459
  if(addMaskBtn) addMaskBtn.addEventListener("click", function(){ addMask(videoEl.currentTime || 0, colorPicker ? colorPicker.value : null); });
2382
2460
  if(colorPicker) colorPicker.addEventListener("input", function(){ var m = activeMask(); if(m){ m.color = normalizeHex(colorPicker.value); renderMasks(); redraw(); } });
2383
2461
  if(undoBtn) undoBtn.addEventListener("click", function(){ var m = activeMask(); if(m && m.strokes.length){ m.redo.push(m.strokes.pop()); redraw(); syncUndoRedo(); } });
@@ -2485,7 +2563,7 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
2485
2563
  function onGenFileEsc(e){ if(e.key === "Escape") closeGenFilePanel(); }
2486
2564
  function openGenFilePanel(){
2487
2565
  var dir = window.__vidfarmDirectory; if(!dir || !dir.pick){ setGenStatus("File browser isn't available right now.", "error"); return; }
2488
- dir.pick({ select: "file", roots: ["files","temp","raws"], title: "Attach reference images", hint: "Pick one or more images to guide the generation.", confirmLabel: "Attach", multiple: true,
2566
+ dir.pick({ select: "file", roots: ["files","temp","raws"], title: "Attach reference images", hint: "Pick one or more images to guide the generation \u2014 or drop / upload them from your computer.", confirmLabel: "Attach", multiple: true, allowUpload: true,
2489
2567
  onPick: function(picked){ var arr = Array.isArray(picked) ? picked : [picked]; arr.forEach(function(f){ var url = f && (f.url || f.viewUrl); if(url){ try { genRefs.push(absoluteUrl(url)); } catch(_){} } }); renderGenRefs(); } });
2490
2568
  }
2491
2569
  // Paste or drop image files straight into the composer -> temp upload -> reference.
@@ -3116,6 +3194,34 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
3116
3194
  }
3117
3195
  function fileIcon(kind) { return kind === 'pdf' ? '📕' : kind === 'json' ? '{ }' : kind === 'markdown' ? '📝' : kind === 'audio' ? '🎵' : kind === 'text' ? '📄' : '📄'; }
3118
3196
 
3197
+ // ── "edit this media" hand-off: dock chat → the matching creative tool ──
3198
+ // Images open /tools/image, videos /tools/video, each with the file preloaded as
3199
+ // that tool's working file (its chat then edits THAT media). Deliberately a fresh
3200
+ // tool session — no ?tracer — so the tool names its own chat from the first prompt.
3201
+ var PENCIL_SVG = '<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M13.6 3.6a1.9 1.9 0 0 1 2.8 2.8L7.7 15.1l-3.6.8.8-3.6z"/></svg>';
3202
+ function toolEditUrl(file) {
3203
+ if (!file || !file.viewUrl) return '';
3204
+ var k = fileKind(file.name, file.contentType);
3205
+ var name = file.name || '';
3206
+ if (k === 'image') return '/tools/image?source_image_url=' + encodeURIComponent(file.viewUrl) + (name ? '&source_image_name=' + encodeURIComponent(name) : '');
3207
+ if (k === 'video') return '/tools/video?source_video_url=' + encodeURIComponent(file.viewUrl) + (name ? '&source_video_name=' + encodeURIComponent(name) : '');
3208
+ return '';
3209
+ }
3210
+ function toolEditLink(file, className) {
3211
+ var href = toolEditUrl(file);
3212
+ if (!href) return null;
3213
+ var a = document.createElement('a');
3214
+ a.className = className;
3215
+ a.href = href;
3216
+ a.target = '_blank';
3217
+ a.rel = 'noopener noreferrer';
3218
+ a.title = 'Edit in the ' + (fileKind(file.name, file.contentType) === 'video' ? 'video' : 'image') + ' tool';
3219
+ a.setAttribute('aria-label', a.title);
3220
+ // The card itself opens the lightbox; the pencil must not also trigger it.
3221
+ a.addEventListener('click', function (e) { e.stopPropagation(); });
3222
+ return a;
3223
+ }
3224
+
3119
3225
  // Compact markdown → HTML for .md previews (headings, lists, quotes, rules,
3120
3226
  // fenced + inline code, bold/italic/links). Escapes first, then injects tags
3121
3227
  // (same order as linkify). Backticks are built via char codes to avoid
@@ -3176,7 +3282,9 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
3176
3282
  var dlBtn = document.createElement('a'); dlBtn.className = 'rk-aichat-preview-act'; dlBtn.target = '_blank'; dlBtn.rel = 'noopener noreferrer'; dlBtn.textContent = 'Download';
3177
3283
  var linkBtn = document.createElement('button'); linkBtn.type = 'button'; linkBtn.className = 'rk-aichat-preview-act'; linkBtn.textContent = 'Copy link';
3178
3284
  var xBtn = document.createElement('button'); xBtn.type = 'button'; xBtn.className = 'rk-aichat-modal-x'; xBtn.setAttribute('aria-label', 'Close'); xBtn.innerHTML = '&times;';
3179
- head.appendChild(nameEl); head.appendChild(copyBtn); head.appendChild(dlBtn); head.appendChild(linkBtn); head.appendChild(xBtn);
3285
+ // Same hand-off as the card pencil, spelled out as a labelled action here.
3286
+ var editBtn = document.createElement('a'); editBtn.className = 'rk-aichat-preview-act'; editBtn.target = '_blank'; editBtn.rel = 'noopener noreferrer'; editBtn.textContent = 'Edit';
3287
+ head.appendChild(nameEl); head.appendChild(editBtn); head.appendChild(copyBtn); head.appendChild(dlBtn); head.appendChild(linkBtn); head.appendChild(xBtn);
3180
3288
  function flashAct(btn, label) { var o = btn.getAttribute('data-label') || btn.textContent; btn.setAttribute('data-label', o); btn.textContent = label; setTimeout(function () { btn.textContent = o; }, 1400); }
3181
3289
  // Copy = file text (json/md/txt) or its URL for binaries. Copy link = the
3182
3290
  // durable public S3 URL. Download opens in a new tab when the browser can't
@@ -3225,6 +3333,9 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
3225
3333
  var it = items[idx];
3226
3334
  nameEl.textContent = it.name || 'file'; nameEl.title = it.name || '';
3227
3335
  dlBtn.href = it.viewUrl; dlBtn.setAttribute('download', it.name || '');
3336
+ var editHref = toolEditUrl(it);
3337
+ editBtn.hidden = !editHref;
3338
+ if (editHref) { editBtn.href = editHref; editBtn.title = 'Edit in the ' + (fileKind(it.name, it.contentType) === 'video' ? 'video' : 'image') + ' tool'; }
3228
3339
  linkBtn.style.display = it.publicUrl ? '' : 'none';
3229
3340
  body.innerHTML = '';
3230
3341
  var kind = fileKind(it.name, it.contentType);
@@ -3279,7 +3390,15 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
3279
3390
  var files = [], seen = {};
3280
3391
  var arts = (job && job.artifacts) || [];
3281
3392
  for (var i = 0; i < arts.length; i++) { var f = rkFileFromArtifact(arts[i]); if (f && !seen[f.viewUrl]) { seen[f.viewUrl] = true; files.push(f); } }
3282
- var mu = rkResultMediaUrl(job); if (mu && !seen[mu]) { seen[mu] = true; files.push({ name: (job.tracer || 'output'), contentType: '', viewUrl: mu, publicUrl: mu }); }
3393
+ // Name the direct result URL after the FILE, not the tracer fileKind() reads the
3394
+ // extension, and a tracer-named entry ("my-poster") classifies as 'other', losing
3395
+ // the image/video thumbnail and the edit-in-tool hand-off.
3396
+ var mu = rkResultMediaUrl(job);
3397
+ if (mu && !seen[mu]) {
3398
+ seen[mu] = true;
3399
+ var seg = String(mu).split('?')[0].split('/').pop() || '';
3400
+ files.push({ name: (/.[a-z0-9]{2,5}$/i.test(seg) ? seg : (job.tracer || 'output')), contentType: '', viewUrl: mu, publicUrl: mu });
3401
+ }
3283
3402
  var res = job && job.result;
3284
3403
  if (res && typeof res === 'object' && !Array.isArray(res) && Object.keys(res).length) {
3285
3404
  try { var blob = new Blob([JSON.stringify(res, null, 2)], { type: 'application/json' }); files.push({ name: (job.tracer || 'result') + '.json', contentType: 'application/json', viewUrl: URL.createObjectURL(blob), publicUrl: null }); } catch (e) {}
@@ -3332,14 +3451,21 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
3332
3451
  var grid = document.createElement('div'); grid.className = 'rk-aichat-files-grid';
3333
3452
  list.forEach(function (f, ix) {
3334
3453
  var k = fileKind(f.name, f.contentType);
3335
- var card = document.createElement('button'); card.type = 'button'; card.className = 'rk-aichat-file';
3454
+ // A div, not a button: media cards carry a nested "edit in tool" link, and
3455
+ // an anchor inside a button is invalid (and unclickable in some browsers).
3456
+ var card = document.createElement('div'); card.className = 'rk-aichat-file'; card.tabIndex = 0; card.setAttribute('role', 'button');
3336
3457
  var thumb = document.createElement('div'); thumb.className = 'rk-aichat-file-thumb rk-aichat-file-thumb--' + k;
3337
3458
  if (k === 'image') { var im = document.createElement('img'); im.src = f.viewUrl; im.alt = ''; im.loading = 'lazy'; thumb.appendChild(im); }
3338
3459
  else if (k === 'video') { var v = document.createElement('video'); v.src = f.viewUrl; v.muted = true; v.preload = 'metadata'; v.playsInline = true; thumb.appendChild(v); var pl = document.createElement('span'); pl.className = 'rk-aichat-file-play'; pl.textContent = '▶'; thumb.appendChild(pl); }
3339
3460
  else { var ic = document.createElement('span'); ic.className = 'rk-aichat-file-ic'; ic.textContent = fileIcon(k); thumb.appendChild(ic); }
3461
+ var edit = toolEditLink(f, 'rk-aichat-file-open');
3462
+ if (edit) { edit.innerHTML = PENCIL_SVG; thumb.appendChild(edit); }
3340
3463
  var nm = document.createElement('div'); nm.className = 'rk-aichat-file-name'; nm.textContent = f.name || 'file'; nm.title = f.name || '';
3341
3464
  card.appendChild(thumb); card.appendChild(nm);
3342
3465
  card.addEventListener('click', function () { openFilePreview(list, ix); });
3466
+ card.addEventListener('keydown', function (e) {
3467
+ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openFilePreview(list, ix); }
3468
+ });
3343
3469
  grid.appendChild(card);
3344
3470
  });
3345
3471
  wrap.appendChild(grid); turn.appendChild(wrap); logBottom();